Это примерный текст выступления "Функциональное реактивное программирование", которое Алексей Осипенко и я проводили на ноябрьской встрече Донецкого Лямбда-клуба.
Мир основан на изменяющемся состоянии. Мы живём во времени и думаем во времени, и разнообразнейшие события прилетают откуда ни возьмись, и тоже во времени. Как организовать приложение, для которого эти события являются вводом - то есть, любое интерактивное приложение? А если хочется не иметь изменяемого состояния, иметь чистые функции и работать как можно более декларативно? На эти вопросы отвечает функционально-реактивное программирование (functional reactive programming). Мы постараемся рассказать о таком подходе.
Нам очень нравится определение функционального программирования из выступления Дэниэла Спивака “Жизнь в пост-функциональном мире”. Это “функция как основная единица абстракции”. Основа основ функцинального программирования - это композируемые примитивы для решения широкого класса задач. За счёт этого о программах, написанных полностью в функциональной парадигме, легко рассуждать в терминах математики - они предсказуемы и даже доказуемы.
“Функциональное программирование объединяет гибкость и мощь абстрактной математики с интуитивной понятностью абстрактной математики”.
Реактивное программирование - это программирование приложений, управляемых событиями. Очень, надо сказать, востребованная штука, потому что это почти все приложения, которыми мы пользуемся - то, что называется, интерактивные приложения. В качестве примера исключения я бы привёл утилиты командной строки. Почти всё остальное - интерактивно и управляется событиями.
События имеют свойство происходить в произвольное время и совершенно не предсказуемы заранее. Чаще всего их обрабатывают как-то так:
function Counter(element) {
var that = this;
this.count = 0;
this.clicked = function () {
this.count += 1;
}
$(element).click(function() {
return that.clicked();
});
};
Код простой и в нём можно очень быстро разобраться, но у него есть несколько проблем. Во-первых, ненавистное нашему сердцу изменяемое состояние, а во-вторых - очень плохая композируемость. Плохая композируемость означает, что код будет очень сложно использовать повторно в других частях приложения и объединять с другими подобными элементами.
Это рушит наш уютный мирок, лишая его доказуемости и предопределённости, запрещая весомую часть привычных техник построения программ и взамен порождая массу неприятных эффектов.
Однако же мы не зря сюда пришли. Реактивное программирование, о котором мы хотим рассказать, основывается по большому счёту на концепциях из функционального программирования и его способах заводить примитивы.
Лирическое отступление: почему мы про UI и где ещё это применимо. Это применимо везде, где есть входящие события. Ну вот просто везде. Однако UI ставит наиболее интересные и сложные задачи по комбинации потоков: хоть сколь-нибудь развесистый пользовательский интерфейс начинает требовать всё более и более новых и интересных комбинаторов.
Идея примитивов ФРП вкратце. Естественный код:
$("#login").on("click", function (event) {
var value = $(event.target).val();
if (value.length > 0) {
$("#notice").text(value);
}
});
Вводим один уровень абстракции:
var clickE = $("#login").events("click");
clickE.onValue(function (event) {
var value = $(event.target).val();
if (value.length > 0) {
$("#notice").text(value);
}
});
На первый взгляд кажется, что мы ничего не добились. Однако же, теперь с этим примитивом мы можем кое-что делать.
var clickE = $("#login").events("click");
var values = clickE.map(function (event) {
return $(event.target).val();
});
var nonEmptyValues = values.filter(function (value) {
return value.length > 0;
});
nonEmptyValues.onValue(function (value) {
$("#notice").text(value);
});
Ну и инлайним лишние переменные:
var values = $("#login").events("click").map(function (event) {
return $(event.target).val();
}).filter(function (value) {
return value.length > 0;
});
values.onValue(function (value) {
$("#notice").text(value);
});
Лирическое отступление: point-free style или tacit programming. Если функция - гражданин первого класса, то имея богатую библиотеку хелперных функций, можно писать, избегая термов. Получается элегантно.
var values = $("#login").events("click").map(
chain(pluck('target'), $, method('val')
).filter(nonEmpty);
values.onValue(function (value) { $("#notice").text(value); });
Теперь немного разберёмся с денотационной семантикой. Функциональных примитивов для ФРП - два. Традиционно их называют Event и Behaviour, мы же по своих соображениям возьмём другие - Stream и Box.
Stream представляет собой дискретный поток событий и семантически эквивалентен списку кортежей “время-значение” при неубывающем времени:
type Stream[a] = [(T, a)]
Box представляет собой непрерывное значение, изменяющееся во времени и семантически эквивалентен просто функции времени.
type Box[a] = T -> a
На этих двух китах стоит функциональное реактивное программирование. Отличаются они тем, что при подписке на поток вы не получаете значения последнего произошедшего события - только следующее. Подписавшись же на коробку, вы сразу получаете значение последнего произошедшего события, и начинаете ждать следующего.
Лирическое отступление: push против pull. Семантика, которую я дал для события и поведения приводит нас к pull-driven реализации, которая, хоть её очень легко понять, чисто и красиво написать и замечательно о ней рассуждать и доказывать, по эффективности сильно проигрывает push-driven реализации. Однако же, push-driven реализация в целом более некрасива и сложна. Мы будем говорить в основном о push-driven реализации, потому что только ей можно нормально пользоваться в реальном мире.
Писать будем только интерфейс библиотеки, и если захотим поделиться чем-то интересным - тогда и реализацию.
Что у нас в потоке может быть? Всё. Простейшие конструкторы:
Box.nothing(); // empty box
Box.unit(10); // box with constant value of 10
Stream.never(); // empty stream
Stream.unit(10); // stream with immediate value of 10
Самоочевидно.
В дополнение - конструктор ошибки. Тема обработки исключительных ситуаций в функциональном программировании сама по себе интересна, но если вкратце - это просто возвращение некоего особого значения, которое при дальнейшей обработке, если с ним пытаются работать, как с нормальным возвратом, игнорируется и не изменяясь плывёт по цепочке вычислений в ту точку, где кто-то поинтересуется-таки, а не лежит ли там ошибка - и что-то с этим сделает.
Введём и мы у себя понятие Error события, которое раз появившись будет нетронутым проходить все нормальные ошибки и комбинаторы до тех пор, пока кто-то специально не поинтересуется. Естественно, нужен элементарный способ сконструировать ошибку.
Stream.error(10); // stream with immediate error of 10
Box.error(10); // box with constant error of 10
Это UI, поэтому, здесь будет полезна работа с временем. Базовый конструктор для этого:
Stream.later(1000, 10); // in a second pops out 10
Более интересно - из DOM-события. Это логично добавить в jQuery.fn:
$('input').stream('keyup'); // stream with keyup events
Ещё хочется привязать стрим к какому-то событию в будущем. Можно создавать стрим из функции обратного вызова (callback), а можно воспользоваться другой абстракцией из функционального мира: promise.
Вот это способ обработать конец асинхронного действия с функцией обратного вызова:
asyncCall(data,function(response){
// You have response from your asynchronous call here.
}, function(err){
// if the async call fails, the error callback is invoked
});
А вот так это делается с помощью promise:
var promise = asyncCall(data);
promise.done(function(response){
// You have response from your asynchronous call here.
}).fail(function(err){
// if the async call fails, you have the error response here.
});
То есть, всё как обычно в функциональном программировании - возвращаем специальное значение и можем с ним что-то такое делать. В новом jQuery обещания используются очень активно - все вызовы ajax и все анимации возвращают такой promise, который успешно завершается при хорошем ответе от сервера или конце анимации, а неуспешно - при плохом ответе от сервера (мне трудно придумать fail для анимации).
Интерфейс очень удобный и очень нужный, поэтому мы им и возпользуемся:
Stream.fromPromise($.ajax(params));
// pops a success event on successful response
// or the error event on error response
Итак, что мы можем сделать с нашими потоками?
Самое простое - отобразить, или пропустить через функцию. Для коллекций, наверно, это всем знакомо.
_.map([1, 2, 3], function (x) { return x*x; }); // returns [1, 4, 9]
На потоке и коробке map работает совершенно очевидным способом - получается новый поток или коробка, значения на которой соответствуют исходным, пропущенным через функцию.
priceValue.map(function(x) {
return x > 1000;
});
В примере мы получим булевое значение, характеризующее, достаточно ли денег предлагает пользователь.
Лирическое отступление. Вы только что познакомились с функтором. В функциональном программировании, функтор - это контейнер, позволяющий выполнить некоторую функцию над своим содержимым и завернуть результат в такой же контейнер. Эта операция обычно называется fmap. В этом смысле, список и наша коробочка со значением (или поток событий) - одно и то же. Потому что их можно обработать единообразно - пропустить значения через функцию, сохранив форму контейнера. fmap там ещё должен удовлетворять двум простеньким законам, и если будет интересно, мы про них расскажем. А пока продолжим.
Рядом со словом map часто звучит что? filter. Rails программисты знают его под словом “select”.
priceValue.filter(function(x) {
return x >= 1000;
});
Мы получим значение цены, если оно превышает 1000. Опять-таки, хорошая аналогия с коллекциями.
Ладно. Перейдём к по-настоящему интересной функции. flatMap. Это тоже преобразование значений в контейнере, но существенно более общим образом. Функция, которая передаётся в flatMap должна возвращать не значение, а значение, завёрнутое в новый контейнер. Сам же flatMap каким-то образом связывает результаты этой функции и возвращает опять-таки обёрнутое значение. Что-то на словах сложно получается.
Ну вот, например, есть список пользователей, а у каждого из них есть список комментариев. Нужно получить список всех комментариев.
_.flatMap(users, function (user) { return user.comments(); });
// returns flat list of all the comments the users have
Обратите внимание, что comments() может вернуть и пустой список - что, в свою очередь, никак не вложится в результат.
В нашем случае функция внутри flatMap должная возвращать не список, а поток или коробку. А вот связывать результат можно по-разному. Можно в результирующий поток замешать события всех дочерних потоков. Это наиболее логичная стратегия для потока. А можно подписаться на первый дочерний поток, а при появлении второго, переключиться на него и отписаться от первого. Это наиболее логичная стратегия для коробки.
Заканчивать же результирующий поток будем тогда, когда все дети и родительский поток закончились.
Зачем нам такое преобразование? Хм. Сейчас покажу. Это невероятно мощная штука. Для начала, её можно использовать для прозрачного связывания локальных потоков с асинхронными действиями.
var requests = usernames.map(function(u) {
return { url: "/check-username/" + u; }
});
requests.flatMap(function (params) {
return Stream.fromPromise($.ajax(params));
});
// usernames => "John", "Peter"
// requests => {"url": "/check-username/John"}, ...
// flatMap => {correct: true}, {correct: false}
Во-вторых, с её помощью можно написать map и filter.
Stream.prototype.map = function (f) {
return this.flatMap(function (x) {
return Stream.unit(f(x));
});
}
Stream.prototype.filter = function (f) {
return this.flatMap(function (x) {
if f(x) {
return Stream.unit(x);
} else {
return Stream.nothing();
}
});
}
Но наибольшие возможности flatMap открывает для написания комбинаторов. Давайте предположим, что нам нужно скомбинировать две коробки в одну. Например для получения значения “пароль и подтверждение” заполнен корректно.
password.map2(passwordConfirmation,
function(l, r) { return l == r; });
Давайте попробуем написать map2:
Box.prototype.map2 = function (other, f) {
return this.flatMap(function (x) {
return other.map(function (y) { return f(x, y); });
});
}
Поскольку операция map2 имеет смысл только на Box, мы используем версию flatMap, которая берёт только последний поток.
Те, кто сейчас сказал “о, я всё понял!” - поздравляю, вы познакомились с монадами.
Да, монада - это способ положить простое значение в контейнер и способ его достать и сделать новый контейнер. Ну плюс три простых как азимовские закона, о которых мы тоже можем рассказать. Положить простое значение в контейнер принято называть unit (мы его назвали так же), а сделать новый контейнер принято называть bind (мы его назвали flatMap и это имя реально имеет шанс победить, потому что на Scala тупо больше человек пишет код за деньги).
Поскольку мы получили монаду, мы получили аппликативный функтор, и это подтверждается наличием map2 (который позволяет нам тривиально определить apply).
Box.prototype.apply = function (other) {
return this.map2(function (f, x) { return f(x); } );
}
Собственно, аппликативный функтор позволяет скомбинировать функтор, содержащий функцию с функтором, содержащим значение, и получить функтор, содержащий преобразованное значение. Ну и 4 мудрёных закона, о которых можно поговорить отдельно.
Для потоков же, вместо map2, мы сделаем не менее полезную функцию - merge, позволяющую просто объединить потоки в один. Используя flatMap для потоков, который слушает все дочерние потоки, сделать это очень просто.
var id = function (x) { return x; }
Stream.merge = function (streams) {
return Stream.fromList(streams).flatMap(id);
}
merge - полезная штука.
var inc = plusClicks.map(function() { return 1; });
var dec = minusClicks.map(function() { return -1; });
var change = plus.merge(minus);
Ещё пара примеров фокусов с flatMap:
Stream.prototype.debounce = function (delay) {
return this.flatMapLast(function (value) {
return Stream.later(delay, value);
});
}
debounce сглаживает слишком частые события. То есть, если на исходном потоке события будут возникать чаще, чем delay, то отправится только последнее из них. Здесь мы используем неродной flatMap, на что и указываем.
Или вот - очень полезный комбинатор sampledBy:
Box.prototype.sampledBy = function (sampler) {
var that = this;
return sampler.flatMap(function () { return that.take(1); } );
}
О нём легко думать, как об отображении (map) потока на коробку.
Вот как просто и красиво таким способом записывается такая сложная и неприятная вещь, как drag and drop. Получаем события мыши:
mousedown = $(document).stream('mousedown', '.item')
mouseup = $(document).stream('mouseup')
mousemove = $(document).stream('mousemove')
Легко и непринуждённо получаем такую прелесть, как событие mouseDrag:
mousedrag = mousedown.flatMapLast(
(e)-> mousemove.takeUntil(mouseup)
)
Ловим положение курсора...
cursorPosition = mousemove.box().map(
(e)-> { x: e.clientX, y: e.clientY }
)
...в нужные нам моменты…
startPosition = cursorPosition.sampledBy mousedown
currentPosition = cursorPosition.sampledBy mousedrag
Потом немножко считаем…
shiftPosition = startPosition.zip(currentPosition)
.map((s, m)->
{ left: m.x-s.x, top: m.y-s.y }
)
...и двигаем объект, куда просили:
shiftPosition.onValue( (pos)-> $('.item').css(pos) )
Ах, да, и просим браузер не умничать:
mousedown.onValue (e)-> e.preventDefault()
Можно ещё вместо этого обойтись замыканиями:
newPosition = mousedown.flatMapLast (md)->
target = $(md.target);
{left, top} = target.offset();
[startX, startY] = [md.clientX - left, md.clientY - top]
mousemove.map (mm)->
target: target
left: mm.clientX - startX
top: mm.clientY - startY
.takeUntil mouseup
В общем, инструмент гибкий и мощный, и единственное, чего ему не хватает - это паттернов применения. То есть, нужно, чтобы побольше людей так писало - тогда они будут ставить и решать реальные проблемы и пустота начнём кое-как заполняться.
Итак, как теперь жить:
-
jnoid, демонстрационная библиотека, которая была написана, чтобы разобраться в теме и другие тоже могли в ней разобраться. Для этого даже было использовано “литературное программирование”.
javelin для closurescript от пользователя с одним из самых зачётных ников на Гитхабе.
elm для маньяков. Красивая вещь для поразбираться, не очень подходит для работы - там jQuery нету.
Берите любой и пробуйте решать проблемы. Не пожалеете.
Другие ссылки: