Данный пост — это третья часть серии статей о функциональном программировании под названием «Мышление в стиле Ramda».

1. Первые шаги
2. Сочетаем функции
3. Частичное применение (каррирование)
4. Декларативное программирование
5. Бесточечная нотация
6. Неизменяемость и объекты
7. Неизменяемость и массивы
8. Линзы
9. Заключение

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

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

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

const publishedInYear = (book, year) => book.year === year
 
const titlesForYear = (books, year) => {
  const selected = filter(book => publishedInYear(book, year), books)
 
  return map(book => book.title, selected)
}

Будет хорошо, если мы совместим filter и map в конвеер, но мы не знаем как это сделать, потому что filter и map принимают два аргумента.

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

Функции высшего порядка


В первой части этой серии мы говорили о функциях как о конструкциях первого класса. Подобные функции могут быть переданы как параметры другим функциям и возвращены в качестве результата от других функций. Мы делали много из первого, но ещё не применяли последнее.

Функции, которые принимают или возвращают другие функции, также называются «функциями высшего порядка.

В примере выше мы передаём стрелочную функцию в filter: book => publishedInYear(book, year), и хорошо бы избавиться от неё. Для того чтобы сделать это, нам нужна функция, которая принимает книгу и возвращает true, если книга была опубликована в нужный год. Но также нам необходимо передать и номер года, чтобы сделать её гибкой.

Способ, которым мы можем решить эту проблему — это создание функции, которая вернёт другую функцию. Я напишу это на обычном синтаксисе функций, чтобы вы понимали, что здесь происходит, но далее мы перейдём к более короткой версии со стрелочным синтаксисом:

// Полная версия функции
function publishedInYear(year) {
  return function(book) {
    return book.year === year
  }
}
 
// Стрелочная версия:
const publishedInYear = year => book => book.year === year

Это новая версия функции publishedInYear, мы можем переписать вызов filter, исключив стрелочную функцию:

const publishedInYear = year => book => book.year === year
 
const titlesForYear = (books, year) => {
  const selected = filter(publishedInYear(year), books)
 
  return map(book => book.title, selected)
}

Теперь, когда мы вызывем filter, publishedInYear(year) немедленно вызывается и возвращает функцию, которая берёт книгу, что как раз и нужно для filter.

Частично примененяемые функции


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

К примеру, если бы у нас был какой-то другой код, который бы просто хотел проверить, что книга опубликована в определённом году, мы бы хотели написать например так: publishedInYear(book, 2012), но мы не можем писать подобным образом. Вместо этого, нам придётся написать немного по другому: publishedInYear(2012)(book). Это менее читабельно и более раздражительно.

К счастью, Ramda предоставляет две функции, чтобы помочь нам в этом: partial и partialRight.

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

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

Давайте вернёмся назад к нашему оригинальному примеру и попробуем использовать эти функции вместо переписывания publishedInYear. Посколько нам нужно предоставить только год, и это самый правый аргумент, нам нужно использовать partialRight:

const publishedInYear = (book, year) => book.year === year
 
const titlesForYear = (books, year) => {
  const selected = filter(partialRight(publishedInYear, [year]), books)
 
  return map(book => book.title, selected)
}

Если бы мы написали publishedInYear, принимающей (year, book) вместо (book, year), мы бы использовали partial вместо partialRight.

Обратите внимание, что аргументы, которые мы передаём в partial и partialRight, всегда должны быть в массиве, даже если вы передаёте туда только один из них. Я не могу сказать, сколько раз я забывал об этом и получал сбивающее с толку сообщение об ошибке:

First argument to _arity must be a non-negative integer no greater than ten

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


Необходимость использовать partial и partialRight везде приводит к многословию и утомлению. Но необходимость вызывать функции с многими аргументами как серию одно-аргументных функций давноценно плоха.

К счастью, Ramda предоставляет нам решение: каррирование.

curry — это другая основная концепция функционального программирования. Технически, каррированная функция — это всегда серия одно-аргументных функций, о которых я только что жаловался. В чистых функциональных языках, ситаксис выглядит таким образом, что у него нет отличия от вызова функций с нескольмими аргументами.

Но потому что Ramda — это JavaScript библиотека, и JavaScript не имеет хорошего синтаксиса для вызова серии одно-аргументных функций, авторы немного смягчили традиционное определение каррирования.

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

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

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

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

const publishedInYear = curry((year, book) => book.year === year)
 
const titlesForYear = (books, year) => {
  const selected = filter(publishedInYear(year), books)
 
  return map(book => book.title, selected)
}

