Я только что закончил читать Введение в функциональное программирование с помощью лямбда-исчисления, написанное Грегом Майклсоном. Ближе к концу автор обучает 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, пожалуйста, оставьте комментарий :-)

Ресурсы

  • Введение в функциональное программирование с помощью лямбда-исчисления, Грег Майклсон