Функциональное программирование в JavaScript: руководство с практическими примерами

Java Script

Функциональное программирование (ФП) — это стремительно набирающий популярность стиль написания кода. Есть много материалов о концепциях ФП, но мало — о том, как применять их на практике. На мой взгляд, разбираться в примерах использования куда важнее, ведь по-настоящему понять и прочувствовать стиль программирования можно только на практике. Поэтому данная статья будет посвящена практическому введению в стиль функционального программирования на JavaScript.

В отличие от некоторых статей и рекомендаций, я не буду побуждать вас к использованию только функций высшего порядка (map, filter и reduce). Да, эти функции — полезный инструмент в арсенале функционального программиста. Однако функции высшего порядка — это лишь часть общей картины. Многие кодовые базы пользуются такими функциями, но забывают об остальных принципах ФП. Поэтому для создания функций, которые позволят нам максимально придерживаться парадигмы функционального программирования, я буду пользоваться vanilla JavaScript. Однако для начала необходимо разобраться в двух основополагающих концепциях.

Примечание: возможности ES6 JavaScript (стрелочные функции и оператор spread) упрощают процесс написания ФП-кода, так что настоятельно рекомендуется работать в дружественной для ES6 среде!

Функциональное программирование в JavaScript: руководство с практическими примерами — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

Концепция 1. Чистые функции

Это настоящее сердце функционального программирования. Чистая функция обладает тремя свойствами:

1. Одинаковые аргументы всегда дают одинаковый результат.

/** Эта функция — чистая:
 * при одинаковых входных значениях получается одинаковый результат.
 */
 const cubeRoot = num => Math.pow(num, 1/3);

/** Эта функция нечистая:
 * здесь один и тот же аргумент может выдавать разные результаты.
 */
 const randInt = (min, max) => {
 return parseInt(Math.random() * (max — min) + min);
 };

2. Чистая функция не может зависеть от какой-либо переменной, объявленной за пределами своей области видимости.

const stock = ['pen', 'pencil', 'notepad', 'highlighter'];
/** Эта функция нечистая:
 * она ссылается на переменную stock в глобальном пространстве имен.
 */
 const isInStock = item => {
 return stock.indexOf(item) !== -1;
 };

/** Эта функция чистая:
 * она не зависит от каких-либо переменных вне своей области видимости.
 */
 const isInStock = item => {
 const stock = ['pen', 'pencil', 'notepad', 'highlighter'];
 return stock.indexOf(item) !== -1;
 };

/** Эта функция тоже чистая: 
 * все переменные передаются в качестве аргументов.
 */
 const isInStock = (item, array) => {
 return array.indexOf(item) !== -1;
 };

3. Функция не может вызывать побочных эффектов. То есть никаких изменений во внешних переменных, никаких вызовов к console.log и никакого запуска дополнительных процессов.

let fruits = ['apple', 'orange', 'apple', 'apple', 'pear'];

/** Эта функция чистая:
 * она не изменяет переменную fruits.
 */
 const countApples = fruits => fruits.filter(word => word === 'apple').length;

/** Эта функция нечистая:
 * она «деструктивно» изменяет переменную fruits (побочный эффект).
 */
 const countApples = () => {
 fruits = fruits.filter(word => word === 'apple');
 return fruits.length;
 };

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

Концепция 2. Комбинаторы

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

  • В комбинаторе отсутствуют свободные переменные.

Свободная (независимая) переменная — это любая переменная, к значениям которой нельзя обратиться обособленно. Каждая переменная в комбинаторе должна передаваться через параметры.

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

const convertUSD = (val, code) => {
  const conversionRates = {
    CNY: 7.07347,
    EUR: 0.906250,
    GBP: 0.796313,
    INR: 71.1427,
    USD: 1,
  };  if (!conversionRates[code]) {
    throw new Error('This currency code is not available');
  };  return val * conversionRates[code];
};

Чтобы превратить convertUSD в комбинатора, нужно будет передать в качестве параметра данные обменного курса.

А вот эти функции, наоборот, — комбинаторы:

const add = (x, y) => x + y;
const multiple = (x, y) => x + y;
const sum = (...nums) => nums.reduce((x, y) => x + y);
const product = (...nums) => nums.reduce((x, y) => x * y);

Очевидно, что add и multiply не содержат свободных переменных. Но как насчет sum и product? Они, вроде как, вводят две новые переменные (x и y), которые не являются параметрами. Однако в данном случае значения x и y прямо определяются аргументами, передаваемыми в каждую функцию. Поэтому x и y являются не новыми переменными, а псевдонимами уже существующих. Получается, что sum и product мы можем смело считать комбинаторами.

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

Почему функциональные программисты сторонятся циклов?

