Первое знакомство с ReasonML

September 12, 2019

Photo by unsplash-logoRobert Bye

Откуда он взялся?

Первое, что хочется отметить, формально, reason не является новым языком программирования. Это новый синтаксис для уже существующего языка ocaml. Что, как минимум, говорит о том, что язык проверен временем ✅.

Чаще всего о reason'е говорят в контексте javascript'а и reactjs, и не с проста: наряду с новым синтаксисом разрабатывается ещё и компилятор в js - bucklescript, а его (reason) разработкой руководит Jordan Walke, ранее создавший react.

Чего от него ждать? Где использовать?

Повторюсь, reason не новый язык, а синтаксис. Ocaml является языком общего назначения и применяется в самых разных областях (украл с офф сайта):

  • автоматические доказыватели теорем 🤷‍♂️
  • компиляторы и интерпретаторы 🙆🏾
  • анализаторы программ 🔍
  • обучение программированию 👩🏽‍🎓

Тут вот можно найти истории успеха его использования, в т.ч. и более прикладные.

Reason тоже уже активно используется и можно найти интересные и актуальные примеры на нём:

  • revery - фреймворк для разработки кросс-платформенных десктопных приложений 💻
  • onivim - среда разработки, построенная на 👆
  • rely - jest-like blazing fast native test framework ⚡️
  • ...

И что будет более интересным для react разработчиков: поговаривают, что reason станет лучшей платформой для реакта, ведь:

  • он функциональный, а значит более идиоматичный для реакта.
    js тоже можно назвать функциональным языком, но в отличии от js в reason есть:

  • статически типизированный, с мощным механизмом выведения типов
    Опять же тема, казалась бы, не нова для фронтенд сообщества: typescript, flow хорошо себя зарекомендовали, я себе не представляю старт проекта без них. Но reason это совершенно новый уровень: в нём объявлять типы практически не нужно, даже для параметров функции!

    Также reason в отличии от них типизированный как бы изначально. В то время как typescript'у и flow приходится бороться/приспосабливаться к динамической природе js, н.р. есть такое понятие как писать код, который можно (или легче) типизировать.
    Не думаю, что у меня получится раскрыть эту тему лучше, чем у Jordan Walk в этой ветке:
  • у него отличный interprop с js - можно использовать js в reason 😲и наоборот reason в js 🙃
    Результат компиляции bucklescript'ом минималистичный, оптимизированный для производительности и, что не маловажно, читабельный 📖!
  • его можно компилировать в машинный код 🤪а это значит вы можете получить максимальную производительность.
    Особенно это интересно с точки зрения мобильных приложений. Может скоро js'у не будет места в react native, и мы будем компилировать reason в нативный код?

От слов к делу

Ну теперь, я надеюсь, вы заинтересовались языком, и мы можем попробовать что-нибудь на нём изобразить. Долго думать над приложением, которое мы будем писать, мне не пришлось. Ещё с университетских времён я знаком с игрой "Жизнь". Первую реализацию, как мне кажется, я делал на pascal'е, потом был delphi, c++, java, js и вот теперь reason. Я считаю, что реализация этой игры является отличным способом поизучать язык: там и какой-никакой алгоритм нужно реализовать, поработать с коллекциями, написать UI...

Посмотреть на готовую реализацию и исходный код, кстати, уже можно здесь, т.к. начал я этот проект несколько месяцев назад. Но и то и другое скорее всего будет меняться во время этого цикла статей: надеюсь я буду находить лучшие решения.

Место действия этой игры - вселенная, предлагаю и начать с её сотворения.

Variant!

Согласно википедии, вселенная у нас - плоскость, каждая клетка которой может иметь одно из двух состояний: живая (заполненная) 💃 и мёртвая (пустая) 🙅‍♂️.

