Пишемо на 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.error log
setTimeout(render_process, 500)


І все! До божевілля просто і ефективно. Про оптимізації відтворення піклується сам react, ми тепер можемо взагалі не цікавитися цим питанням. Залишається забезпечити зміни об'єкта state таким чином, щоб це відповідало функціональної парадигми. І тут починається найцікавіше.

Trouble in the Kitchen
Перша і не дуже значна проблема — м'які типи 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 "[Boolean object]" then some
when "[object Number]" then some
when "[object String]" then some
when "[object Function]" then some.bind({})
when "[object Null]" null then
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 "[Boolean object]" 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.error log
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 "привіт")
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!

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.