В сети часто пишут о том, что функциональные программисты сторонятся циклов for и while, но не объясняют, почему. Вот вам пример. Ниже написана функция, которая создает массив значений от 1 до 100:

const list1to100 = () => {
  const arr = [];
  for (let i = 0; i < 100; i++) {
    arr.push(i + 1);
  };
  return arr;
};

Для использования такого цикла for нам, скорее всего, потребуются две свободные переменные (arr и i). Из-за этого for не будет комбинатором. Выражаясь техническим языком, перед нами — чистая функция, не видоизменяющая переменных за пределами своей локальной области видимости. Однако по возможности нам бы хотелось избежать любых мутаций.

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

const list1to100 = () => {
  return new Array(100).fill(undefined).map((x, i) => i + 1);
};

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

В чем польза чистых функций и комбинаторов?

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

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

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

Далее в статье мы рассмотрим практические примеры:

  • написания ФП-кода;
  • решения выше обозначенных проблем.

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

Функциональное программирование в JavaScript: руководство с практическими примерами — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

Знакомство с функцией compose

Одной из самых популярных задач в функциональном программировании является объединение нескольких функций в одну. Такая функция называется compose и представляет собой типичный комбинатор.

Допустим, нам нужна функция, которая будет конвертировать центы в доллары. ФП призывает нас к разделению данной задачи на несколько составляющих. Давайте начнем с создания четырех функций: divideBy100, roundTo2dp, addDollarSign и addSeparators.

const divideBy100 = num => num / 100;const roundTo2dp = num => num.toFixed(2);const addDollarSign = str => '$' + String(str);const addSeparators = str => {
  // add commas before the decimal point
  str = str.replace(/(?<!.d+)B(?=(d{3})+b)/g, `,`);
  // add commas after the decimal point
  str = str.replace(/(?<=.(d{3})+)B/g, `,`);
  return str;
};

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

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

const centsToDollars = 
  addSeparators(
    addDollarSign(
      roundTo2dp(
        divideBy100
      )
    )
  );

Решение неплохое. Но по мере разрастания используемых функций следить за скобками станет куда сложнее. И здесь вступает в дело compose. Функция compose позволяет нам объединять функции следующим образом:

const centsToDollars = compose(
  addSeparators,
  addDollarSign,
  roundTo2dp,
  divideBy100,
);

А без замороченных скобок такой код выглядит куда эффектнее. Так как же создается compose?

Создание функции compose

Compose можно прописать с помощью функции высшего порядка reduceRight буквально в одной строке:

const compose = (...fns) => x => fns.reduceRight((res, fn) => fn(res), x);

Так что же происходит в коде выше?

  • Во-первых, мы используем оператор распространения… для передачи произвольного количества функций в качестве параметров.
  • Затем, мы хотим превратить наш массив функций (fns) в один вывод. Конечно, можно воспользоваться классической JavaScript-функцией reduce. Но тогда compose будет выполняться справа-налево. Поэтому нам подойдет reduceRight.
  • reduceRight принимает функцию обратного вызова в качестве своего первого аргумента. В этом обратном вызове мы передаем два параметра: результат (res), в котором отслеживаем самый последний возвращенный результат, и функцию (fn) — ей мы пользуемся для запуска каждой функции в массиве fns.
  • И, наконец, в reduceRight есть необязательный второй аргумент, который определяет ее начальное значение. В данном случае это x.

Примечание: если вы предпочитаете выполнять функции слева направо, то вместо reduceRight можете воспользоваться reduce. Такую разновидность compose (слева направо) принято называть pipe или sequence.

Теперь этот код должен вернуть одну функцию:

const centsToDollars = compose(
  addSeparators,
  addDollarSign,
  roundTo2dp,
  divideBy100,
);

А при запуске console.log(typeof centsToDollars) мы увидим “function” .

Теперь давайте потренируемся на практике. При выполнении console.log(centsToDollars(100000000)) у нас должен получиться результат $1,000,000.00. Превосходно!

Мы только что написали наш первый реальный пример функционального кода! Не хотите добавлять значок доллара? Тогда просто уберите addDollarSign из аргументов compose. Ваше начальное значение в долларах, а не центах? Удаляйте divideBy100. Раз мы следуем канонам ФП, то можем быть уверенными в том, что удаление данных функций никак не скажется на остальном коде.

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

Отладка compose

Но появилась другая проблема. Предположим, что с помощью удобной функции addSeparators мы форматируем 20 различных чисел в приложении… но что-то пошло не так. Чтобы следить за происходящим, мы, как правило, добавляем в функцию оператор console.log:

const addSeparators = str => {
  str = str.replace(/(?<!.d+)B(?=(d{3})+b)/g, `,`);
  str = str.replace(/(?<=.(d{3})+)B/g, `,`);
  console.log(str);
  return str;
};