В js, да и во многих других языках, я бы прибегнул к использованию boolean или строки ('alive' | 'dead') для описания этого состояния. Но первый подход, на мой взгляд, не самый прозрачный/декларативный, а второй мне не нравится тем, что строки и без того многолики: они и текст хранят, и ключами в объектах/картах выступают и наверняка что-то ещё 🤔. В typescript есть enum, что уже ближе к тому, с чем хотелось бы работать. Но в reason есть нечто лучшее - variant'ы. Чем они лучше?

  1. Они дают бОльшую типо-безопасность - reason заставляет проверить все кейсы variant'ов при работе с ними. Встроеным variant'ом является option, тот самый, что спасёт нас от null'ов и undefined'ов, которых в reason нет. В случае с option значение у нас может быть - Some('a) или не быть - None.
  2. Они могут хранить одно или несколько значений внутри.
  3. Они идут вкупе с другим мощным механизмом языка - сопоставлением с образцом (pattern matching)

Пока давайте остановимся на том, что объявим наш variant:

type cellState =
  | Dead
  | Alive;

let myCell = Alive;

Наглядно, не правда ли?

Теперь давайте создадим вселенную - плоскость. Для этого, очевидно, мы будем использовать двумерный массив. Т.к. как цель статьи - изучения языка, давайте попробуем сами реализовать функции, необходимые для создания массивов.

Т.к. двумерный массив ничто иное как массив массивов, начнём мы с функции создания массива, причём не пустого, а наполненного необходимыми значениями. Т.е. я хочу написать функцию, которая принимает 2 значения: длину создаваемого массива и функцию инициализатор, которая будет получать в качестве аргумента индекс инициализируемого элемента (обычно это очень удобно, далее покажу почему). Такие функции в функциональном программировании (далее просто ФП) носят называние функций высшего порядка, т.к. в качестве аргументов они принимают другие функции (или возвращают их).

Рекурсивное создание массива

Recursion

Другой особенностью языков ФП является то, что в них как правило отсутствуют операторы управления потоком исполнения (🤯): if/else, for/while... т.к. они уводят разработчика от описания вычислений в описание команд машине (не придумал как лучше выразиться 🤷‍♂️). Да и вообще как заметил в одном из своих докладов Виталий Брагилевский ни чем не лучше всеми ненавистного оператора goto. И не смотря на то, что в reason циклы есть, мы попытаемся воспользоваться идеоматической конструкцией - рекурсией.

let init = (l, fn) => {
  /*
   * на каждом шаге рекурсии
   * добавляем новый элемент массива
   * и передаём результат в следующий шаг
   */
  let rec _init = (l, arr) =>
    l > 0 ? _init(l - 1, concat(arr, [|fn(l)|])) : arr;

  _init(l, [||]);
};

Для людей пришедших из js, незнакомыми тут являются 2 конструкции:

  • rec - в reason мы вынуждены явно указать, что наша функция будет рекурсивной
  • [||] - литерал создания массива (более привычный [] создаст список)

Tail call optimisation

Кто-то, возможно, заметит, что эту функцию можно было бы реализовать и без дополнительной внутренней, а кто-то, что создавать массивы рекурсивно не комильфо - ведь мы можем переполнить стек. И как ни странно оба эти утверждения связаны 😮.

Да, я действительно мог записать эту функцию несколько короче:

let rec init = (l, fn) =>
  l > 0 ? concat(init(l - 1, fn), [|fn(l)|]) : [||];

Но именно эта реализация и привела бы к потенциальному переполнению стека 😆. Почему? Функциональные языки программирования появились не вчера, и компиляторы умеют хорошо их оптимизировать. Примером такой оптимизации является tail call. Если коротко, то суть в том, что можно не создавать фрейм в стеке для рекурсивного вызова, если он является последним действием в функции. За счёт этого производительность такой рекурсии будет равна итерации (и вообще может быть ей заменена компилятором). Поподробнее об этом можно прочитать здесь.

И если посмотреть на первую реализацию, то видно что в функции _init её рекурсивный вызов и является последним, а значит наш компилятор должен её оптимизировать 😌.

И тут искушённый читатель может заметить, что не смотря на то, что ECMAScript 6 предлагает tail call оптимизацию, большинство браузеров реализовать её пока не смогли. И он будет прав 😭. Но! Reason ведь не js, и у него есть свой компилятор - BuckleScript. И он не полагается на интерпретаторы js и сам реализует эту оптимизацию, заменяя эту контрукцию на while 🤪. Так будет выглядить скомпилированная функция:

function init(l, fn) {
  var _l = l
  var _arr = /* array */ []
  while (true) {
    var arr = _arr
    var l$1 = _l
    var match = l$1 > 0
    if (match) {
      _arr = /* array */ [Curry._1(fn, (l$1 - 1) | 0)].concat(arr)
      _l = (l$1 - 1) | 0
      continue
    } else {
      return arr
    }
  }
}

Императивность под капотом декларативности

Scooby doo mask reveal

Ещё одним замечанием, которое напрашивается, является конкатенация массивов на каждом шаге. И не смотря на то, что я являюсь противником преждевременных оптимизаций, не обратить на это внимание в статье я не могу. Мы действительно создаём массив из одного элемента, а потом копируем его и массив полученный на предыдущем шаге в новый массив. На создание массива из 10к элеметов наша функция тратит ~60ms на моём macbook pro. И с этим магический компилятор reason'а уже ничего поделать не может. Но и не должен!

  • Данная функция лишь пример того, как мы можем использовать рекурсию вместо императивных циклов
  • Reason не запрещает использовать циклы
  • Также можно использовать более идеоматические для ФП структуры данных.
    Например использование списков в рекурсивной функции, а затем преобразование этого списка в массив позволяет создать массив из миллиона элементов за ~300ms (ведь добавление элемента в список очень дешёвое)
  • У reason есть стандартная библиотека Belt которая предоставляет аналогичный метод makeBy, который смог создать массив из 10млн элеметов с теже ~60ms

Он, кстати, под капотом имеет ту самую императивщину:

function makeByU(l, f) {
  if (l <= 0) {
    return /* array */ []
  } else {
    var res = new Array(l)
    for (var i = 0, i_finish = (l - 1) | 0; i <= i_finish; ++i) {
      res[i] = f(i)
    }
    return res
  }
}

Типизация

А вы заметили, что мы не описали ни одного типа? Но reason при этом вывел следующий тип для нашей функции: (int, int => 'a) => Js_array.t('a), что означает: фунция которая на вход принимает целое число и функцию, которая тоже принимает целое число а возвращает что-то (a'), а наша исходная функция вернёт массив этого чего-то. Так мы с вами это и задумывали: целое число - это размер создаваемого массива, функция - наш инициализатор, ну и результат действительно массив результатов инициализатора. Кстати, плагин для WebStorm показывает выведенные типы следующим образом:

Webstorm plugin displays inferred types

Магия не правда ли? Для меня, как для человека большую часть времени работающего с typescript'ом - правда. Как это работает? Ну... Reason это ведь новый синтаксис для OCaml, который является реализацией языка Caml, который, в свою очередь, принадлежит семейству ML, в котором используется система типов Хиндли—Милнера (надеюсь не ошибся в этой цепочке 🤞). Именно эта система то и позволяет автоматически выводить типы для выражений. Из теории это всё, что на данный момент могу сказать 😳. На практике же, как вы убедились, это означает что нам вовсе не надо описывать типы чтобы получить статическую типизацию.

Создание матрицы

Neo stands in front of matrix of TVs

Ну теперь когда мы умеем создавать массивы, можно и создать массив массивов. Для этого предлагаю использовать библиотечную функцию makeBy которая сигнатурой не отличается от созданной нами, но на несколько порядков производительнее:

let makeMatrixBy = (dimx, dimy, fn) =>
  makeBy(dimy, y => makeBy(dimx, x => fn((x, y))));

Ну тут вроде всё просто: принимаем размеры матрицы и функцию инициализатор, возвращаем массив размером dimy каждый элемент которого это массив размером dimx, наполненный значениями которые возвращает функция fn.

Tuple

Но особенность тут всё же есть - fn((x, y)). Я не ошибся, это не лишние скобочки, это кортеж (tuple) - неизменяемый, упорядоченный, разнородный список значений фиксированного размера. Как говорит официальная документация эта структура данных очень удобна, когда вам нужно передать или вернуть несколько значений без лишних церемоний.

Ну с неизменяемостью и фиксированным размером, я думаю, всё понятно. Упорядоченность элементов позволяет обойтись без имён свойств. А благодаря разнородности мы можем хранить значения разных типов в одном кортеже.

Т.е. tuple может заменить нам объекты, но я бы не стал их использовать для сущности с более чем тремя атрибутами - сложно будет запомнить, что зачем идёт. А вот для хранения пары координат они подходят идеально 😉.

В js, кстати, аналогом могут выступить массивы, для них (как и в reason для tuple) есть очень удобный синтаксис деструктуризации: let [x, y] = coordinates; (в reason: let (x, y) = coordinates;).

Ну давайте наконец попробуем создать случайно заполненную вселенную:

let universe = makeMatrixBy(5, 5, _ => Js_math.random() > 0.5 ? Alive : Dead);
Js.log(universe);

Что в консоли хрома даёт нам вот такую картинку:

Матрица в консоли хрома

Итого

Не смотря на то, что с точки зрения игры мы продвинулись не так далеко, мы теперь знаем что такое reason, откуда он пришёл и где его можно использовать, а также разобрали несколько языковых конструкций.

Надеюсь, вам было интересно, и вы ещё придёте читать другие статьи про reason и не только.

А ещё не забывайте давать обратную связь. Сделать это можно через комментарии, а можно выделив текст на странице отправить сообщение о какой-либо неточности.