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


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

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

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

Концепция 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.

Знакомство с функцией 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.

Контейнеры

В заключительной части статьи кратко поговорим о контейнерах. Мы не сможем на 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-коде. Большая часть ФП действительно проста: надо как можно чаще писать чистые функции.


Перевод статьи Bret Cameron: Functional Programming in JavaScript: Introduction and Practical Examples


Поделиться статьей:


Вернуться к статьям

Комментарии

    Ничего не найдено.