Я только что закончил читать Введение в функциональное программирование с помощью лямбда-исчисления, написанное Грегом Майклсоном. Ближе к концу автор обучает SML и Lisp. Я наткнулся на несколько коротких функций, которые использовали синтаксис и рекурсию сопоставления с шаблоном структуры SML, две очень важные функциональные особенности, которые вы также можете увидеть в более широко используемых языках программирования FP, таких как Erlang, Haskell, Rust, F # и многих других. Я подумал, а почему бы не JavaScript? Хотя в JavaScript нет встроенной поддержки сопоставления с образцом, было предпринято несколько смелых попыток сделать это с помощью пользовательских библиотек, таких как:
Однако я не думал, что они действительно приносят большую пользу. Остальные проекты казались действительно устаревшими. Конечно, лучше всего было бы встроить сопоставление с образцом в JavaScript, как указано в следующем предложении:
Я настоятельно призываю вас поддержать этот проект стадии 0, поскольку он определенно способствует развитию разговора и заслуживает внимания. Тем не менее, JavaScript прошел долгий путь с поддержкой деструктуризации, которую вы можете рассматривать как очень элементарное сопоставление с образцом и хорошую отправную точку для этого нового предложения.
Итак, я решил использовать JavaScript, чтобы увидеть, могу ли я имитировать выразительность SML, чтобы подходить к некоторым тривиальным функциям таким функциональным образом.
Во-первых, немного о синтаксисе SML, который вы увидите, если не знакомы. SML - это статически типизированный язык, поэтому целое число i
объявляется как i: int
, а список строк t
как t: string list
. Наконец, оператор конкатенации infix ::
, который вернет новый список с первым объектом в позиции головы, а другой в позиции хвоста, например 0::[1,2,3] -> [0,1,2,3]
. В упражнениях, размещенных здесь, это степень, в которой вы будете использовать синтаксис SML.
Мне нужно будет найти аналоги этих языковых функций в JavaScript. Как я уже сказал, деструктуризация позволяет мне легко разложить массив на начальную и конечную части, используя [h, … tail]
в качестве замены [h::t]
. Это значительно упростит рекурсивные решения. Что касается типов, то я использовал Flow. В Flow число i
объявляется как i: number
, а последовательность строк t
как t: Array<string>
. Чтобы приблизиться к SML, я объявлю псевдоним типа Flow:
type list<E> = Array<E>
Вот и все, правда! Давайте поработаем там:
Пример 1: функции двух предложений case
Эти примеры демонстрируют простые рекурсивные алгоритмы с двумя совпадениями шаблонов в стиле case: базовым и рекурсивным шагом. Имейте в виду, что под капотом сопоставление с образцом - это не что иное, как условное ветвление сверху вниз, соответствующее структуре и форме предоставленных аргументов. Когда совпадение найдено, функция выполняет выражение справа. В SML это будет выглядеть так:
fun length [] = 0 | length (_::(t:int list)) = 1 + (length t);
Есть 2 выражения стиля регистра. Если вы вызываете length
с пустым массивом, возвращается ноль. В противном случае проверьте следующее предложение case (разделенное |
), которое будет соответствовать непустому массиву. В этом случае заголовок сопоставляется с подстановочным знаком _
и отбрасывается, наконец, рекурсивный вызов вычисляет 1 плюс длину хвоста списка.
А теперь представьте, что в JavaScript нет свойства Array.prototype.length
, мы могли бы выразить это как:
const length = ([h, ...tail]: list<number>): number => isNil(h) ? 0 : 1 + length(tail) assert.equal(length([]), 0) assert.equal(length([1,2,3]), 3)
В структурном отношении я чувствую, что эта функция сохраняет ту же выразительность предложения сопоставления с образцом слева и возвращаемого значения справа. Думайте о каждом тернарном операторе a ? b : c
как о соединении предложений case - совпадение a
, если true
выполнение b
, иначе выполнение c
. Помимо удобочитаемости, мне особенно нравится то, что он подталкивает нас к использованию хвостовой рекурсии и неизменности!
В том же духе мы можем создавать другие небольшие программы с тем же шаблоном:
Суммирование элементов в списке:
const sum = ([h, ...tail]: list<number>): number => isNil(h) ? 0 : h + sum(tail) assert.equal(sum([]), 0) assert.equal(sum([1,2,3]), 6)
Выведите первые n кубиков, начиная с 0:
const cubes = (n: number): list<number> => !n ? [0] : cubes(n - 1).concat([n * n * n]) assert.deepEqual(cubes(0), [0]) assert.deepEqual(cubes(3), [0, 1, 8, 27])
Итак, это все весело и хорошо, но разве этот шаблон не нарушается ›двумя предложениями о регистре?
Пример 2: функции трех case-предложений
Я реализую функцию, которая находит i-й элемент в списке строк. В SML:
fun sifind _ [] = "can't find it" | sifind 0 ((h:string)::_) = h | sifind (i:int) (_::(t:string list)) = sifind (i - 1) t;
В этом красота и сила сопоставления с образцом в том, что оно настолько декларативно, что вы можете прочитать алгоритм с экрана: попробуйте найти любую позицию в пустом списке и получите ошибку; или попробуйте найти первую строку в списке, тогда вы получите начало списка; наконец, продолжайте просматривать оставшуюся часть списка.
Давайте реализуем это в JavaScript, чтобы он читался так же. Я позволил себе добавить немного бликов и объявить функцию find
, которая найдет i-й элемент в списке объектов mixed
. Тип возвращаемого значения - Either
, чтобы лучше представить как состояние отказа, так и состояние успеха.
const find = (i: number, [h, ...tail]: list<mixed>): Either<string, number> => isNil(h) ? Either.Left(`Element not found at ${i}`) : i === 0 ? Either.Right(h) : find(i - 1, tail) assert.isTrue(find(0, []).isLeft) assert.isTrue(find(1, [0]).isLeft) assert.isTrue(find(2, ['a', 'b', 'c']).isRight) assert.equal(find(2, ['a', 'b', 'c']).fold(null, identity), 'c')
Давайте добавим еще один пример предложения из трех регистров. На этот раз я напишу функцию для замены i-го элемента списка другим элементом. Вы должны начать видеть в них закономерность:
const replace = (i: number, e: mixed, [h, ...tail]: list<mixed>): list<mixed> => isNil(h) ? [] : i === 0 ? [e, ...tail] : [h].concat(replace(i - 1, e, tail)) assert.deepEqual(replace(2, 'Jo', ['Chris', 'Jean', 'Les', 'Pat', 'Phil']), ['Chris', 'Jean', 'Jo', 'Pat', 'Phil'] )
Надеюсь, вы заметили еще одно функциональное качество всех этих программ - неизменяемость. В этом конкретном случае замена значения никогда не изменяет исходный массив.
Давайте посмотрим на последний пример, функцию вставки списка общего назначения:
const insert = (e: any, [h, ...tail]: list<any>): list<mixed> => isNil(h) ? [e] : e < h ? [e, h, ...tail] : [h].concat(insert(e, tail)) assert.deepEqual(insert('Jo', ['Chris', 'Jean', 'Les', 'Pat', 'Phil']), ['Chris', 'Jean', 'Jo', 'Les', 'Pat', 'Phil'] ) assert.deepEqual(insert('Jo', []), ['Jo'])
В этом случае я решил использовать список any
, чтобы перегруженный оператор сравнения e < h
работал, как в нетипизированном JS.
Конечно, я предпочел бы увидеть более декларативный синтаксис сопоставления с образцом, чем использовать if then else
, но если вы задумаетесь, то вот как это работает за кулисами.
Это все, чем я хотел поделиться. Я подумал, что это действительно здорово, что мы можем рассуждать и выражать код таким образом с помощью JavaScript. Это свидетельство гибкости языка. Я думаю, что он определенно движется в правильном направлении, и мы должны продолжать продвигать эти ориентированные на ПП предложения в максимально возможной степени.
Спасибо за чтение!
P.S. Если у вас есть пример ›функции« сопоставления с образцом »из 3 регистров в JS, пожалуйста, оставьте комментарий :-)
Ресурсы
- Введение в функциональное программирование с помощью лямбда-исчисления, Грег Майклсон