Пишем на JS в функционально-декларативном стиле
Я люблю функциональные языки за их простоту, ясность и предсказуемость. Пишу в основном на Elixir / Erlang / OTP, пробовал другие языки, но Erlang с его акторами пока мне гораздо ближе чем например Lisp или Haskell. Как известно Erlang == web, а у чего-либо написанного для веба порой бывает клиентский веб-интерфейс: html, css, js — содержимое. Увы js это стандарт современного веба, для него есть библиотеки почти под любую задачу почти на все случаи жизни, да и это более-менее единственное доступное средство что-то выполнить в браузере на стороне клиента. Поэтому нам всё-таки нужен js. Сперва мне подумалось «Лямбды и функции высшего порядка есть, значит писать на js будет просто. Выучу синтаксис и буду писать так же как пишу в Erlang/Lisp/Haskell». Как же я ошибался.
Начнём с того что чистый js для написания кода совершенно не годится. От обилия скобочек и точек с запятой рябит в глазах. Слово return написанное где-либо в теле функции совершенно непрозрачно намекает на императивность языка и рушит мою веру в лучшее. Есть много языков, в т.ч. и функциональных (purescript, fay, clojurescript) компилируемых в js. Но для себя я выбрал coffeescript — довольно компромиссный вариант, который могут понимать как функциональщики, так и императивщики. Отчасти этот выбор был обоснован тем что я использую для сборки проектов brunch, который написан на coffeescript. Ну и по сравнению например с fay, накладные расходы для перехода с js на coffeescript почти равны 0.
Второй интригующий вопрос — как именно связать наш код и html-страничку. Вариантов реально много. Есть куча огромных фреймворков, которые на самом деле выглядят довольно целостно. Я сам какое-то время пользовался angularjs, но после некоторой практики стали видны очевидные минусы: для того чтобы сделать что-либо, нужны директивы, если их нет — надо писать свои велосипеды, а разобраться во внутреннем устройстве ангуляра сложнее чем кажется, также если посмотреть откровенно — самая часто используемая директива ng-model обеспечивает двустороннее связывание данных и представления, что с точки зрения функиональщика совершенно идеоматически неверно и вообще нарушает инкапсуляцию, кроме того все эти ангуляровские приложения-котроллеры итд итп довольно сильно утяжеляют код. Да, и кстати говоря производительность ангуляра реально так себе. Некоторое время назад я познакомился с react js — и мой выбор пал не него. Идея подкупает своей простотой и более-менее функционально-декларативным стилем. Есть state, который в процессе работы приложения может как-то меняться. Мы просто время от времени передаём его в jreact для отрисовки.
widget = require("widget")
do_render = () -> React.render(widget(state), domelement) if domelement?
render_process = () ->
try
do_render()
catch error
console.log error
setTimeout(render_process, 500)
И всё! До безумия просто и эффективно. Об оптимизации отрисовки заботится сам react, мы теперь можем вообще не интересоваться этим вопросом. Остаётся обеспечить изменения объекта state таким образом чтобы это соответствовало функциональной парадигме. И тут начинается самое интересное.
Первая и не очень значительная проблема — мягкие типы js. В Erlang они конечно тоже мягкие, но в js они рельно мягкие как, извините за выражение, говно. Вот например не очень содержательное, но довольно смешное видео на эту тему. Но на практике преобразования типов случаются нечасто (во всяком случае если ваш код хорош) — так что мягкие типы js я принял более-менее как они есть.
Когда я начал понемногу практиковаться в js сперва всё было более-менее хорошо, но в какой-то момент приложения почему-то начинали работать совсем не так как я хотел. Я полез глубже и увидел страшное:
coffee> map = {a: 1}
{ a: 1 }
coffee> lst = []
[]
coffee> lst.push map
1
coffee> map.a = 2
2
coffee> lst.push map
2
coffee> map.a = 3
3
coffee> lst.push map
3
coffee> lst
[ { a: 3 }, { a: 3 }, { a: 3 } ]
хотя в этом случае я конечно ожидал увидеть
coffee> lst
[ { a: 1 }, { a: 2 }, { a: 3 } ]
Это реально был шок. Бинго, данные в js мутабельны! Причём как оказалось — всё что сложнее чем number, string, null и undefined будет передаваться по ссылке!
Но когда я увидел что
coffee> [1,2,3] == [1,2,3]
false
coffee> {a: 1} == {a: 1}
false
то волосы у меня зашевелились в самых разных местах. К такому жизнь меня не готовила. Оказывается типы данных maps и lists в js сравниваются не по значению, а тоже по ссылке.
Я стал думать как быть. Касаемо мутабельности например было решено оборачивать константные данные (напрмиер для инициализации каких-либо значений) в лямбда-функции арности ноль, в таких случаях они действительно оставались в общем-то не мутабельными. Можно просто вызывать их в тех выражениях где они нужны и не бояться что они (данные) при этом изменятся.
coffee> const_lst = () -> [1,2,3]
[Function]
coffee> new_lst = const_lst().concat([4,5,6])
[ 1, 2, 3, 4, 5, 6 ]
coffee> const_lst()
[ 1, 2, 3 ]
В принципе, подумалось мне, если рекурсивно оборачивть лямбдами вообще все даннные, если все функции будут принимать лямбды и возвращать лямбды — язык станет действительно функциональным! В принципе — это решение. Нужно просто описать эти лямбда-типы данных на основе обычных типов, написать функции для рекурсивного прямого и обратного преобразования в обычные js типы, а также функции высшего порядка для работы с этими лямбда-типами (map, reduce, filter, zip итд итп). Заодно кстати можно и сделать эти новые типы менее мягкими. Задача в принципе решаемая, но довольно объёмная, отчасти кстати уже реализованная например вот в этой библиотеке. Но такой подход имеет довольно существенные недостатки:
1) Так как наш код как правило не подвешен в воздухе, а имеет зависисимости от других js-библиотек, всякий раз обращаясь к ним надо не забыть сделать преобразование лямбда-типа в обычный тип, и соответсвенно наоборот.
2) Этим подходом мы конечно в какой-то степени обеспечим чистоту функций и иммутабельность данных, но транзакционности всё равно не будет
3) Такой код будет не очень понятен тем кто предпочитает императивный подход
Таким образом от этой идеи я пока отказался (но стоит ей уделить внимание в будущем) и решил сделать что-то менее радикальное, но такое же простое и понятное. Очевидно чтобы локально решить проблему мутабельности и сравнения данных по ссылке мне нужно было научиться рекурсивно копировать и сравнивать по значению любые js-данные.
Пишем функцию clone
clone = (some) ->
switch Object.prototype.toString.call(some)
when "[object Undefined]" then undefined
when "[object Boolean]" then some
when "[object Number]" then some
when "[object String]" then some
when "[object Function]" then some.bind({})
when "[object Null]" then null
when "[object Array]" then some.map (el) -> clone(el)
when "[object Object]" then Object.keys(some).reduce ((acc, k) -> acc[clone(k)] = clone(some[k]); acc), {}
Пишем функцию equal
equal = (a, b) ->
[type_a, type_b] = [Object.prototype.toString.call(a), Object.prototype.toString.call(b)]
if type_a == type_b
switch type_a
when "[object Undefined]" then a == b
when "[object Boolean]" then a == b
when "[object Number]" then a == b
when "[object String]" then a == b
when "[object Function]" then a.toString() == b.toString()
when "[object Null]" then a == b
when "[object Array]"
len_a = a.length
len_b = b.length
if len_a == len_b
[0..len_a].every (n) -> equal(a[n], b[n])
else
false
when "[object Object]"
keys_a = Object.keys(a).sort()
keys_b = Object.keys(b).sort()
if equal(keys_a, keys_b)
keys_a.every (k) -> equal(a[k], b[k])
else
false
else
false
Оказалось проще чем я думал, единственное «но» — если в данных будут циклические ссылки, конечно получим stack overflow. Для меня это в принципе не проблема, так как я не использую такие абстракции как «циклические ссылки». Мне думается можно как-то и их обрабатывать в процессе клонирования данных, но тогда конечно код не будет таким простым и изящным. В общем я собрал эти и некоторые другие функции в библиотеку и считаю что проблема мутабельности данных в js на какое-то время для меня решена.
Теперь поговорим зачем нам транзакционность изенения данных. Допустим есть какой-то более-менее сложный state и мы его меняем в процессе выполнения какой-либо функции.
state.aaa = 20
state.foo = 100
state.bar = state.bar.map (el) -> baz(el, state)
На практике процесс изменения state конечно может быть более сложным и длинным, содержать асинхронные вызовы к внешним api итд итп. Но суть в том что если где-то в процессе изменения state где-то в другом месте вызовется функция func (state) — что произойдёт? Будет ли наполовину изменённый state при этом валидным? А может в силу однопоточности js полу-изменённого state вообще не будет существовать и всё нормально? А если нет? А что делать если мне надо сделать какие-то внешние вызовы и жизненно важно чтобы пока я их делаю state на менялся? Чтобы не ломать голову такими непростыми вопросами сделаем изменения state транзакционными.
Тут думаю многие вспомят о мьютексах. Я тоже вспомнил о мьютексах. И о состояниях гонки. И о дедлоках. И понял что я хочу совсем не этого, поэтому будем писать не мьютекс, а позаимствуем из языка Erlang понятие «актор». В контексте js актор будет просто неким объектом который инициализируется определённым state, и будет делать всего три вещи
1) принимать «сообщения» в виде функций арности 1 или 0 и добавлять их в очередь
2) самостоятельно «разгребать» очередь сообщений, применяя функции арности 1 к своему внутреннему state (функции арности 0 просто вызываются, state не меняется) — всё это делается строго в том порядке в каком были получены сообщения.
3) по требованию вернуть значение своего внутреннего state
Естественно для соблюдения не-мутабельности данных будем каждый раз клонировать state при изменении, и в функции get будем возвращать не сам state, а его копию. Для этого будем использовать библиотеку, написанную ранее. В итоге получим код.
window.Act = (init_state, timeout) ->
obj = {
#
# priv
#
state: Imuta.clone(init_state)
queue: []
init: () ->
try
@state = Imuta.clone(@queue.shift()(@state)) while @queue.length != 0
catch error
console.log "Actor error"
console.log error
this_ref = this
setTimeout((() -> this_ref.init()), timeout)
#
# public
#
cast: (func) ->
if (func.length == 1) and Imuta.is_function(func)
@queue.push(Imuta.clone(func))
@queue.length
else
throw(new Error("Act expects functions arity == 1 (single arg is actor's state)"))
zcast: (func) ->
if (func.length == 0) and Imuta.is_function(func)
@queue.push( ((state) -> Imuta.clone(func)(); state) )
@queue.length
else
throw(new Error("Act expects functions arity == 0"))
get: () ->
Imuta.clone(@state)
}
obj.init()
obj
* Функции которые кладут лямбду в очередь называются cast и zcast по аналогии с эрланговскими функциями handle_cast
Поскольку не все js-данные клонируемы (вспомним о циклических ссылках и внешних библиотеках), сделаем ещё один вариант конструктора для актора в котором уберём клонирование внутреннего state и соберём всё это дело в библиотеку.
Наслаждаемся:
coffee> actor = new Act({a: 1}, "pure", 500)
{ state: { a: 1 },
queue: [],
init: [Function],
cast: [Function],
zcast: [Function],
get: [Function] }
coffee> actor.cast((state) -> state.b = 1; state)
1
coffee> actor.get()
{ a: 1, b: 1 }
coffee> actor.cast((state) -> state.c = 1; state)
1
coffee> value = actor.get()
{ a: 1, b: 1, c: 1 }
coffee> value.d = 123
123
coffee> value
{ a: 1, b: 1, c: 1, d: 123 }
coffee> actor.get()
{ a: 1, b: 1, c: 1 }
coffee> actor.zcast(() -> console.log "hello")
1
coffee> hello
coffee> actor.get()
{ a: 1, b: 1, c: 1 }
coffee> global_var = {foo: "bar"}
{ foo: 'bar' }
coffee> actor.cast((_) -> global_var)
1
coffee> actor.get()
{ foo: 'bar' }
coffee> global_var.baz = "baf"
'baf'
coffee> global_var
{ foo: 'bar', baz: 'baf' }
coffee> actor.get()
{ foo: 'bar' }
Все изменения state осуществляем исключительно через очередь посредствам функции cast. Как мы можем видеть в «чистом» варианте state полностью инкапсулирован внутри актора, что бы мы ни делали с ним после получения из функции get (то же самое верно если мы добавляем что-то из внешнего мира в функции cast). Транзакционность обеспечивается очередью сообщений. Мы получили практически эрланговский код, только на js. Если мы хотим использовать в своём state по какой-то причине не-клонируемые данные, то будем просто использовать «грязный» вариант актора с глобальным state. В принципе даже такой вариант (если менять state строго через актор) приемлим и обеспечивает транзакционность изменения данных. Была ещё мысль сделать изменения данных не просто транзакционными, а в каком-то смысле даже атомарными, передавая не одну лямбду, а три (например на случай каких-то эксепшнов во внешних библиотеках).
actor.cast(
{
prepare: (state) -> prepare_process(state)
apply: (state, args) -> do_work(state, args)
rollback: (state, args) -> do_rollback(state, args, error)
})
Но я подумал что вот это уже перегиб, тем более что в нынешнем варианте можно просто писать
actor.cast((state) ->
args = init_args(state)
try
do_work(state, args)
catch error
rollback(state, args, error))
если вдруг атомарность так жизненно необходима.
Js оказался в итоге не так безнадёжен как мне показалось в начале. За его лямбда-функциями скрывается настоящая функциональная мощь и при должном уровне сноровки на нём можно писать в довольно-таки декларативном стиле. И на закуску простой пример с использованием акторов + react + jade + sass + bullet (эрланговские вебсокеты). Stay functional, stay web!