Но сейчас от этого мало пользы, ведь функция запускается 20 раз при каждой загрузке приложения, и мы видим 20 копий console.log!

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

Функция tap

Функция tap запускает функцию с заданным объектом, а затем возвращает этот объект:

const tap = f => x => {
  f(x);
  return x;
};

Так мы сможем выполнять дополнительные функции в составе прочих функций, передаваемых в compose и не влиять при этом на результат. Получается, что tap является идеальным местом для логирования данных в консоль.

Функция trace

Теперь вызовем логирующую функцию trace, а в качестве функции обратного вызова выберем console.log:

const trace = label => tap(console.log.bind(console, label + ‘:’));

Обратите внимание, что нам потребуется bind. Она проверяет доступность глобального объекта console при выполнении tap. Следующий параметр — label. Он добавляет строку перед залогированной информацией в консоли, что в разы упрощает отладку.

Вернемся к compose. Мы можем добавить функции trace и следить за передачей объекта между другими объектами:

const centsToDollars = compose(
  trace('addSeparators'),
  addSeparators,
  trace('addDollarSign'),
  addDollarSign,
  trace('roundTo2dp'),
  roundTo2dp,
  trace('divideBy100'),
  divideBy100,
  trace('argument'),
);

Теперь при запуске centsToDollars(100000000) в консоли мы увидим следующее:

argument: 100000000
divideBy100: 1000000
roundTo2dp: 1000000.00
addDollarSign: $1000000.00
addSeparators: $1,000,000.00

И если на каком-то этапе возникнут ошибки, их можно будет легко обнаружить!

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

Функциональное программирование в JavaScript: руководство с практическими примерами — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

Контейнеры

В заключительной части статьи кратко поговорим о контейнерах. Мы не сможем на 100% избавиться от запутанного кода с кучей состояний. И здесь функциональное программирование предлагает свое решение: изолировать нечистый код из кодовой базы. Таким образом, весь видоизменяемый, «грязный» код с побочными эффектами будет храниться в одном месте, не «загрязняя» остальную базу. Наша чистая логика будет взаимодействовать с таким кодом с помощью мостов — методов, которые мы создаем для управляемого вызова побочных эффектов и видоизменяемых переменных.

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

const isFunction = fn => fn && Object.prototype.toString.call(fn) === '[object Function]';const isAsync = fn => fn && Object.prototype.toString.call(fn) === '[object AsyncFunction]';
const isPromise = p => p && Object.prototype.toString.call(p) === '[object Promise]';

Создавать контейнер мы будем с помощью ES6 синтаксиса для классов. Но вы можете воспользоваться и обычной функцией:

class Container {
  constructor(fn) {
    this.value = fn;
    if (!isFunction(this.value) && !isAsync(this.value)) {
      throw new TypeError('Container expects a function, not a ${typeof this.value}.');
    };
  }  run() {
    return this.value();
  }
};

Наш constructor принимает функцию или асинхронную функцию. При отсутствии того и другого, выбрасывается TypeError. Затем функцию выполняет метод run.

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

const sayHello = () => 'Hello';const container = new Container(sayHello);
console.log(container.run());  // 'Hello'

Конечно же, sayHello не является нечистой функцией. Но она может таковой оказаться!

Ну, а чтобы контейнер стал еще более полезным, неплохо будет выполнить дополнительные функции c результатом метода контейнера run. С этой целью добавим map в качестве метода класса Container:

map(fn) {
  if (!isFunction(fn) && !isAsync(fn)) {
    throw new TypeError('The map method expects a function, not a ${typeof fn}.');
  };  return new Container(
    () => isPromise(this.value()) ?
      this.value().then(fn) : fn(this.value())
  )
}

Так в качестве параметра будет приниматься новая функция. Если результатом исходной функции (this.value()) станет промис (promise), то он свяжет эту новую функцию с помощью метода then. В противном случае, он просто выполнит функцию this.value().

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

const sayHello = () => 'Hello';
const addName = (name, str) => str + ' ' + name;const container = new Container(sayHello);const greet = container
  .map(addName.bind(this, 'Joe Bloggs'))
  .map(tap(console.log));

При выполнении greet.run() в консоли должно появиться сообщение Hello Joe Bloggs.

Весь код из этой части доступен в gist.

На самом деле, контейнеров намного больше. К примеру, популярный инструмент Redux является не чем иным, как контейнером для управления состоянием. Мы надеемся, что данный пример помог вам разобраться с сутью контейнеров и их пользой.

Заключение

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

Специально для сайта ITWORLD.UZ. Новость взята с сайта NOP::Nuances of programming