23 октября 2013

FRP (functional reactive programming) на Bacon.js

JavaScriptФункциональное программирование
Из песочницы
Часто, при создании достаточно сложных приложений на JavaScript наступает тот момент, когда становиться совершенно непонятно почему приложение перестало работать как надо, или наоборот вдруг заработало. Cвязей между элементами приложения становится так много, что уследить за ними даже с хорошими дебаггером очень трудно. И вот диллема: с одной стороны есть хорошо известная методика создания приложений на JS, столь привычная и глубоко описанная, что недостатков мы уже как бы и не замечаем. С другой стороны есть масса библиотек предлагающих нам перейти на другую сторону попробовать что-то новое. К таким библиотекам относиться и Bacon.js, предоставляя реализацию FRP на JavaScript.

Пару слов о FRP и его прикладном смысле.
Если не вдаваться в дао функционального программирования, то можно выделить несколько моментов FRP особо притягательных для веб-разработки. Это:
  • явные состояния,
  • распространение изменений,
  • работа не данными, а с источниками данных.

С состояниями, конечно, не все так прозрачно. В конце концов, это все равно JavaScript со свойственными ему проблемами. Так что где-то под капотом браузера все равно происходит все то же самое, что и происходило бы в коде без bacon.js, но вся мякотка в том, что больше это не забота разработчика. Задача разработчика сводиться к тому, чтобы думать над логикой приложения.

FRP подразумевает, что источники данных — это потоки событий. Сами данные — это состояние потока в определенное время, поэтому изменение данных влечет за собой немедленное изменение других данных, которые зависят от первых. По итогу получается структурное дерево описывающее зависимость одних данных от других, что делает систему прозрачной и легкоусвояемой.

Сейчас бы хотелось перейти к прикладной части, а именно на примере продемонстрировать преимущества FRP в целом и Bacon.js в частности перед императивным программированием на JS. В качестве примера возьму хорошо известную игру Sokoban, которую я напиcал с применением Bacon.js и без, чтобы наглядно показать различия.

Чтобы не вдаваться в излишнюю детализацию, можно посмотреть сразу на результат. Сконцентрирую внимание на функциональной части игры, той, которая отвечает за логику действий. Если у кого-то возникнет непреодолимое желание ознакомиться с сырцами, то прошу. В двух словах все работает так: мы задаем ширину и высоту игрового поля в ячейках (DHTML), рисуем уровень (JSON объект) и с помощью стрелок двигаем игрока (блок с зеленым фоном) по полю, пытаясь переместить желтые ячейки на синие, когда все желтые ячейки находятся на синих — уровень пройден.

Практическое применение
А теперь к самому главному. Обычно, мы просто слушаем события, которые генерирует браузер в ответ на действия юзера, чтобы узнать что делать дальше.

$(document).on("keydown", function(e){
	//вот здесь происходит механика игры
});

Игра управляется 4-мя клавишами. И тут же сразу возникает проблема условной проверки, что синтаксически выглядит так.

if(e.keyCode >= 37 && e.keyCode <= 40){
     //На самом деле, механика игры здесь
}


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

Что же предлагает bacon.js? В bacon.js определены потоки (EventStream) и свойства (Property), — состояния потоков в определенное время. Потоки можно обрабатывать, соединять, комбинировать. Здесь есть наглядные диаграммы методов. Таким образом описание нужных реакций сводится к описанию порядка событий в потоке и преобразованию данных. Идея в том, чтобы не следить за одиночными событиями и обрабатывать каждое из них в отдельности, а заполучить источник данных, то есть поток событий из которого можно извлекать только нужные или преобразовывать их. Например с применением фильтров:

var keyDowns = $(document).asEventStream("keydown"); //поток событий keydown на $(document)
var arrowDowns = keyDowns.filter(isArrows); //поток событий, таких, что прошли фильтр isArrows

function isArrows(e){
	//здесь asEventStream передает в эту функцию jQuery.Event 
   return e.keyCode >= 37 && e.keyCode <= 40
}

Или с применением map'ов:

var changeDirection =  $(document).asEventStream("keydown")
				.filter(isArrows)       //если filter возвращает true, то передает событие дальше, иначе не реагирует
				.map(selectDirection)   //map возвращает значение selectDirection(event)
				.onValue(function(x){   //и выполним анонимную функцию, для этих событий
     //здесь задаем направление движение игрока
});

function selectDirection(e){
      return { 
            x : e.keyCode % 2 ? e.keyCode - 38 : 0, 
            y : !(e.keyCode % 2) ? e.keyCode - 39 : 0 
      } 
}

