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

Однако это казалось хорошей возможностью узнать что-то новое, поэтому я решил реализовать парсер в javascript, используя методы функционального программирования, описанные в превосходном Руководстве по функциональному программированию профессора Фрисби по большей части. Эта книга написана Брайаном Лонсдорфом и доступна в Интернете бесплатно. Проверить это! Вам также стоит посмотреть его бесплатный видеокурс egghead.io, который движется в более быстром темпе. Отличный материал.

Поскольку это будет длинный пост, я разделю его на две части. В первой части я опишу (ну, в основном перечислю) некоторые фундаментальные аспекты функционального программирования, начиная от составления чистых функций и заканчивая описанием некоторых полезных функторов и монад. Затем, в Части II, мы будем использовать этот хороший материал для постепенного создания нашего парсера.

Некоторые основы

Прежде чем мы начнем кодировать, есть некоторые концепции функционального программирования, которые мы должны усвоить, иначе мы будем двигаться слишком медленно. Я быстро опишу эти концепции в этом разделе.

Чистые функции

Это функции, которые:

  • при одном и том же наборе входных данных всегда производить один и тот же результат. Это означает, что они не могут зависеть от какого-либо внешнего состояния (т. Е. Всего, чего не было на его наборе входов). Как следствие, чистая функция без каких-либо входов также может называться константой!
  • Они не могут вызывать никаких побочных эффектов, таких как изменение некоторого внешнего состояния (я имею в виду вне функции), запись в базу данных или даже запись в консоль.

Чистые функции имеют некоторые преимущества перед нечистыми, например:

  • легче тестировать, потому что нам не нужно ничего высмеивать. Предоставьте входы, проверьте ожидаемые результаты.
  • Отсутствие побочных эффектов означает, что вы всегда можете безопасно вызвать функцию, не задумываясь ни на секунду, разрушит ли она мир (или, что еще хуже, ваше приложение). Это освобождающее чувство: сколько бы раз я ни вызывала эту функцию, результат всегда будет одним и тем же, и он ничего не разрушит и не изменит. На самом деле, я могу спокойно запомнить эту функцию и никогда не оглядываться назад!

Мы будем писать большинство наших функций как чистые и постараемся максимально отложить использование нечистых функций. Обратите внимание, что мы всегда хотим, чтобы наша программа производила некоторые побочные эффекты (например, show print some output, create a file, или что-то еще…), поэтому было бы невозможно использовать только чистые функции.

Составление

Компоновка в основном берет две функции и создает новую, которая выполняет первые две по порядку. Например, учитывая:

const add1 = x => x+1
const mult3 = x => x*3

Мы можем создать новую функцию, которая делает и то, и другое:

const add1mult3 = x => mult3(add1(x))
add1mult3(5)  // 18

Для этого мы воспользуемся функцией compose Ramda (мы могли бы легко создать свою собственную. Это действительно классное упражнение!):

const add1mult3 = R.compose(mult3, add1)

Учтите, что порядок имеет значение! R.compose читается справа налево, поэтому сначала будет выполнено add1, а mult3 будет выполнено с результатом от add1 .

Одно (круто?) Свойство этой версии заключается в том, что нам даже не нужно было придумывать имя для параметра (x в случае «ручной» версии). Мы просто проверяем соответствие типов обеих функций (т.е. возвращаемый тип первой - это то, что ожидает вторая функция) и составляем.

Составление функций (таким способом или «вручную») - это способ управлять сложностью программного обеспечения. Мы разбиваем большие и сложные проблемы на более мелкие и создаем небольшие функции для решения этих более мелких проблем. Затем мы составляем эти функции (т.е. вызываем их в правильном порядке) для решения больших проблем.

Два дополнительных замечания о R.compose:

  • мы не ограничены составлением двух функций одновременно. Угадайте, что это делает:
const add5mult3 = R.compose(mult3, add1, add1, add1, add1, add1)
  • одним очень важным ограничением композиции функций, выполненной таким образом, является то, что каждая функция должна получать один параметр, не больше и не меньше. ЧТО ?! Это кажется довольно ограничивающим. Вы действительно ожидаете, что я буду кодировать такие функции, как add1, add2, add3? Конечно, нет, для решения нам понадобятся:

Каррирование

При каррировании функции создается новая функция, параметры которой не нужно указывать сразу. Если мы поставим их все, то функция, как обычно, сразу же будет выполнена. Однако, если мы предоставим меньше параметров, чем требуется для функции, то будет возвращена новая функция, ожидающая отсутствующих параметров. Например, рассмотрим функцию, которая принимает три параметра:

