Функциональное программирование (ФП) — это стремительно набирающий популярность стиль написания кода. Есть много материалов о концепциях ФП, но мало — о том, как применять их на практике. На мой взгляд, разбираться в примерах использования куда важнее, ведь по-настоящему понять и прочувствовать стиль программирования можно только на практике. Поэтому данная статья будет посвящена практическому введению в стиль функционального программирования на 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-коде. Большая часть ФП действительно проста: надо как можно чаще писать чистые функции.
Читайте также:
- Советы по анимации с CSS и JavaScript
- 3 способа клонирования объектов в JavaScript
- Основы JavaScript: управление DOM элементами (часть 1)
Перевод статьи Bret Cameron: Functional Programming in JavaScript: Introduction and Practical Examples