14 ноября 2019

Функциональное программирование с точки зрения EcmaScript. Композиция, каррирование, частичное применение

JavaScript
Привет, Хабр!

Сегодня мы продолжим наши изыскания на тему функционального программирования в разрезе EcmaScript, на спецификации которого основан JavaScript. В предыдущей статье мы разобрали основные понятия: чистые функции, лямбды, концепцию имутабельности. Сегодня поговорим о чуть более сложных техниках ФП: композиции, каррировании и чистых функциях. Статья написана в стиле «псевдо кодревью», т.е. мы будем решать практическую задачу, одновременно изучая концепции ФП и рефакторя код для приближения последнего к идеалам ФП.

Итак, начнём!

Предположим, перед нами стоит задача: создать набор инструментов для работы с палиндромами.
ПАЛИНДРО́М
Мужской родСПЕЦИАЛЬНОЕ
Слово или фраза, которые одинаково читаются слева направо и справа налево.
«П. «Я иду с мечем судия»»
Одна из возможных реализаций данной задачи могла бы выглядеть так:

function getPalindrom (str) {
  const regexp = /[\.,\/#!$%\^&\*;:{}=\-_`~()?\s]/g;
  str = str.replace(regexp, '').toLowerCase().split('').reverse().join('');
  //далее какой-то аякс запрос в словарь или к логике, которая генерирует фразы по переданным буквам

  return str;
}

function isPalindrom (str) {
  const regexp = /[\.,\/#!$%\^&\*;:{}=\-_`~()?\s]/g;
  str = str.replace(regexp, '').toLowerCase();
  return str === str.split('').reverse().join('');
}

Безусловно, эта реализация работает. Мы можем ожидать, что getPalindrom будет работать корректно если апи будет возвращать корректные данные. Вызов isPalindrom('Я иду с мечем судия') вернёт true, а вызов isPalindrom('не палиндром') вернёт false. Хороша ли эта реализация с точки зрения идеалов функционального программирования? Определённо, не хороша!

Согласно определению Чистых функций из этой статьи:
Чистые функции (Pure functions, PF) — всегда возвращают предсказуемый результат.
Свойства PF:

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

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

Вспомним определение: Функциональное программирование — способ написания кода через составления набора функций.

Составим набор функций для этой задачи:

const allNotWordSymbolsRegexpGlobal = () => /[\.,\/#!$%\^&\*;:{}=\-_~()?\s]/g;//(1)
const replace = (regexp, replacement, str) => str.replace(regexp, replacement);//(2)
const toLowerCase = str => str.toLowerCase();//(3)
const stringReverse = str => str.split('').reverse().join('');//(4)
const isStringsEqual = (strA, strB) => strA === strB;//(5)

В строке 1 мы объявили константу регулярного выражения в функциональной форме. Такой способ описания констант часто применяется в ФП. Во 2-й строке мы инкапсулировали метод String.prototype.replace в функциональную абстракцию replace, для того чтобы он(вызов replace) соответствовал контракту функционального программирования. В 3-й строке идентичным образом создали абстракцию для String.prototype.toLowerCase. В 4-й реализовали функцию создающую новую развёрнутую строку из переданной. 5-я проверяет на равенство строки.

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

Теперь нам необходимо реализовать проверку — является ли строка палиндромом. На помощь нам придёт композиция функций.

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

Возможно, определение покажется сложным, но с практической точки зрения оно справедливо.

Мы можем сделать так:

isStringsEqual(toLowerCase(replace(allNotWordSymbolsRegexpGlobal(), '', 'Я иду с мечем судия')), stringReverse(toLowerCase(replace(allNotWordSymbolsRegexpGlobal(), '', 'Я иду с мечем судия'))));

или вот так:

const strA = toLowerCase(replace(allNotWordSymbolsRegexpGlobal(), '', 'Я иду с мечем судия'));
const strB = stringReverse(toLowerCase(replace(allNotWordSymbolsRegexpGlobal(), '', 'Я иду с мечем судия')));
console.log(isStringsEqual(strA, strB));

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

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

const compose = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);

Функция compose принимает в качестве аргументов список выполняемых функций, превращает их в массив, сохраняет его в замыкании и возвращает функцию, которая ожидает начальное значение. После того как начальное значение передано, запускается последовательное выполнение всех функций из массива fns. Аргументом первой функции будет переданное начальное значение x, а аргументами всех последующих будет результат выполнения предыдущей. Так мы сможем создавать композиции любого числа функций.

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

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

const replace = (regexp, replacement, str) => str.replace(regexp, replacement);

ожидает принять 3 параметра на вход, а мы в compose передаём только один. Решить эту проблему нам поможет другая техника ФП — Каррирование.

Каррирование — преобразование функции от многих аргументов к функции от одного аргумента.

Помните нашу функцию add из первой статьи?

const add = (x,y) => x+y;

Её можно каррировать так:

const add = x => y => x+y;

Функция принимает x и возвращает лямбду, которая ожидает y и выполняет действие.

Преимущества каррирования:

  • код выглядит лучше;
  • каррированные функции всегда чисты.

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

const replaceAllNotWordSymbolsGlobal = replacement => str => replace(allNotWordSymbolsRegexpGlobal(), replacement, str);

Как видите мы закрепляем один из аргументов константой. Это связано с тем, что на самом деле каррирование это частный случай частичного применения.

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

В нашем случае мы создали функцию replaceAllNotWordSymbolsGlobal, которая является частично примененным вариантом replace. Она принимает replacement, сохраняет его в замыкании и ожидает на вход строку для которой вызовет replace, а аргумент regexp мы закрепляем константтой.

Вернёмся к палиндромам. Создадим композицию функций для палиндромовой сроки:

const processFormPalindrom = compose(
  replaceAllNotWordSymbolsGlobal(''),
  toLowerCase,
  stringReverse
);

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

const processFormTestString = compose(
  replaceAllNotWordSymbolsGlobal(''),
  toLowerCase,
);

теперь вспомним то, что мы говорили выше:
типичный пример композиции — передача вызова одной функции в качестве аргумента другой
и напишем:

const testString = 'Я иду с мечем судия';//в данном случае я не стал переписывать коснтанту в функциональном стиле, т.к. подразумеваю, что это не константа, а значение, которое приходит откуда-то из внешнего кода, например с сервера
const isPalindrom = isStringsEqual(processFormPalindrom(testString), processFormTestString(testString));

Вот мы получили рабочее и неплохо выглядящее решение:

const allNotWordSymbolsRegexpGlobal = () => /[\.,\/#!$%\^&\*;:{}=\-_~()?\s]/g;
const replace = (regexp, replacement, str) => str.replace(regexp, replacement);
const toLowerCase = str => str.toLowerCase();
const stringReverse = str => str.split('').reverse().join('');
const isStringsEqual = (strA, strB) => strA === strB;

const replaceAllNotWordSymbolsGlobal = replacement => str => replace(allNotWordSymbolsRegexpGlobal(), replacement, str);

const compose = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);

const processFormPalindrom = compose(
  replaceAllNotWordSymbolsGlobal(''),
  toLowerCase,
  stringReverse
);

const processFormTestString = compose(
  replaceAllNotWordSymbolsGlobal(''),
  toLowerCase,
);

const testString = 'Я иду с мечем судия';
const isPalindrom = isStringsEqual(processFormPalindrom(testString), processFormTestString(testString));

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

const curry = fn => (...args) => {
  if (fn.length > args.length) {
    const f = fn.bind(null, ...args);
    return curry(f);
  } else {
    return fn(...args)
  }
}

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

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

const replaceAllNotWordSymbolsToEmpltyGlobal = curry(replace)(allNotWordSymbolsRegexpGlobal(), '');

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

const party = (fn, x) => (...args) => fn(x, ...args);

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

Теперь перепишем party так, чтобы мы могли создать частично применённую функцию от нескольких аргументов:

const party = (fn, ...args) => (...rest) => fn(...args.concat(rest));

Отдельно стоит отметить, что каррированные таким способом функции могут быть вызваны с любым количеством аргументов меньше задикларированного(fn.length).

const sum = (a,b,c,d) => a+b+c+d;
const fn = curry(sum);

const r1 = fn(1,2,3,4);//очевидно, рабочий пример
const r2 = fn(1, 2, 3)(4);//этот и все последующие также будут работать
const r3 = fn(1, 2)(3)(4);
const r4 = fn(1)(2)(3)(4);
const r5 = fn(1)(2, 3, 4);
const r6 = fn(1)(2)(3, 4);
const r7 = fn(1, 2)(3, 4);

Вернёмся к нашим палиндромам. Мы можем переписать нашу replaceAllNotWordSymbolsToEmpltyGlobal без лишних скобок:

const replaceAllNotWordSymbolsToEmpltyGlobal = party(replace,allNotWordSymbolsRegexpGlobal(), '');

Давайте посмотрим на весь код:

//Набор функций может лежать где-то в бандле по утилям для строк 
const allNotWordSymbolsRegexpGlobal = () => /[\.,\/#!$%\^&\*;:{}=\-_~()?\s]/g;
const replace = (regexp, replacement, str) => str.replace(regexp, replacement);
const toLowerCase = str => str.toLowerCase();
const stringReverse = str => str.split('').reverse().join('');
const isStringsEqual = (strA, strB) => strA === strB;

//Строки могут приходить откуда нибудь с сервера
const testString = 'Я иду с мечем судия';

//инструменты функционального программирования могут лежать в отдельном бандле или можно использовать какую-нибудь функциональную библиотеку типа rambda.js

const compose = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);

const curry = fn => (...args) => {
  if (fn.length > args.length) {
    const f = fn.bind(null, ...args);
    return curry(f);
  } else {
    return fn(...args)
  }
}

const party = (fn, ...args) => (...rest) => fn(...args.concat(rest));


//и вот он бандл с нашей программой
const replaceAllNotWordSymbolsToEmpltyGlobal = party(replace,allNotWordSymbolsRegexpGlobal(), '');

const processFormPalindrom = compose(
  replaceAllNotWordSymbolsToEmpltyGlobal,
  toLowerCase,
  stringReverse
);

const processFormTestString = compose(
  replaceAllNotWordSymbolsToEmpltyGlobal,
  toLowerCase,
);

const checkPalindrom = testString => isStringsEqual(processFormPalindrom(testString), processFormTestString(testString));


Выглядит здорово, но вдруг нам не строка, а массив придёт? Поэтому допишем ещё одну функцию:

const map = fn => (...args) => args.map(fn);

Теперь если у нас будет массив для тестирования на палиндромы, то:

const palindroms = ['Я иду с мечем судия','А к долу лодка','Кит на море романтик'. 'Не палиндром']

map(checkPalindrom )(...palindroms ); // [true, true, true, false] работает корректно

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

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

В функциональных библиотеках, например ramda.js есть функции compose и pipe. compose реализует алгоритм композиции справа налево, а pipe слева направо. Наша функция compose это аналог pipe из ramda. В библиотеке две разных функции композирования т.к. композиция справа-налево и слева-направо это два разных контракта функционального программирования. Если кто-то из читателей найдёт статью, в которой описаны все существующие контракты ФП, то поделитесь ей в комментах, с радостью почитаю и плюсик коменту поставлю!

Количество формальных параметров функции называют арностью. то тоже важное определение с точки зрения теории ФП.

Заключение


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

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

К слову, вы можете продолжить развитие этого модуля работы с палиндромами и развить его идеи, например загружать строки по апи, преобразовывать в наборы букв и отправлять на сервер, где будет генерироваться строка палиндром и многое другое… На ваше усмотрение.

Также неплохо было бы избавится от дублирования в процессах этих строк:

  replaceAllNotWordSymbolsToEmpltyGlobal,
  toLowerCase,

В общем, совершенствовать код можно и нужно постоянно!

До будущих статей.
Теги:javascriptфункциональное программированиекаррированиекомпозициячастичное применение
Хабы: JavaScript
+5
5,8k 74
Комментарии 46
Лучшие публикации за сутки