Источник: Nuances of Programming
Функциональное программирование (ФП) — это стремительно набирающий популярность стиль написания кода. Есть много материалов о концепциях ФП, но мало — о том, как применять их на практике. На мой взгляд, разбираться в примерах использования куда важнее, ведь по-настоящему понять и прочувствовать стиль программирования можно только на практике. Поэтому данная статья будет посвящена практическому введению в стиль функционального программирования на JavaScript.
В отличие от некоторых статей и рекомендаций, я не буду побуждать вас к использованию только функций высшего порядка (map, filter и reduce). Да, эти функции — полезный инструмент в арсенале функционального программиста. Однако функции высшего порядка — это лишь часть общей картины. Многие кодовые базы пользуются такими функциями, но забывают об остальных принципах ФП. Поэтому для создания функций, которые позволят нам максимально придерживаться парадигмы функционального программирования, я буду пользоваться vanilla JavaScript. Однако для начала необходимо разобраться в двух основополагающих концепциях.
Примечание: возможности ES6 JavaScript (стрелочные функции и оператор spread) упрощают процесс написания ФП-кода, так что настоятельно рекомендуется работать в дружественной для ES6 среде!
Концепция 1. Чистые функции
Это настоящее сердце функционального программирования. Чистая функция обладает тремя свойствами:
1. Одинаковые аргументы всегда дают одинаковый результат.
3. Функция не может вызывать побочных эффектов. То есть никаких изменений во внешних переменных, никаких вызовов к console.log и никакого запуска дополнительных процессов.
Все это роднит чистые функции с математическими. Пользуясь чистыми функциями как можно чаще, мы поддерживаем большую прозрачность кода и его предсказуемость, а также упрощаем поддержку и отладку. К тому же, это побуждает нас разделять крупные задачи на более мелкие и легко управляемые части.
Концепция 2. Комбинаторы
Комбинаторы похожи на чистые функции, но еще более ограничены. К комбинатору предъявляются те же требования, что и к чистой функции, с одним небольшим дополнением:
- В комбинаторе отсутствуют свободные переменные.
Свободная (независимая) переменная — это любая переменная, к значениям которой нельзя обратиться обособленно. Каждая переменная в комбинаторе должна передаваться через параметры.
Таким образом, ниже приведена чистая функция, которая не является комбинатором. Она зависит от переменной conversionRates, и к ней нельзя обратиться независимо, т.к. это — не параметр функции.
Очевидно, что add и multiply не содержат свободных переменных. Но как насчет sum и product? Они, вроде как, вводят две новые переменные (x и y), которые не являются параметрами. Однако в данном случае значения x и y прямо определяются аргументами, передаваемыми в каждую функцию. Поэтому x и y являются не новыми переменными, а псевдонимами уже существующих. Получается, что sum и product мы можем смело считать комбинаторами.
Примечание: как правило, комбинаторы в ФП принимают и возвращают функции. На практике вы не часто встретите функции из примера выше, даже несмотря на то, что они написаны по канону ФП. Подробнее о классическом комбинаторе из функционального программирования см. ниже в главе «Знакомство с функцией compose».
Почему функциональные программисты сторонятся циклов?
В сети часто пишут о том, что функциональные программисты сторонятся циклов for и while, но не объясняют, почему. Вот вам пример. Ниже написана функция, которая создает массив значений от 1 до 100:
Для использования такого цикла 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.
Это предельно простой код, за исключением функции addSeparators, которая с помощью нескольких изящных регулярных выражений добавляет запятые перед каждой третьей цифрой.
Теперь, когда у нас есть четыре функции, нужно подумать над их объединением. Традиционно это делается с помощью скобок:
А без замороченных скобок такой код выглядит куда эффектнее. Так как же создается 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.
Теперь этот код должен вернуть одну функцию:
А при запуске console.log(typeof centsToDollars) мы увидим “function” .
Теперь давайте потренируемся на практике. При выполнении console.log(centsToDollars(100000000)) у нас должен получиться результат $1,000,000.00. Превосходно!
Мы только что написали наш первый реальный пример функционального кода! Не хотите добавлять значок доллара? Тогда просто уберите addDollarSign из аргументов compose. Ваше начальное значение в долларах, а не центах? Удаляйте divideBy100. Раз мы следуем канонам ФП, то можем быть уверенными в том, что удаление данных функций никак не скажется на остальном коде.
А еще мы можем повторно использовать эти менее крупные функции в любой части кода. Например, addSeparators пригодится для форматирования других чисел в нашем приложении. Функциональное программирование говорит нам о том, что мы можем совершенно спокойно использовать эту функцию повторно!
Отладка compose
Но появилась другая проблема. Предположим, что с помощью удобной функции addSeparators мы форматируем 20 различных чисел в приложении… но что-то пошло не так. Чтобы следить за происходящим, мы, как правило, добавляем в функцию оператор console.log:
Но сейчас от этого мало пользы, ведь функция запускается 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 и следить за передачей объекта между другими объектами:
И если на каком-то этапе возникнут ошибки, их можно будет легко обнаружить!
Список всех созданных нами функций, включая пример с 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 синтаксиса для классов. Но вы можете воспользоваться и обычной функцией:
Наш constructor принимает функцию или асинхронную функцию. При отсутствии того и другого, выбрасывается TypeError. Затем функцию выполняет метод run.
Мы можем хранить нечистые функции внутри контейнера, и они не будут выполняться без специального вызова. Например:
const sayHello = () => 'Hello';const container = new Container(sayHello);
console.log(container.run()); // 'Hello'
Конечно же, sayHello не является нечистой функцией. Но она может таковой оказаться!
Ну, а чтобы контейнер стал еще более полезным, неплохо будет выполнить дополнительные функции c результатом метода контейнера run. С этой целью добавим map в качестве метода класса Container:
Так в качестве параметра будет приниматься новая функция. Если результатом исходной функции (this.value()) станет промис (promise), то он свяжет эту новую функцию с помощью метода then. В противном случае, он просто выполнит функцию this.value().
Теперь мы можем связать функции с той функцией, которая используется для создания контейнера. В примере ниже мы добавляем новую функцию (addName) в последовательность и используем функцию tap для записи результата в консоль.
При выполнении greet.run() в консоли должно появиться сообщение Hello Joe Bloggs.
Весь код из этой части доступен в gist.
На самом деле, контейнеров намного больше. К примеру, популярный инструмент Redux является не чем иным, как контейнером для управления состоянием. Мы надеемся, что данный пример помог вам разобраться с сутью контейнеров и их пользой.
Заключение
Надеемся, что эта статься научила вас практическим способам реализации функционального программирования в JavaScript-коде. Большая часть ФП действительно проста: надо как можно чаще писать чистые функции.
Читайте также:
Читайте нас в телеграмме и vk
Перевод статьи Bret Cameron: Functional Programming in JavaScript: Introduction and Practical Examples