Теперь мы можем единожды вызвать publishedInYear только с годом и получить назад функцию, которая будет брать книгу и вызывать нашу оригинальную функцию. Тем не менее, мы всё ещё можем вызывать обычным образом publishedInYear(2012, book) без раздражающего )( синтаксиса. Лучшее из двух миров!

Порядок аргументов


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

Вы можете думать о первых параметрах как о конфигурации для операции. Так, для publishedInYear, параметр года — это конфигурация (что мы ищем?), а параметр книги — это данные (где мы ищем?).

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

Аргументы в некорректном порядке


Что если мы оставим порядок аргументов функции publishedInYear без изменений? Как мы можем всё ещё получить пользу от природы каррирования?

Ramda предоставляет несколько вариантов.

Flip


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

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

const publishedInYear = curry((book, year) => book.year === year)
 
const titlesForYear = (books, year) => {
  const selected = filter(flip(publishedInYear)(year), books)
 
  return map(book => book.title, selected)
}

В большинстве случаев, я бы предпочёл использовать более удобный порядок аргументов, но, к примеру, если вам необходимо использовать такую функцию, которую вы не контроллируете, то flip — это полезный вариант.

Заполнитель


Более общий вариант — это аргумент „заполнитель“ (__).

Что если мы имеем каррированную функцию с тремя аргументами, и желаем передать первый и последний аргументы, оставляя тот, который посередине — на будущее? Мы можем использовать заполнитель для серединного аргумента:

const threeArgs = curry((a, b, c) => { /* ... */ })
 
const middleArgumentLater = threeArgs('value for a', __, 'value for c')

Мы также можем использовать заменитель более одного раза в вызове. К примеру, что если мы хотим передать только серединный аргумент?

const threeArgs = curry((a, b, c) => { /* ... */ })
 
const middleArgumentOnly = threeArgs(__, 'value for b', __)

Мы можем использовать стиль заполнителя вместо flip, если хотим:

const publishedInYear = curry((book, year) => book.year === year)
 
const titlesForYear = (books, year) => {
  const selected = filter(publishedInYear(__, year), books)
 
  return map(book => book.title, selected)
}

Я нахожу эту версию более читабельной, но если мне нужно многократно использовать „перевёрнутую“ версию publishedInYear, я могу определить дополнительную функцию, используя flip и далее использовать её везде. Возможно, вы увидите несколько примеров в будущих постах.

Обратите внимание, что __ работает только с каррированными функциями, когда partial, partialRight и flip работают с любой функцией. Если вам необходимо использовать __ с нормальной функцией, вы всегда можете обернуть её вызовом curry перед этим.

Давайте сделаем конвеер


Давайте посмотрим, как мы можем переместить наши вызовы filter и map внутрь конвеера. Это текущее состояние кода, с удобным порядком аргументов для publishedInYear:

const publishedInYear = curry((year, book) => book.year === year)
 
const titlesForYear = (books, year) => {
  const selected = filter(publishedInYear(year), books)
 
  return map(book => book.title, selected)
}

Мы узнали о pipe и compose в прошлом посте, но нам необходимо узнать ещё один кусочек информации, чтобы получить полную пользу из этого изучения.

Последний кусочек информации следующий: почти каждая Ramda функция каррирована по умолчанию. Это включает в себя filter и map. Так что filter(publishedInYear(year)) прекрасно подходит и возвращает новую функцию, которая просто ожидает, когда её будут переданы книги впоследствии, также как и map(book => book.title).

И теперь мы можем написать конвеер:

const publishedInYear = curry((year, book) => book.year === year)
 
const titlesForYear = (books, year) =>
  pipe(
    filter(publishedInYear(year)),
    map(book => book.title)
  )(books)

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

const publishedInYear = curry((year, book) => book.year === year)
 
const titlesForYear = curry((year, books) =>
  pipe(
    filter(publishedInYear(year)),
    map(book => book.title)
  )(books)
)

Заключение


Данный пост, возможно — самая глубокая часть из этой серии статей. Частичное применение и каррирование может занять некоторое время и силы, чтобы уложиться в голове. Но когда вы однажды „получите“ их, они познакомят вас с очень мощным способом преобразования данных в функциональном стиле.

Они заставляют нас производить преобразования на основе конвееров, состоящих из маленьких простых строительных блоков.

Далее


Для того чтобы начать писать в функциональном стиле, нам необходимо начать думать „декларативно“ вместо „императивно“. Для этого нам придётся найти способы выражения императивных конструкций в функциональном стиле. Статья о декларативном программировании будет обсуждать эти идеи.

Комментарии (0)