const volume = (a, b, c) => a * b * c
// we'll curry the function using Ramda.curry
const curriedVolume = R.curry(volume)
curriedVolume(1,2,3)    // 6
curriedVolume(1,2)(3)   // 6
curriedVolume(1)(2)(3)  // 6

Когда я впервые прочитал о карри, я подумал: «Ну и что? Какого черта мне это нужно ?! ». На первый взгляд это казалось бесполезным. Однако каррирование чрезвычайно важно для создания функций с более чем одним параметром. Давайте посмотрим на наш предыдущий пример с карри в нашем наборе инструментов:

const add = R.curry( (x,y) => x+y )
const mult = R.curry( (x,y) => x*y )
const add1mult3 = R.compose(mult(3), add(1))
add1mult3(5)  // 18

Намного приятнее! Теперь наши функции гораздо более общие, поэтому наши шансы на их повторное использование (комбинируя их с другими функциями) намного выше.

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

В качестве примера представьте, что мы определяем функцию, которая возвращает значение поля для данного объекта. Эта функция имеет два параметра:

  • имя поля, которое мы хотим получить из объекта. Это не данные, которые мы передадим функции, а скорее параметр конфигурации: например, если мы хотим, мы можем создать другую функцию, которая настроена для получения поля name любого объекта, который мы ему добавляем.
  • объект, содержащий поле. В большинстве случаев это будут данные, которые мы передадим функции; это переменная часть, а не параметр конфигурации.

Имея это в виду, мы должны определить эту функцию следующим образом:

const getProp = R.curry( (field, obj) => obj[field] )

И мы можем использовать его в композиции, которая меняет имя какого-нибудь парня на противоположное:

const guy = { name: 'Ritchie' }
const getName = getProp('name')
const reverseName = compose(R.reverse, getName)
reverseName(guy)  // eihctiR

Если бы мы выбрали использование obj в качестве первого параметра, мы бы ошиблись прямо сейчас, потому что мы не могли настроить нашу функцию для получения поля имени любого объекта с помощью каррирования.

Примечание: на самом деле в Ramda можно настроить параметры, отличные от первого, с помощью R.__. Иногда нам придется это сделать, потому что решить, какой параметр является основными данными, а какой - параметрами конфигурации, может быть непросто и может полностью зависеть от контекста, в котором мы используйте функцию.

Кстати, две отличительные особенности Ramda заключаются в том, что его функции автоматически каррированы, а порядок их параметров тщательно продуман, чтобы их можно было легко каррировать в большинстве ситуаций (опять же, в зависимости от в контексте, в котором мы используем эти функции, это может быть не всегда). Другие библиотеки, такие как Lodash / fp или Sanctuary (и, возможно, другие) делают то же самое, поэтому не стесняйтесь использовать любую из них.

Картография

Наверное, каждый разработчик javascript знает функцию map. Он просто берет массив и функцию и выполняет итерацию по массиву, применяя функцию к каждому из его элементов. В конце концов, он возвращает новый массив с тем же количеством элементов, который содержит результаты применения функции.

Однако, по крайней мере для целей этого сообщения, карта больше не будет относиться к итерации; карта будет относиться к применению функции к значению внутри некоторого контекста.

Контекст - это то, что определяет если и как применяется функция. В случае массива сам массив является контекстом и определяет, что функция должна применяться ко всем его элементам. Кроме того, если массив пуст, функция никогда не применяется ни к каким значениям.

Массив - это лишь один из таких контекстов, с которым мы можем использовать map. Есть много других интересных контекстов, некоторые из которых мы будем использовать для нашего парсера, а именно Возможно, Либо и Будущее. Все эти контексты содержат (или обертывают) какое-то значение (например, наш массив, который на самом деле содержит их множество), но действительно интересная вещь в них - это то, как они управляют тем, как функции применяются к этому значению.

Одним из очень важных моментов в map является то, что он всегда возвращает контекст именно того типа. Значение:

  • сопоставление с массивом всегда создает другой массив с точно таким же количеством элементов. Обратите внимание, что значения внутри нового массива вполне могут быть другого типа (например, мы можем создать массив чисел из массива строк, сопоставив функцию toString), но новый массив будет всегда иметь одинаковое количество элементов. Ни больше ни меньше.
  • сопоставление с Может всегда приводит к другому "Может быть" (далее мы кратко обсудим Maybes и другие типы)
  • и так далее

Тип (или контекст), который может быть отображен, также называется функтором.