Или как-нибудь еще. Методов для работы с потоками и свойствами много. Суть даже не в том какой функционал предоставляет Bacon.js, а в том, что потом можно делать с этими потоками событий. Если общий алгоритм игры без bacon.js представляет собой хитросплетенный лабиринт условий и состояний, то с помощью FRP мы достигаем вполне себе декларативного описания состояний программы и источников данных.
Мы привыкли изменять состояния системы, когда происходят нужные события, будь то ввод с клавиатуры или клик по какому-нибудь объекту. Чтобы понять, что события нужные приходится вешать хендлеры и пристально следить за развитием событий. Приходится много планировать и предугадывать как вообще поведет себя юзер, чтобы на всякое состояние были свои действия. Но в действительности меня как разработчика мало интересует чего такого сделал юзер, если это логически не влияет на состояние системы. При таком подходе декларативное описание событий очень сильно облегчает жизнь так как не требует проверок в стиле «а правда, что юзер ткнул именно эту кнопку на клавиатуре, а не какую-то другую».

	
var playerMove = $(document).asEventStream("keydown")  //события keydown на $(document)
									.filter(isArrows)  //которые являются нажатиями на стрелки
									.map(player)       //для игрока
									.map(nextCell);    //ячейка, куда движется игрок.  

//поток событий, таких, что следующая ячейка куда двигается игрок пуста
var playerNextEmpty =  playerMove.filter(isEmpty).onValue(function(nov){
	//меняем координаты игрока.
});

//поток событий, таких, что следующая ячейка куда двигается игрок - подвижный блок
var goalMove = playerMove.map(isGoal).filter(function(x){return x});

//поток событий, таких, что следующая ячейка за той, где стоит подвижный блок - пуста
var goalNextEmpty = goalMove.map(nextCell).filter(isEmpty).onValue(function(x){
	//меняем координаты блока
});

Как видно, синтаксис сам подталкивает к тому, чтобы писать очевидно. На самом деле все можно сделать еще проще, если использовать методы scan() и combine(), но я хотел показать самые простые методы map() и filter().

Тот же функционал без bacon.js целиком приводить не буду, но callback, который обрабатывает механику игры при событии keydown заканчивается вот этим:


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

	
//поток событий, таких, что следующая ячейка, куда двигается игрок - мина
var playerNextMine = playerMove.filter(isMine); 
//поток событий, таких, что следующая ячейка, куда будет перемещен блок - мина
var goalNextMine = goalMove.map(nextCell).filter(isMine);
//совмещенный поток событий playerNextMine и goalNextMine.
var mineAlert = goalNextMine.merge(playerNextMine).onValue(function(x){
	//игра проиграна
});


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

Плюсы и минусы
  • + Больше не нужно следить за состоянием системы, что не может не радовать тех, кто пишет глубоко асинхронный код, состоящий преимущественно из одних только ajax-запросов.
  • + Не нужны сверх-замороченные описания управляющего механизма приложения, что не может не радовать, тех, кто пишет сложные логические приложения (например анкету с очень хитрой спецификацией заполнения). Сам синтаксис велит писать очевидно.
  • + Легко поддерживать, дополнять, изменять приложение не опасаясь, что все накроется медным сосудом.


  • — Все равно, все чего мы пытаемся избежать прибегая к FRP в JavaScript так или иначе делается, но уже скрыто от нас в недрах Bacon.js.
  • — Производительность. Тщательное профилирование показало, что делая все в лоб выходит быстрее. Однако, проект развивается, и правильное его использование окупается
  • — Из моих субъективных ощущений, — это скорее эмуляция FRP. ClojureScript, например, предоставляет тот же функционал в более привычной форме. Я гораздо дольше вникал в прикладную разницу между свойствами и потоками соответствующими map'у, чем в работу тех же ячеек (cell) в Javelin.


Имеет ли все это смысл?
Из всего написанного выше можно сделать вывод. Существует гипотетическая кривая зависимости необходимости использования bacon.js (и вообще FRP) от сложности приложения. Понятно, что для наведения красотулек на сайт или для одного только sign form никакого смысла нет использовать bacon.js. Библиотека не тяжелая, но лучше сразу увидеть, где можно ее применить, а где просто не нужно. Bacon.js разумно использовать там, где будет сложно ориентироваться без декларативного программирования. Даже если система большая и включает в себя огромное множество элементов, bacon использовать имеет смысл только тогда, когда эти элементы зависят друг от друга, изменение одних данных должно повлечь за собой изменение других данных и так далее. Сложность в этом смысле не означает размер приложения или совокупную сложность алгоритмов, скорее размер логической структуры приложения.

Альтернативы
Из JS библиотек есть Майкрософтовский RxJS. Есть такая штука, как Elm. Есть еще такая интересная вещь, как ClojureScript. Посвятив немного времени изучению этого вопроса, можно удобный для себя вариант.

Материалы:
GitHub: Bacon.js, web-site
Несколько хороших материалов о FRP: один, два, три.
Теги:javscriptFRPbacon.js
Хабы: JavaScript Функциональное программирование
+17
24,4k 119
Комментарии 17
Похожие публикации
Middle | High middle front-end разработчик (React + Typescript)
от 80 000 до 160 000 ₽CSSSRМожно удаленно
Frontend разработчик (React)
от 120 000 до 180 000 ₽COREМоскваМожно удаленно
High middle front-end разработчик (React)
от 125 000 до 160 000 ₽CSSSRМожно удаленно
Javascript разработчик
от 130 000 до 180 000 ₽ArtezioНижний Новгород
Javascript разработчик
от 160 000 до 220 000 ₽ArtezioМосква
▇▅▄▅▅▄ ▇▄▅