Введение
Как-то раз я сидел и грустно смотрел на написанный в рамках изучения эрланговский код. Очень хотелось написать на нем что-нибудь более полезное, чем крестики-нолики, но как назло никаких подходящих задач в голову не приходило. Зато есть JavaScript, в котором есть и функции первого порядка, и каррирование, и map/filter/fold, и, главное, задачу придумать куда проще. А вот pattern matching-а своего нету. Беглый поиск выдал мне несколько библиотек, но предлагаемый ими синтаксис показался мне тяжеловесным. Можно ли сделать лаконичнее, ближе к родному эрланговскому синтаксису?
Спойлер: можно, если взять coffeescript, сделать так:
fn = Match -> [
When {command: “draw”, figure: @figure = {type: “circle”, radius: @radius}}, ->
console.log(@figure, @radius)
When {command: “draw”, figure: @figure = {type: “polygon”, points: [@first, @second | @rest]}}, ->
console.log(@figure, @first, @second, @rest);
]
fn {command: “draw”, figure: {type: “circle”, radius: 5, color: “red”}}
#output: {type: “circle”, radius: 5, color: “red”} 5
Кому интересно, как это получилось — добро пожаловать под кат.
Краткое описание
Собственно, что тут происходит, если вкратце:
- Match принимает функцию, которая возвращает массив шаблонов (patterns) и соответствующих им действий;
- При вызове подменяется контекст, так что все эти @a (== this.a) указывают на специально подготовленные свойства (properties), позволяющие парсеру понять, какие значения куда привязывать;
- Далее при вызове с конкретным значением идет сравнение с шаблоном (пока можно считать, что идет рекурсивный обход шаблона и значения и сравнения конкретных значений, чуть ниже будет подробнее об этом);
- Если значение совпало с шаблоном, то вызывается функция-действие. У нее мы тоже подменим контекст, подставив соответствующие значения.
Так что если взять пример выше, то @radius в первом аргументе When указывает, какую часть входного объекта нужно изъять (в данном случае — .figure.radius), а во втором аргументе (функции) оно же содержит конкретное значение.
Работа с массивами
В Erlang есть удобный синтаксис для разбиения списка на голову (первый элемент) и хвост (все остальное), который широко используется для разных рекурсивных алгоритмов.
case List of
[Head | Tail] -> Head;
[] -> {empty};
end.
В javascript (и coffeescript) отсутствует возможность переопределить операторы, поэтому штатными средствами можно сделать только что-то вроде:
When [@head, @tail…], -> console.log(@head, @tail)
В принципе неплохо, но в erlang как-то симпатичнее. Может все-таки как-то можно, хотя бы для ряда сценариев?
Тут стоит вспомнить как вообще javascript удается выполнить операцию типа:
var object1 = {x:1}, object2 = {y: 2};
console.log(object1 | object2);
И получить 0. Это работает так как javascript пытается для начала привести тип к числовому, для чего вызывает у объекта метод valueOf. Если подменить метод у наших объектов и возвращать степень двойки, то в результате можно узнать к каким объектам была применена операция.
var object1 = {x:1}, object2 = {y: 2}, object3 = {z: 3};
object1.valueOf = function() { return 2; }
object2.valueOf = function() { return 4; }
object3.valueOf = function() { return 8; }
console.log(object1 | object2);
//6 == 2 | 4 == object1 and object2
Было сделано смелое предположение, что очень редко кто будет использовать массивы конкретных чисел в шаблонах, поэтому если парсер встречает в конце массива число, он пытается определить, не является ли это результатом операции or двух объектов. В итоге стало возможным писать так:
When [@head | @tail], -> console.log(@head, @tail)
Выглядит симпатично, но за пределами этой задачи я бы не стал повсеместно использовать такой метод.
Сопоставление с классом
Следующее что хотелось — сделать сопоставление структур как в Erlang, с указанием типа и содержимого.
#person{name = Name}
Прямо так, конечно, не удастся, но что-то похожее сделать можно. В итоге я остановился на трех решениях:
When ObjectOf(Point1, {x: 1, y: 2}), -> …
When Point2(x:1, y:2), -> …
When Point3$(x:1, y:2), -> ...
Первое работает «из коробки», второе выглядит почти как case class на scala, но требует вставки вот такой строки в конструктор.
class Point2
constructor: (@x, @y) ->
return m if m = ObjectOf(@, Point2, arguments)
This нужен чтобы понять, была ли вызвана функция как конструктор или нет, сам конструктор и аргументы попадают в шаблон.
Третий вариант является вариацией на тему первого, просто функцию заготавливаем заранее:
Point3$ = ObjectOf(Point3)
Производительность
Первая наивная версия выполняла сравнение шаблона и значения, проходя их рекурсивно. В принципе, я ожидал, что производительность будет не на высоте, если сравнивать с простым разбором объекта. Но стоило проверить.
coffeeDestruct = (demo) ->
{user} = demo
return if not user.enabled
{firstname, group, mailbox, settings} = user
return if group.id != "admin"
notifications = settings?.mail?.notify ? []
return if mailbox?.kind != 'personal'
mailboxId = mailbox?.id ? null
{unreadmails, readmails} = mailbox;
return if unreadmails.length < 1
firstUnread = unreadmails?[0] ? []
restUnread = unreadmails?.slice(1) ? []
return if readmails?.length < 1
return if readmails?[0]?.subject != "Hello"
rest = readmails?.slice(1)
return {firstname, notifications, firstUnread, restUnread, rest, mailboxId}
singlePattern = Match -> [
When {user: {
firstname: @firstname,
enabled: true,
group: {id: "admin"},
settings: {mail: {notify: @notifications}},
mailbox: {
id: @mailboxId,
kind: "personal",
unreadmails: [
@firstUnread | @restUnread
],
readmails: [
{subject: "Hello"}, Tail(@rest)
]
}
}}, -> "ok"
]
Результаты для 10000 сопоставлений:
Regular: 5ms Single Pattern: 140ms Multiple patterns: 429ms
Да, не то что хочется увидеть в production. Почему бы не преобразовать шаблон в код близкий к первому примеру?
Сказано — сделано. Идем по шаблону и составляем список условий и промежуточных переменных.
Здесь вылезла интересная особенность. Первый вариант скомпилированной функции был почти идентичен рукописному разбору, но производительность была хуже раза в полтора. Разница была в способе создания результирующего объекта: оказалось, что создать объект с заданными полями — дешевле, чем создать пустой объект и заполнять его в дальнейшем. Для проверки я сделал вот такой бенчмарк. После чего нашел две статьи на эту тему — вот и вот — и еще и перевод на хабре.
Провели оптимизацию, запускаем:
Regular: 5ms Single Pattern: 8ms Multiple patterns: 164ms
Второе число выглядит неплохо, а что за третье и почему оно все еще большое? Третье — это выражение match с несколькими шаблонами, где срабатывает только последний. Т.к.шаблоны компилируются независимо, мы получаем линейную зависимость от числа шаблонов.
Но в реальности шаблоны будут весьма похожи — мы будем разбирать объекты отличающиеся какими-то деталями, и при этом имеющие схожую структуру. Скажем, вот здесь:
fn = Match -> [
When ["wait", "infinity"], -> console.log("wait forever")
When ["wait", @time = Number], -> console.log("wait for " + this.time + "s")
]
В обоих случаях массив состоит из двух элементов и первый равен «wait», отличия только во втором. А парсер сделает две почти одинаковые функции и будет вызывать их одну за другой. Попробуем их объединить.
Смысл простой:
- Парсер вместо кода будет выдавать цепочки «команд»;
- Дальше берутся все команды и собираются в одну цепочку с ветвлениями;
- Теперь команды превращаются в код.
Стоит заметить, что если мы зашли в одну цепочку, то в случае неудачи мы должны не выходить, а попробовать следующую цепочку. Я видел три способа этого достичь:
1. Вложенными условиями
if (Array.isArray(val)) {
if (val.length === 2) {
if (val[0] === ‘wait’) {
if (val[1] === ‘infinity’) {
return {when: 0};
}
if (val[1].constructor.name === ‘Number’) {
return {when: 1};
}
}
}
}
Смотрится ужасно, да еще и при генерации кода не запутаться бы в скобках. Нет.
2. Вложенными функциями
if (!Array.isArray(val)) return false;
if (val.length !== 2) return false;
if (val[0] !== ‘wait’) return false;
if (res = function fork1() {
if (val[1] !== ‘infinity’) return false;
return {when: 0}
}()) return res;
if (res = function fork2() {
if (val[1].constructor.name !== ‘Number’) return false;
return {when: 1};
}()) return res;
Выглядит лучше. Но напрягают дополнительные проверки и return-ы, т.к.нет способа вернуться сразу из внешней функции (ну разве что через исключения, но это несерьезно).
3. Breaking bad label
if (!Array.isArray(val)) return false;
if (val.length !== 2) return false;
if (val[0] !== ‘wait’) return false;
fork1: {
if (val[1] !== ‘infinity’) break fork1;
return {when: 0}
}
fork2: {
if (val[1].constructor.name !== ‘Number’) break fork2;
return {when: 1};
}
Выглядит неплохо и мне, как старому сишнику, сразу показалось что этот вариант будет быстрее. Быстрая проверка на jsperf подтвердила мою догадку. Значит на этом варианте и остановимся.
Посмотрим на производительность:
Regular: 5ms Single Pattern: 8ms Multiple patterns: 12ms
Вполне приемлемо. Оставляем как есть.
Архитектура и плагины
После добавления очередной функциональности путем дописывания новых if-ов в двух разных местах я решил переработать архитектуру. Теперь вместо двух больших функций parse и render появились функции parse и render поменьше, которые сами ничего толком не делают, зато каждую часть шаблона посылают по цепочке плагинов. Каждый плагин умеет:
- обрабатывать свой кусок шаблона и превращать его в набор команд;
- говорить что парсить дальше;
- превращать свои команды в код.
Например плагин для сопоставления с конструктором выглядит так:
function pluginConstructor() {
return {
//этот метод вызывается если в шаблоне встретилась функция
//если бы мы ждали объект, то нужно было бы объявить метод parse_object
//или метод parse, который вызвался бы для любого значения
parse_function: function(part, f) {
//добавляем команду с именем "constructor"
//после перегруппировки нас попросят превратить команду в код - см. метод ниже
f.addCheck("constructor", part.name);
},
//этот метод вызывается чтобы "отрисовать" код для команды "constructor"
//мы возвращаем условие, оно будет завернуто в if.
render_constructor: function(command, varname) {
return varname + ".constructor.name === " + JSON.stringify(command.value);
}
};
}
Это позволило с одной стороны упростить добавление новых фич мне самому, а с другой — дать возможность пользователям дописать свой плагин и расширить синтаксис шаблонов. Например, можно добавить поддержку регулярных выражений, чтобы можно было писать так:
fn = Match -> [
When @res = /(d+):(d+)/, -> hours: @res[1], mins: @res[2]
# or
When RE(/(d+):(d+)/, @hours, @min), -> hours: @hours, mins: @mins
]
Сравнение с другими решениями
Как писал в самом начале, я попробовал поискать аналогичные решения, и вот что нашлось:
- matches.js — схожий функционал, близкая производительность, но шаблоны задаются строкой — следовательно ни тебе подсветки, ни вынесения общих частей в переменные
- идейный наследник sparkler — судя по всему имеет схожий функционал, но для синтаксиса использует макросы sweet.js, т.е. код придется дополнительно компилировать. В целом проблемы те же, хотя и выглядят шаблоны симпатичнее
- pun.js — синтаксис похож (только в шаблонах вместо @a предлагается писать $(‘a’)), однако возможностей меньше (например не поддерживаются массивы переменной длины) и производительность ниже — видимо они не выполняют компиляцию.
Сравнение производительности с matches.js, pun и ручным разбором можно найти тут.
Заключение
Вот в целом и все. Сам код можно посмотреть тут. Несмотря на то, что синтаксис заточен под coffeescript, сама библиотека написана на javascript и может быть использована в других языках, компилирующихся в js.
Несколько минусов вдогонку:
- Разбиение массивов на «голову» и «хвост» полезно для рекурсивных алгоритмов, но без оптимизации хвостовой рекурсии могут возникнуть проблемы с производительностью и переполнением стека на больших объемах.
Решение: пока не придумал - В шаблонах не получится использовать функции — вернее, использовать-то можно, однако вызовутся они только один раз при компиляции шаблона.
Решение: использовать guards - Из-за этого подмены контекста не получится привязать функции-действия к вашему контексту. С другой стороны, если уж мы пишем в функциональном стиле, то вызовы методов нам вроде бы и не нужны.
Решение: по старинке, self = this - По той же причине скорее всего не получится использовать arrow-functions из ecmascript 6 — они намертво привязывают контекст так, что даже вызовы через call/apply на них не влияют.
Решение: пока не придумал
Надеюсь, что-то окажется полезным. Спасибо за внимание.
Автор: GRaAL