Путь асинхронного самурая
JavaScript — очень необычный язык. Может это звучит немного странно, но по-моему в его истории есть некоторое сходство с судьбой японского языка. Он, возможно, не был изначально глубоко продуман и был сделан на скорую руку, но при этом в умелых руках он часто оказывается неожиданно элегантным. Он был “поскрёбан” по различной степени качества сусекам, но при этом он легко впитывает нововведения и иногда даже кажется, что только для них и был создан. Он покорно принимает различные стили письма и, если бы не апологеты, “правильное” написание было бы, возможно, уже забыто… И, самое главное, как и для японского, нет обозримой границы в познании этого языка. Я знаком с ним на протяжении многих лет и он постоянно открывает мне новые грани.
Но одно я знаю точно — на JavaScript можно писать очень простой и понятный код. К этому нужно стремиться и взращивать это в себе, нужно не опускать руки, нужно узнавать новые и новые вещи, уметь принимать их, и рано или поздно обнаруживаешь, что сам язык — довольно-таки прост и при этом очень, просто-таки бесконечно, элегантен.
И впитывает новое, как губка.
Теперь к делу.
Проблема
Большинство жалоб на язык — ООП, которое, как известно, в нём есть, в своём наипрекраснейшем и буквальном виде, в виде прототипов. И даже необходимость в наследовании оказывается довольно-таки надумана, когда понимаешь, как дружить миксины. Но статья, в этот раз, не об этом.
Вторая по категоричности, но первая по сегодняшней моде, жалоба — “неудобство асинхронного программирования”. Она озвучивается тут и там, находят способы один необычнее другого, но все они ненамного проще друг друга настолько, что мне даже не хочется приводить ссылки (хотя приведу пример). Предлагаются сотни похожих библиотек, пишутся монструозные заменители, в общем всё происходит так, как обычно происходит с бедным стареньким, молодым и свежим JavaScript'ом.
…И потом пишутся тонны “образумливающих” статей подобных моей, и только читателю решать, где истина.
Посему эта статья о том, как сделать асинхронное программирование действительно простым. Но добиться настоящей простоты обычно довольно-таки сложно, поэтому, чую, статья получится вполне объёмной.
В обратном порядке, от не очень приятного метода до самого клёвого и независимого (от того, node.js вы используете или браузерный движок), поэтому я вам даже советую просто проскроллить вниз. Кроме того, последние пункты немного разъяснят как работают, например, библиотеки. …И сразу же пример из него, чтобы уж точно никто не смущался: семью строками кода JS, без никаких библиотек (!), мы добъёмся, скажем, этого:
successive_read(read_file('a'), read_file('b'), read_file('не_существует'), read_file('c') // тело этой, последней, функции не будет вызвано (вообще!) );
Когда всё это нужно?
- Цепочки запросов к серверному API
- Необходимость последовательного извещения UI по сингалам с сервера, да и без сигналов тоже
- Поочерёдное чтение файлов
- Парсеры текста и парсер-генераторы
- Аналоги консольного pipe (|) или направление символьных потоков
- Чтение пользовательского ввода
- Безопасные вызовы цепочек функций
Цепочки, цепочки… Вы заметили, да?
Путь 1. Просто вызовы
Почти что первое, что приходит на ум.
Допустим, у вас есть серверное атомарное API, есть класс работы с ним и мы хотим получить сортированный список пользователей. Сильно не утруждайтесь разбираться, оцените только размер и сложность кода, это всё равно худший из вариантов (хотя в сети встречаются ещё хуже):
var people = (function() { var papi = new PeopleAPI(); function People() { this.__requested = false; this.__callback = null; } People.prototype.getAllSorted = function(callback) { if (this.__requested) throw new Error('Request already started'); this.__requested = true; this.__callback = callback; papi.orderedBy('name', bind(this, this._gotOrder)); } People.prototype._gotOrder = function(order) { var res = {}, got = 0; var got_one = (function(people, count) { return function(man) { res[man.id] = man; people.__got++; if (people.__got === count) { people._gotAll(order, res); } } })(this, order.length); for (var oi = 0, ol = order.length; oi < ol, oi++) { papi.find(order[oi], got_one); } } People.prototype._gotAll = function(order, res) { this.__callback(order, res); this.__requested = false; } return new People(); })();
В нужный момент мы передаём нужный метод-хэндлер, храним состояние вызова… Ох, всё равно до хрена монструозно, правда? Ужас, ужас! Мне даже сейчас было противно писать это и я ничего не тестировал, хотя когда-то похожим образом у меня был построен относительно крупный проект (там выглядит чуть лучше, потому что API писал тоже я :) ). Пропускаем.
Путь 2. Шины событий
Попробуем быть чуть умнее, заведём общую шину событий:
var handlers = { 'user': {}, 'book': {}, 'message': {}, '_error': [] // допустим, ошибки не зависят от namespace }; events = [ 'update', 'create', 'delete', 'list' ]; for (var ei = events.length; ei--;) { for (var ns in handlers) { // мой объект чист, поэтому не нужно `hasOwnProperty` handlers[ns][events[ei]] = []; } };
Шина событий в данном случае разбита на подразделы (области имён), а глубже уровнем подразделы разбиты на типы событий, где на данный момент содержатся пустые массивы. Например, handlers.user.update и handlers.message.list это пустые массивы ([]), и так для каждого события в каждом подразделе.
Теперь организуем функции подписки на события и ошибки и функции выброса (ну а как ещё назвать?) и тех и других.
// теперь объект handlers можно наполнять ссылками // на "слушателей", группируя их по неймспейсу // и типу события // подписаться на событие в неймспейсе function subscribe(ns, event, handler) { handlers[ns][event].push(handler); } // подписаться на сообщения об ошибках function subscribe_errors(handler) { handlers._error.push(handler); } // сообщить о произошедшем в неймспейсе сообытии function fire(ns, event, e) { var e_handlers = handlers[ns][event], hname = 'on_'+ns+'_'+event, handler; for (var ei = e_handlers.length; ei--;) { handler = e_handlers[ei][hname]; handler.call(handler, e); } } // сообщить о произошедшей ошибке function fire_error(err) { var e_handlers = handlers._error; for (var ei = e_handlers.length; ei--;) { e_handlers[ei].on_error.call(handler, err); } }
По сути это весь необходимый код механизма событий и он, по-моему, довольно приличный. Так что, без лишних рассуждений, приведём пример использования:
// некий proxy к серверному API, // делает только асинхронные вызовы var uapi = new UserAPI(); // ваше приложение function MyApp() { // TODO: сделать функцию subscribe_all('user', this) subscribe('user','list', this); subscribe('user','update', this); . . . subscribe_errors(this); } // запросить список пользователей MyApp.prototype.requestUsers = function() { uapi.get_all(function(order, res) { fire('user', 'list', { order: order, list: res }); }); }; // обновить данные о пользователе // (может вызываться при отправке формы заполнения профиля) MyApp.prototype.updateUser = function(user) { uapi.save(user, function(user) { fire('user', 'update', user); }/*, fire_error*/); }; // этот метод будет вызван при срабатывании события user/list MyApp.prototype.on_user_list = function(users) { . . . // обновление UI . . . // при необходимости можно выбросить другое событие } // этот метод будет вызван при срабатывании события user/update MyApp.prototype.on_user_update = function(user) { . . . // обновление UI . . . // при необходимости можно выбросить другое событие } // этот метод будет вызван при ошибке MyApp.prototype.on_error = function(err) { . . . // нотификация об ошибке, паника, кони, люди }
Выглядит значительно более лаконично по сравнению с предыдущим примером и получается даже чем-то похоже на GWT, только в разы короче ;). На события может подписываться не один объект, а сколько угодно, для работы с серверным API — почти что идеальное решение.
Поиграться с ним можно здесь.
Но для парсеров и последовательного чтения файлов — не совсем то. Теперь представим, что нам надоело, мы закрыли глаза, и обратились в сторону библиотек, не задаваясь вопросом что за ними стоит. Просто — взять и вставить, кого нынче волнуют килобайты и внутренности всяких этих хламидомонад?
Путь 3. Библиотеки
Как бы там ни было, по сравнению с другими популярно-предлагаемыми способами, библиотеки — не самое плохое решение. Хоть их и пишут сразу кучу по первой же надобности, некоторые из них отдельно хороши и в разы повышают качество вашего кода. Просто пара ссылок, думаю вы запросто сами разберётесь как их использовать:
- Q (node.js, browser)
- Futures (browser, node.js/v8, rhino)
- deferred (node.js, browser)
- fibers (node.js/v8)
- arrowlets (browser)
- Sync (node.js)
- Async (node.js, browser)
- Step (node.js)
- Seq (node.js)
Туда же пойдут Dojo.deferred и прочие кандидаты. Плюс, январская презентация «Аsynchronous JavaScript» (англ.) от автора третьей библиотеки вдогонку.
Путь 4. «Чистые» монады
モナダの空道
…Ух ты, почти что ни одного упоминания о монадах в JS на русском, а я надеялся, мне не придётся их объяснять. Впрочем, я и не буду. И не будет в этой главе примеров кода «правильных» монад на JS. Англоязычных статей за последний год тысячи и в ближайшее время кто-нибудь их, да переведёт, и такого кода там завались:
- Translation from Haskell to JavaScript of selected portions of the best introduction to monads I’ve ever read
- Understanding Monads with JavaScript
- Monad Syntax for JavaScript
- How to build your own Monads
- You could have invented monads
- Promises are the monad of asynchronous programming
- Dojo.deferred is a monad
- JQuery is monad
- Conjuring JQuery Deferred with monadic incantations
- (Новичковые) ужасы Хаскеля
- Look Ma, No Callbacks!
- Native Javascript: sync and async
- Java Monad Implementation
Но долг требует хотя бы вкратце изложить суть.
В разделе «Когда это нужно?» почти весь список содержал популярные примеры применения монад, причём распространено мнение, что вы можете использовать их часто даже сами не осознавая того, что вы их используете. (Знаю я этот приёмчик, слышал не раз). И монады, кстати, стары, как сам программистский мир.
…Однако восклик “ах, блин, да это же монады, я ведь их часто использую”, родился и у меня. Не супер, прямо скажем, часто, но, оказывается, правда случается. И это действительно ещё одна вещь из того множества, которое надо понимать любому уважающему себя программисту.
Примечание: К моему стыду, я очень плохо понимаю код на Haskell и как он работает, даже в двустрочных примерах, хоть и предпринимал пару решительных попыток залезть во вражеский лагерь. С другими языками программирования у меня обычно таких проблем нет (читаю за еду код на Java, Lisp, Python), а вот тут — обнаружилась. Посему мои последующие (до пятой главы) слова отнюдь не аксиомы, а лишь то, что я увидел со своего берега. Я могу даже нагло врать, абсолютно не стесняясь (говорят, правда, что я этого не умею, но в тексте не должно быть заметно), но если вы вчитываетесь в эту главу, другого выхода, кроме как поверить на слово, у вас, на данный момент, нет :)
Всё просто. Если вы задались любой проблемой из вышеупомянутого списка из раздела «Когда это нужно?», значит вам нужны монады. И, как верно для любого паттерна, вы бы рано или поздно к ним пришли.
Они, в каких-то своих проявлениях, находятся среди вас — например, когда вы используете в консоли пайп:
> cat my.js | more
Достаточно задуматься о том, как этот пайп написан, и вы тут же поймёте монады. Ну, не справочное описание, а именно как они примерно работают.
Если файл my.js не существует, more не будет вызван вообще[1]. Это нам довольно знакомо, мы ведь со времён Perl любим писать:
> read_file('my.js') || die('where\'s my file?!')
Основная проблема в написании такого оператора — передача контекста. Вы не хотели бы знать, как работают cat, more, read_file или die (хотя снова вру, иногда очень даже интересно, что там, после этого die…). Вы бы скорее потребовали от них некий общий протокол общения, которому бы они беспрекословно следовали. Что-нибудь такое, что сделало бы очевидным, сорвалась операция или нет и готов для приёма абстрактный поток или не судьба.
Чтобы проблема была видна нагляднее, сделаем цепочку подлиннее, что-нибудь злобное (не пробовать дома, я и сам не пробовал):
> cat my.file >> /dev/dsp >> /dev/hda1 >> my_utility >> /dev/null
Монада — это и элемент такой цепочки и одновременно функция, которая её обрабатывает.
«Чистая» монада должна быть полностью независимой от внешнего контекста, быть вещью в себе, но от неё требуется вернуть унифицированный ответ. При этом позволяется заставить её возвращать этот самый унифицированный ответ через всяческие функции-обёртки, но, ещё раз, для использования в цепочке необходимо, чтобы каждый элемент был унифицирован, вся цепочка должна работать по единому правилу и её элементы должны быть компонуемы.
В нашем, последнем представленном, случае, любой элемент (или сам контекст вызова) должен уметь оборвать выполнение всей цепочки, если хоть один элемент не доступен, и последовательно запрашивать новые куски потока, если всё хорошо. А для обеспечения корректной работы всего этого мы должны понять, произошла ошибка или нет и принудить все элементы передавать поток единообразно.
Вот этот момент, с ошибкой, является характерным примером монады MayBe, которую мы незаметно так рассмотрели: в некоторых языках (JavaScript среди них, so sad ;( ) нет специального типа для ошибки (временно забудем о try/catch) и мы не можем стопроцентно для всех случаев сказать, хотел нам пользователь намеренно вернуть undefined, null или false как некие пустые данные или он правда имел ввиду, что произошла ошибка. В шелле есть exit code и это однозначное сообщение об ошибке, так все эти пайпы и работают. И Хаскель тоже так умеет, а JavaScript вот — нет.
Так что монада — это некая функция, которая может быть вызвана в некой очереди, в дереве процессов или просто независимом контексте и, оставаясь для них прозрачной, способная адекватно сообщить о своём состоянии. Навскидку — так.
В Хаскеле все функции «чисты» и не изменяют что-либо вне себя (в смысле вообще ничего!), они работают исключительно с одним аргументом (другой функцией, каррирование), а кроме этого занимаются только подготовкой возвращаемого значения, и лезть куда-то наружу для них — святотатство. Поэтому почти любую функцию в Хаскеле можно «омонадить» (TODO: спросить Хаскелистов, похоже на правду это утверждение или нет), просто потому что она независима и прозрачна. Так рождаются различные комбинации монад.
Кроме MayBe (привязка точной информации об ошибке к оборачиваемой функции) существуют другие монады-паттерны:
- Continuation (связывание нескольких функций между собой),
- Writer (привязка текстовой информации к функции, например логгинга),
- I/O (спросить пользователя, дождаться ответа из терминала, отреагировать на ответ; или прочитать файл, дождаться когда он будет доступен, прочитать содержимое, закрыть файл),
- Identity (привязка/подмена информации в возвращаемом значении),
- State (привязка состояния к функции) и другие (смотрите ссылки в русской статье на википедии и раздел «Ссылки» статьи на английском).
То есть, как результат, несколько функций можно обернуть в Continuation (последовательный вызов) и для обеспечения требуемой унификации, для каждой можно использовать монаду MayBe и как раз получится наш пайп или оператор || / &&.
Поэтому, когда вы делаете асинхронные вызовы (или даже просто последовательные) к серверному коду — вы тоже используете монады.
Когда вы просите одну функцию вызвать другую или несколько, в неком чистом окружении, и ждёте от них ответ — вы используете монады.
(Кстати, пока я искал материал к статье, нашёл всё-таки одно описание на русском (глава 4), которое, к моему приятному удивлению, показало, что я и правда «всё правильно понял», а пример с пайпом, оказывается, вообще стандартен для описания монадических замутов).
Советую заглянуть в статьи по ссылкам в начале главы и посмотреть, как монады надо «правильно» адаптировать в JavaScript. Там, в общем случае, описываются одна-две монады и приводятся три основные функции: bind, переводящая переданную функцию в компонуемую форму (чтобы её можно было использовать в цепочках), unit, обеспечивающая унифицированный формат для вовращаемого значения функции и, опционально, lift, добавляющая к функции необходимые данные, чтобы передавать их по цепочке.
Но ввиду неприспособленности JS к настолько абстрактным понятиям, многие реализации требует своих версий этих функций и значительных усилий над собой, чтобы всё это верно организовать. Может где-то недалеко и пишут уже фреймворк с прямой трансляцией хаскелевских монад на JS и это наверное хорошо.
Но я имею привычку отмечать, что «Жаваскрипту — Жаваскриптовое».
Так что хватит этой напыщенной чистоты, пора и грязь познать :)
Путь 5. «Грязноватые» (но от этого такие простые) монады или «Мы можем это сами»
モナダの土道
Из предыдущего раздела мы узнали что такое монады и как, примерно, они должны «правильно» готовиться. Но, как я люблю говаривать, «Хаскелю — Хаскелево». Монады — общее достояние и каждый язык имеет право смотреть на них со своей колокольни. В статьях, ссылки на которые вы найдёте предыдущей главе, они адаптируются в язык не то чтобы дословно, но довольно тщательно — авторы стремятся дать почти идентичное хаскелевому решение, универсальное для всех функций. На самом же деле это больше концепция, чем необходимость дословной реализации и таскания её за собой.
JS на самом деле не особо предусмотрен для таких инъекций, код становится только толще и сложнее, а таскать за собой ещё парочку js-файлов, раз этого нет в явном виде в стандарте языка, иногда очень даже «не комильфо». Для того чтобы подход стал простым, надо кое-от-чего отказаться.
Откажемся от передачи контекстов, условимся, что контекст у нас внешний и функции могут в него свободно писать. Сначала может показаться, что реализация станет зависимой от задачи, но это совсем не так: наоборот, мы оставим на своё усмотрение операции над контекстом и доверимся одной-единственной функции, которая будет превращать другую, переданную ей, функцию в отложенную и компонуемую. Вот она:
function deferrable(f) { // выберите название поприятнее return function() { return (function(f, args) { return function() { return f.apply(null, args); }; })(f, arguments); } }
Я свернул пару строчек в одну, чтобы их правда было семь :).
Это изящное, на мой взгляд, сочетание тех самых bind и unit.
Посмотрим, как можно это использовать. Допустим, мы хотим манипулировать чтением файлов, выполняя его последовательно по цепочке и обрывая цепочку, если какой-либо файл из неё не был найден.
/* функция, вызовы к которой мы хотим уметь откладывать */ function read_file(name) { console.log('reading ' + name); return name !== 'foo.js'; // true, если имя файла не `foo.js` }
Заметим, однако, что для этого простейшего случая, мы уже умеем это делать:
/* мы можем эмулировать метод "прервать очередь по падению" через оператор && или метод "прервать по первой удаче" используя оператор || но это всё. обратите внимание, что `read_file('c')` не вызывается. */ read_file('a') && read_file('b') && read_file('foo.js') && read_file('c'); // > reading a // > reading b // > reading foo.js // < false
Если условиться, что функция возвращает более осмысленное значение (например, ссылку на файл) и null при ошибке (но помните о MayBe), то в JS мы можем сделать даже так:
var found = find_file('foo.js') || find_file('a') || find_file('b'); // > found // < [file 'a']
Пояснение бы не было полным, если бы мы не сэмулировали это поведение через функции. Функция, которая эмулирует поведение &&, выглядит примерно так:
/* некая функция, которая оперирует над списком других функций более хитрым способом подготавливает их, прерывает, всё что угодно... */ function smart_and() { var fs = arguments, // массив отложенных функций flen = fs.length; for (var i = 0; i < flen; i++) { // если функция не сработала, остановить процесс if (!fs[i]()) return; }; }
А функция, которая эмулирует поведение ||, выглядит примерно так:
function smart_or() { var fs = arguments, // массив отложенных функций flen = fs.length, res = null; for (var i = 0; i < flen; i++) { // если функция сработала, вернуть результат if (res = fs[i]()) return res; }; }
Но если мы захотим использовать одну из них, то нам придётся сделать что-то трудночитаемое:
/* мы можем использовать smart_and таким вот образом, но выглядит, честно говоря, хреново да, мы можем обпередаваться внутрь массивами имён файлов и обрабатывать их внутри, но тогда надо будет назвать её не smart_and, а скорее smart_read_file */ smart_and(function() { return read_file('a') }, function() { return read_file('b') }, function() { return read_file('foo.js') }, function() { return read_file('c') }); // > reading a // > reading b // > reading foo.js // < undefined
Вся проблема здесь в подготовке массива отложенных функций. Как в JS можно вызвать функцию, передав ей параметр, запомнив его, но не выполнив её тела до тех пор, пока к ней не было прямого обращения, как это делают ||/&&? Очень просто, она должна вернуть внутреннюю функцию, содержащую своё тело:
function my_func(arg) { return function() { console.log(arg); } } // > var f = my_func(['a', 0]); // > f // < [function] // > f(); // или напрямую: my_func(['a', 0])(); // < [ "a", 0 ]
Но это не самый приятный подход, оборачивать так каждую функцию быстро надоест и выведет вас из себя… Так вот же, наверху, семистрочное решение всех ваших проблем:
function _log(a) { console.log(a); } _log = deferrable(_log); // > _log('Hi!'); // < [function] // > _log('Mooo!')(); // < Moo! Вуаля: // делаем `read_file` откладываемой read_file = deferrable(read_file); /* ... достаточно круто, ведь правда? обратите внимание, что `read_file('c')` не исполняется… */ smart_and(read_file('a'), read_file('b'), read_file('foo.js'), read_file('c')); // > reading a // > reading b // > reading foo.js // < undefined
Настало время, однако, представить, что наша задача сложнее и нам нужно, например, передать последний найденный файл в следующую функцию — операторы ||/&& здесь уже совсем не подойдут. А то ведь не очень понятно, зачем мы углубились в эти странные эмуляции операторов, если всё можно сделать их посредством без лишнего кода. Вовсе не всё, на что способны монады.
Хочу сразу заметить, что deferrable — это только часть монады; другую часть, делающую вызовы отложенных функций в нужной последовательности и окружении, я рекомендую писать вам самим (выше это smart_and и smart_or). Да и deferrable можно подправлять в зависимости от желаний. Просто по той причине, что лучше написать две семистрочные функции, работающие для вашей конкретной задачи (а в действительности, в подавляющем большинстве случаев, для одной задачи требуется только одна версия каждой из функций), чем добавлять целую библиотеку и/или наращивать универсальность.
[В процессе обсуждения статьи, благодаря Pozadi и qmax обнаружилось, что последние абзацы не ложились в канву статьи и выяснилась пара довольно важных вещей (см. Q5, Q6) поэтому часть ниже, до упоминания парсера — обновлена в соответствии с замечаниями]
NB. Как выяснилось, функция deferrable по концепции практически идентична введённой в ES5/JS1.8.5 Function.prototype.bind и в варианте «по умолчанию» может быть заменена на неё почти безболезненно. Но на данный момент во всех текущих браузерах кроме FF она работает в разы медленнее deferrable, поддерживается отнюдь не везде и логически она сложнее — так что выбирать вам, статья скорее о методе, чем о конкретной функции.
Что ж, теперь давайте напишем обещанную в начале статьи цепочку чтения файлов, в асинхронном варианте.
Немного изменяем deferrable (никто не говорил, что её нельзя менять, вы помните? :) ), чтобы он умел принимать callback в точке вызова и передавать его отложенной функции в виде последнего параметра:
var __s = Array.prototype.slice; function deferrable_as(ctx, f) { return function() { return (function(args) { return function(callback) { return f.apply(ctx, args.concat([callback])); }; })(__s.call(arguments)); } }
Есть некий fs.readFile, здесь мы его эмулируем через setTimeout, просто чтобы сделать вид, что это асинхронность:
var fs = { 'readFile': function(name, callback) { setTimeout(function() { var err = null; if (name === 'not_exist') err = new Error('Not exist'); callback(err, name); }, 2000); return 'unneeded'; } };
Делаем связывание:
var read_file = deferrable_as(fs, fs.readFile);
Пишем функцию, которая поочерёдно вызывает отложенные функции через механизм коллбэков:
function successive_read(/*f...*/) { var as = __s.call(arguments), handle_err = as.slice(-1)[0]; as[0](function(err, res) { if (err) { handle_err(err); return; }; console.log('Executed:',res); successive_read.apply(null, as.slice(1)); }); }
Вызываем:
successive_read(read_file('one'), read_file('two'), read_file('three'), read_file('not_exist'), read_file('four'), function(e) { console.error('Error:',e.message); });
Или вот, очень актуальная задача, генератор парсеров с правилами и блекджеком. Такой, чтобы можно было сказать…:
(Я этим как раз сейчас занимаюсь, оптимизирую тут один генератор парсеров, который для сложных синтаксисов выдаёт парсеры на несколько мегабайт JS-кода; и в поисках красоты и справедливости, я и пришёл неожиданно к монадам, поэтому у меня есть готовый симпатичный пример)
// start = ("a"* / "b") "c" (d:f+ { return d.join(':'); }) // f = "YY" "d"+ // "aaacYYddYYdd" -> [ ["a","a","a"], "c", "YY,d,d:YY,d,d" ] start = function() { return sequence( choise( any(match("a")), match("b") ), action( label("d", some(rule_f)), function() { return d.join(':'); } ) )(); } rule_f = function() { return sequence( match("YY"), some(match("d"))(); . . . console.log(parse("aaacYYddYYdd")); // > [ ["a","a","a"], "c", "YY,d,d:YY,d,d" ] (Воистину, монадическое торжество!)
Такие функции вполне могут возвратить и пустые строки и undefined (см. action), которые будут являться вполне полноправным значением и оно не будет значить, что что-то упало, что-то не найдено: просто не совпал элемент, но парсинг-то продолжается.
Ни одна из этих функций не должна выполняться по месту вызова, choise может пропустить последний элемент, если совпал первый, должен иметь возможность остановиться в нужный момент и откатиться назад. В этом коде я использую тот же самый deferrable, который я привёл выше, и это мой единственный молоток.
Все используемые функции парсера не сильно сложнее по коду, чем примеры выше, пара-тройка строк на каждую простую, пять-десять на каждую сложную.
sequence, например, подобно smart_or выше, собирает результаты совпавших функций в массив и возвращает его, благодаря чему переменная результата парсинга (вот этот сложносоставной массив) нигде не определена и блуждает по парсеру до окончания его действия и обретает однозначную сущность только по возвращению из функции parse.
Плюс, эксепшны. Тут они подходят как нельзя кстати. Они обычно занимают кучу кода, а мне важен каждый байт, поэтому я тоже обернул их в функции:
// сообщить об ошибке function failed(expected, found) { failures.push(expected); // да, failures объявлен извне, // мне важны простота и размер кода throw new MatchFailed(expected, found); } // подавить ошибку при вызове функции и известить о ней коллбэк, // если таковой указан function safe(f, cb) { try { return f(); } catch(e) { if (e instanceof MatchFailed) { if (cb) cb(e); } else { throw e; } } }
Именно функция safe подавляет ошибки, брошенные при несовпадении, например, от match, перехватывая их, например, для choise, который при неудаче просто переходит к следующему варианту.
Минус такого подхода в том, что эта самая блуждающая переменная результата при выбросе исключения теряется. Вернее, каждый раз перед потенциальной неудачей, её нужно сохранять (например, передавать в failed). То есть, если вы собираетесь использовать сгенерированный парсер чтобы подсвечивать текст в редакторе (например, маркдаун) на лету, то вы могли бы как раз и опираться на этот эксепшн для сборки табика code completion. Но предыдущий-то код тоже надо подсвечивать, а прошлый результат парсинга мог и устареть.
В общем, с ошибками ситуации изредка и правда могут быть не однозначными: из-за сомнительности возвращаемых типов, из-за сложных структур, которые нужно восстановить при ошибке и т.п. поэтому, в этих редких случаях, приемлемо по-хаскельному примешивать к возвращаемым значениям функций код или инстанс ошибки, например. Тут и понадобится монада MayBe и всяческие bind/unit.
Но вы ведь можете просто чётко знать, чего вы хотите достичь и что происходит в вашем коде и свободно оперировать внешними переменными. Так что, учтите — перебор действий с примешиваниями в JS — это значительная жертва читабельности и простоте кода.
Не замыкайтесь на контекст. Пользуйтесь данной вам свободой. Стремитесь к простоте. Хаскелю — Хаскелево.
Позже, когда закончу, я расскажу про этот парсерогенератор подробнее, а сейчас давно уже пора закругляться, поэтому эпилог:
Эпилог
Ну вот и всё :) Надеюсь, было понятно. Будьте проще! Чмоки-чмоки. xxxxo. また近いうちに 👻
(Upd.) Q&A
Логично, что к статье появились вопросы. Ответы, думаю, тоже должны быть частью статьи (она ведь худенькая совсем, разве нет? :) )
1. Мистер Silver_Clash спрашивает:
Q: Можно подробнее расписать что происходит в семистрочной функции и зачем там столько return?
A: Можно разбить её на две и тогда, наверное, будет очевиднее что там происходит:
// связывает функцию с аргументами и возвращает "отложенный вариант", // то есть можно вызвать: // // function a(arg) {console.log('a', arg); } // bind(a, 15); => функция в "замороженном", навсегда связанном с переданными аргументами, состоянии // bind(a, 15)(); => a 15, "замороженный" вариант вызван // // но, используй мы только её одну, пришлось бы вызывать её каждый раз так: // smart_or(bind(read_file, ['one']), bind(read_file, ['two']), bind(read_file, ['three'])); function bind(f, args) {
return function() { return f.apply(null, args); };
} // даёт функции свойство всегда запоминать c какими аргументами она вызвана // и возвращать отложенный вариант // // то есть она даёт возможность в любом месте писать `a(15)` и получать "замороженный" вариант, // который можно будет вызвать через a(15)(), таким образом избавляя от необходимости // повсеместно использовать `bind` function wrap(f) {
return function() { // для этого она возвращает функцию, которая при вызове запомнит // переданные ей аргументы и вернёт "отложенный" вариант return bind(f, arguments); };
}
// bind + wrap => просто сумма этих двух функций function deferrable(f) { // wrap
return function() { // fn-1 return (function(f, args) { // fn-2, bind return function() { return f.apply(null, args); }; })(f, arguments); // сразу выполняющаяся обёртка, которая на месте создаёт ссылку // на аргументы функции fn-1 в функции fn-2 // (чтобы они не запутались, где чьи аргументы) }
} И, кстати, этот bind — «сосед» того bind, который упоминается в этой статье и в статьях про монады по ссылкам. Но там аргументы примешиваются через контекст вызова — они приходят из возвращаемых значений других функций, просочившись через них все. В моём же случае просто делается «pin» — «я собираюсь вызвать эту функцию именно с этими аргументами, запомни и верни то, что я смогу вызвать потом».
2. Некто zokotuhaFly интересуется:
Q: Наверняка использующие эту вашу семистрочную функцию в ООП или сложных замыканиях захотят привязки к контексту, как решить эту проблему, ведь она применяется к null?
A: Проблема решается легко, вариант для объектов/контекстов:
function o_deferrable(o, f) { // выберите название поприятнее
return function() { return (function(f, o, args) { // f и o можно не передавать return function() { return f.apply(o, args); }; })(f, o, arguments); }
} Использование:
var my_obj = { test: function(a) { console.log(this); console.log(a); } }; var obj_test = o_deferrable(my_obj, my_obj.test); // > obj_test(12)(); // < [object my_obj] 12
Или вот так:
var my_obj = {}; // объект должен существовать на момент вызова `o_deferrable` my_obj.test = o_deferrable(my_obj, function(a) { console.log(this); console.log(a); }); // ну, или через прототипы // > my_obj.test(12)(); // < [object my_obj] 12
3. Сэр klvov задумывается:
Q: Мне одному кажется, что автор изобрел Y-комбинатор в виде функции deferrable?
A: Надеюсь, одному — код визуально схож, но по факту это немного разные вещи. Применение комбинатора ограничено более узким спектром задач. Вообще, это это трюк, чтобы делать рекурсивные вычисления не используя имён функций (через лямбды), получая ссылку на функцию от предыдущего, внешнего, вызова. Цели об отложенных вызовах здесь нет, хотя функция-рекурсер изначально (один раз) откладывается, чтобы использоваться в следующих вызовах, но не привязывается к аргументам.
Кстати, тот самый парень, который переводит монады в JS, тоже писал трансляцию/разъяснение Y-комбинатора. (+ ещё одна статья)
Пусть здесь сразу будет пример, чтобы каждый мог оценить для себя, тем более это немного в тему статьи, да и комбинатор — это хитрость посложнее чем deferrable):
var Y = function(f) {
return (function(g) { return g(g); })(function(h) { return function() { return f(h(h)).apply(null, arguments); }; });
};
var factorial = Y(function(recurse) {
return function(x) { console.log('called with',x); return x == 0 ? 1 : x * recurse(x-1); };
}); // > factorial(5); // < called with 5 // < called with 4 // < called with 3 // < called with 2 // < called with 1 // < 120 …хотя через deferrable можно эмулировать комбинатор, хоть и чуть более криво.
var fc = function(f, x) {
return x == 0 ? 1 : x * f(f, x-1)();
} fc = deferrable(fc); // > fc(fc,5)(); // < 120; Хм…
Ну а за сам комбинатор мы должны благодарить того самого Хаскеля, в честь которого, собственно, назван язык. Это так, для тех кто вдруг не знает.
4. Господин grasshoppergn замечает:
Q: а вообще deferrable можно даже еще сократить, потому что есть же Function.prototype.bind :)
function deferrable(f) {
return function() { Array.prototype.unshift.call(arguments, null); return f.bind.apply(f, arguments); }
} A: Не стóит, такой вариант работает в разы медленнее, да и bind поддерживается не во всех браузерах.
5. Дон Pozadi замечает:
Q: Вообще-то функция deferrable идентична Function.prototype.bind.
A: Да, я не знал про эту фунцию и не заметил этого, пока не прочитал про неё. Но, её псевдокод значительно больше (большей частью поскольку она нативная, чтобы уберечь программиста от возможных ошибок), и на данный момент она не поддерживается во многих браузерах и в текущем состоянии в Chrome она работает в разы медленнее (в разы!, но в FF она конечно выигрывает).
6. Мёсьё qmax и Pozadi задают самый важный вопрос, который привёл нас к полезной дискуссии (вопрос перефразирован автором статьи):
Q: Я ожидал увидеть в статье пример из начала с использованием семистрочной функции и настоящих нативных асинхронных функций (вроде fs.readFile), собственно ради только этого её и читал, но вы так ни одного и не привели.
A: Ваша правда, простите меня, исправляюсь[2]:
Тру-нативно-асинхронная функция — это, например fs.readFile или XMLHttpRequest.
Вот пример на jsfiddle. Я поменял пример piped в статье на ответ на этот вопрос, прошу сюда.
—-
[1] Как меня верно поправили, на самом деле это ложь. more будет вызван, операции по пайпу выполняются параллельно, и это единственное отличие шелловских монад от Хаскелевской имплементации, и у них были личные причины на это. Эта ложь в жертву науке: лучшего, но верного, примера я пока не придумал — надеюсь, вы меня простите. Или представьте, что всё это происходит в Perl. А та самая функция никак не ограничивает вас в способе организации этих процессов, вы можете вызывать отложенную функцию несколько раз и просить новую часть результата (типа yield) и сделать почти что вот такое же параллельное выполнение.
[2] На самом деле я (автор) очень долго настаивал на своём и делал вид, что не понимаю вопроса, выигрывая время на обдумывание, зато вопрос получился достаточно чётким и выяснилась пара провисов в статье, благодарю комментаторов за настойчивость.