Цепочка

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

Рассмотрим следующий сценарий: у нас есть массив номеров заказов на покупку (PO). Для каждого заказа мы хотим получить его позиции, извлечь цену из каждой позиции и вычислить сумму для всех заказов на покупку. Вот что у нас получилось:

// this returns an array of line items. a line item is an object that looks like:
// { id: 1, name: "socks", price: "3", ... }
const getLineItems = poNumber => ...
// get the price of one line item
// this is the same as using R.prop('price')
const extractPrice = li => li.price
// all the PO numbers
const poNumbers = [1, 23, 11, 45]

Важной частью здесь является то, что getLineItems - это функция, возвращающая массив. Если мы попытаемся сопоставить его с нашим массивом poNumbers, мы получим массив массивов:

const lineItems = R.map(getLineItems, poNumbers)
// --> [ [{id:1,price:3,...} , {id:2,price:56,...}]
       , [{id:10,price:42,...} , {id:292,price:4,...}]
       , [{id:43,price:2,...} , {id:456,price:5000,...}]
       ]

В этом случае было бы лучше, если бы у нас был плоский массив, потому что мы хотим просто обрабатывать каждую позицию одинаково, независимо от того, к какому PO она принадлежит. Мы просто хотим извлечь их цены и подвести итоги.

Для этого мы можем использовать цепочку. Как и map, chain также работает в этих контекстах, о которых мы говорили, но принимает функцию, которая возвращает значение, которое также находится внутри того же типа контекста . В нашем примере мы используем цепочку для массива и даем ему функцию, которая также возвращает массив. Цепочка свертывает два массива в один:

const lineItems = R.chain(getLineItems, poNumbers)
// --> [ {id:1,price:22,...} , {id:1,price:56,...}
       , {id:10,price:42,...} , {id:292,price:4,...}
       , {id:43,price:2,...} , {id:456,price:5000,...}
       ]

Оттуда мы можем легко выполнить наше требование:

const prices = R.map(extractPrice, lineItems)
const total = R.sum(prices)

Или более кратко (помните, что compose читается справа налево):

const getLineItems = poNumber => ...
const poNumbers = [1, 23, 11, 45]
const calc = R.compose(
    R.sum,                     // returns the total price
    R.map(R.prop('price')),    // returns an array of prices
    R.chain(getLineItems))     // returns an array of line items
calc(poNumbers)

Таким образом, нам не нужно придумывать названия для промежуточных значений. Читается ли он лучше или хуже - это, наверное, вопрос мнения.

Это довольно запутанный пример, но цепочка действительно чрезвычайно полезна во многих ситуациях, когда мы не хотим иметь значение внутри двух контекстов (например, массив массива, возможно, внутри, и т. Д.) , как мы увидим позже.

Другие возможные названия для цепочки: bind и flatMap. Кроме того, тип (или контекст), который может быть связан таким образом, также называется монадой.

Типы

Мы говорили о контекстах (или типах), которые содержат значения и в которых мы можем сопоставить и связать функции. Мы также знаем, что Array - один из таких типов. В этом разделе мы опишем еще три типа, которые мы будем использовать для создания парсера: Возможно, Либо и Будущее.

Может быть

Тип Может быть представляет собой значение, которое может быть, а может и не быть. Он может иметь только одно из двух возможных значений:

  • это может быть Ничего, и в этом случае с ним больше ничего не связано. Если мы map или chain больше Ничего, мы всегда возвращаем Ничего.
  • или это может быть просто что-то. В этом случае он обертывает другое значение (которое может быть чем угодно, строкой, числом или даже всем состоянием вашего приложения). Если мы map над просто чем-то, мы получим еще одну просто оболочку, которая была результатом применения функции к исходному значению. Обратите внимание: если мы chain превысим Просто, мы сможем получить обратно Ничего!

Или в коде:

// 'a' means any type of value, such as string, number, object, etc.
// '|' reads as OR
// Maybe a = Nothing | Just a
const add1 = x => x + 1
R.map(add1, Just(5))     // Just(6)
R.map(add1, Nothing)     // Nothing

Мы собираемся использовать Maybes для замены null и undefined в нашем коде. Каждый раз, когда где-то есть поле, которое может не иметь значения, или функция, которая может возвращать пустое или несуществующее значение, мы будем использовать Maybe.

Преимущество Maybe перед null или undefined заключается в том, что мы можем безопасно применять функции к пустым Maybes (т. Е. Nothings), чтобы он не обрушился на нас. И когда пришло время получить значение внутри Возможно, мы должны принять во внимание возможность отсутствия значения, вынуждая нас обрабатывать этот случай.

Либо

Либо похож на Возможно в том смысле, что у него есть только два возможных значения: Влево и Вправо. Однако в Either оба этих значения содержат другое значение:

// Either a b = Left a | Right b
const add1 = x => x + 1
R.map(add1, Right(5))              // Right(6)
R.map(add1, Left({msg: "Ouch!"}))  // Left("Ouch!")

Значение Right в основном такое же, как Just: оно представляет собой «обычный» случай, когда функции, которые мы передаем в map, фактически получат применяемый.

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

Почему это полезно? Мы собираемся использовать Either, чтобы избежать генерации и перехвата исключений в нашем парсере! Каждый раз, когда мы кодируем синхронную функцию, которая может дать сбой, эта функция будет возвращать Either. Таким образом, если функция выйдет из строя в середине большого набора функций, любое последующее сопоставление или связывание не будет выполняться.

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

Фьючерсы

В то время как Either будет использоваться для обработки синхронных функций, которые могут дать сбой, Future будет использоваться для обработки любой асинхронной функции. (что, конечно, всегда может потерпеть неудачу).

Будущее похоже на Обещание, но с некоторыми очень важными отличиями:

  • Обещание является нетерпеливым, что означает, что оно начнет выполнять свои функции (например, сетевое соединение) сразу после создания. Будущее всегда лениво: вы создаете его, наносите на карту или связываете его несколько раз, и оно ничего не делает. Только когда вы вызываете специальный метод под названием fork, он действительно делает свое дело.
  • У Promise есть метод then, который используется для цепочки операций с обещанием. Из метода then вы можете вернуть либо другое обещание, либо «обычное» значение. Следующий метод в цепочке получит значение из предыдущего значения. Таким образом, метод then в обещании ведет себя как наша функция map (в случае, если мы возвращаем «обычное» значение в then) или как chain (на случай, если мы вернем другое обещание затем)

Прежде чем мы наконец запустим парсер, нужно поговорить еще об одном, что поможет нам собрать важную информацию о наших функциях, не глядя на их код или даже на их имя: сигнатуры типов!

Типовые подписи

Мы будем широко использовать сигнатуры типов в нашем парсере, особенно для описания наших функций. Сигнатура типа сразу же сообщит нам входные и выходные данные функции.

В качестве примера рассмотрим следующую сигнатуру типа.

// add :: Number -> Number -> Number

Это означает, что есть функция с именем add, которая принимает два числа и возвращает другое число. Или мы могли бы также прочитать его как функцию добавить, которая принимает число и возвращает другую функцию, которая принимает число и возвращает число (мы не будем читать функции таким образом, но хорошо понимать, что это как каррирование!)

Теперь для наших целей мы можем реализовать эту функцию любым из следующих способов:

// uncurried version
const add = (x, y) => x + y
// curried version (with standard arrow functions)
const add = x => y => x + y
// curried version (using Ramda)
const add = R.curry( (x, y) => x + y )

Имейте в виду, что ни одна из этих функций не идентична (можете ли вы заметить различия в способах использования каждой версии?), Но мы можем рассматривать их как примерно одну и ту же функцию. Мы воспользуемся первой версией, если сочтем, что каррирование нам, вероятно, не нужно (или, конечно, если функция имеет только одно значение). И мы воспользуемся третьей версией, если знаем, что нам может понадобиться каррирование.

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

??? :: (Number -> String) -> Array Number -> Array String

Итак, для этого требуется функция, преобразующая число в строку, и массив чисел. И выводит массив строк. Это специализированная версия нашего лучшего друга, функции map! Более общий подход:

mapArray :: (a -> b) -> Array a -> Array b

(здесь a и b - переменные типа, которые могут быть заменены любыми двумя типами). Конечно, мы уже видели, что map вовсе не о массивах, поэтому:

mapMaybe :: (a -> b) -> Maybe a -> Maybe b

Или, для полноты, в сигнатуре самого общего типа:

map :: Functor f => (a -> b) -> f a -> f b

Здесь Functor f - это просто ограничение, которое говорит, что f должен быть функтором. Помните, что Массив, Возможно, Либо и Будущее являются функторами. Итак, в сигнатуре типа выше вы можете просто заменить f одним из них и получить mapArray, mapMaybe и т. Д.

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

Это первая часть! Перейдите к Части II, которая содержит забавный фрагмент, где мы на самом деле создаем скребок, используя то, что только что закончили.