Основы функционального программирования на JavaScript
Плавно погружаемся в основы функционального программирования на JavaScript и пишем собственную идеальную реализацию функции forEach.
Мы контролируем сложность, создавая абстракции, которые скрывают детали, когда это необходимо.
Каждый JavaScript разработчик имеет фантастическую возможность ежедневно работать с мультипарадигмальным языком, которому доступна мощь функционального программирования. Знаете ли вы об этой силе? Используете ли ее?
- Функциональное программирование и его применение в JavaScript
- 70 ресурсов по функциональному программированию
Простая итерация списка
Возьмем массив строк:
const langs = ['lisp', 'haskell', 'ocaml'];
Перед нами сложная задача – перебрать все элементы этой коллекции и вывести каждый из них в консоль.
Конечно, вы легко с этим справитесь. Подобную конструкцию молодые программисты осваивают в первые же дни обучения:
// старый добрый императивный путь for (let i = 0; i < langs.length; i++) { console.log(langs[i]); }
Выглядит понятно, не правда ли? А будет ли понятно вашим друзьям не-программистам?
Это императивный подход: он описывает последовательность действий, которые должен совершить компьютер. Нормальные люди не представляют таких конструкций, думая об итерации.
Конечно, это не так императивно, как if + goto
, но все же.
У этого фрагмента есть несколько важных недостатков:
- Он одноразовый, для каждого подобного случая его придется переписать, то есть нарушается принцип DRY.
- Этот блок процедурного кода нельзя разложить на компоненты, а затем снова собрать.
- Такую программу нелегко поддерживать, она очень подвержена случайным ошибкам и опечаткам. Например, что произойдет, если во время цикла какой-то внешний фактор изменит значение счетчика
i
? - Плохая масштабируемость. Вложенные друг в друга циклы
for
– это настоящий кошмар!
Синтаксический сахар
Дружественный к разработчикам стандарт ES6 сделал программирование на JavaScript немного проще и добавил удобный синтаксический сахар для цикла for
. Он позволяет обойтись без дополнительной переменной для счетчика:
// полудекларативный синтаксический сахар for (const lang of langs) { console.log(lang); }
Это уже немного более декларативно. Если не-программист прочитает этот фрагмент слева направо, вероятно, у него появятся подозрения о том, что он делает.
Этот код легче поддерживать и масштабировать… но мы выиграли всего лишь пару уровней сложности.
Старый добрый forEach
За пять лет до ES6 в JavaScript появился очень полезный инструмент – forEach
.
Это не встроенная функция и не зарезервированное слово. forEach
– это метод Array.prototype
, доступный любому массиву (даже пустому).
langs.forEach();
Тут будет ошибка, так как методу необходим один-единственный аргумент. Но не простой, а специальный. Аргументом forEach
должна быть функция.
Примечание: фактически метод может принимать второй параметр – значение контекста выполнения – this
.
Вы, конечно, помните, что в JavaScript функции являются "гражданами первого класса". Несмотря на то, что звучит эта фраза очень особенно, означает она ровно противоположное. Функции ничем не отличаются от других типов данных (объектов и примитивных значений). Вы можете записать функцию в переменную, передать в качестве аргумента или получить на выходе другой функции. (В Haskell даже операторы вроде +
могут быть переданы как аргументы).
Функции, подобные forEach
, называются функциями высшего порядка (higher-order functions). Это функции, которые работают с другими функциями как с входными или выходными параметрами.
// 3 алерта с текстом "hello" // по одному для каждого элемента массива langs.forEach(() => alert('hello'));
Внутренняя функция
Итак, метод forEach
принимает объявленную (но не вызванную!) функцию, которую будет последовательно вызывать для каждого элемента коллекции. Сигнатура этой функции следующая:
(any) -> void
Параметр any
– это перебираемый в данный момент элемент массива. void
на выходе означает, что функция не должна ничего возвращать.
Примечание: на самом деле внутренняя функция может принимать три аргумента (any, number, any[]) -> void
. Первый – это непосредственно текущий элемент массива, второй – его индекс, а третий – сам массив целиком. Эти дополнительные значения могут быть полезны, например, для определения последнего элемента.
langs.forEach(lang => alert(`hi ${lang}`));
Результатом работы этого фрагмента будут три алерта – один за другим для каждого члена коллекции – с текстом hi <language>
. Теперь мы знаем достаточно, чтобы решить исходную задачу:
// декларативный метод с функцией высшего порядка langs.forEach(lang => console.log(lang));
Но подождите.
Каждый JavaScript developer знает функцию console.log
! Разве она не соответствует указанной сигнатуре (any) -> void
? Вполне соответствует.
Примечание: на самом деле метод log
объекта console
может принимать неограниченное количество аргументов, разделенных запятыми, а также возвращает false
, но нам это не помешает: (any, ...any[]) -> false
.
Есть ли разница между этими двумя фрагментами:
// 1 const log = a => console.log(a); log("hi"); // 2 console.log("hi");
Нет никакой разницы: функция-обертка – это просто лишний код, который можно убрать.
// облегченный код langs.forEach(console.log);
Примечание: конечно же, разница есть. Если вы внимательно читали предыдущие примечания, то уже поняли, что последний пример выведет в консоль не только элементы массива, но и дополнительные данные.
Хм, это выглядит странно, но в некотором смысле даже проще, чем было. Что это – пример настоящего функционального программирования?
О нет! Это лишь первый шаг к нему. Реальный мир FP очень строг. Он требует от программы полной предсказуемости и контроля за побочными эффектами. Если вы хотите достичь этого идеала, придется следовать некоторым правилам.
Чистые функции и иммутабельность
Вы еще не забыли сигнатуру, которой должна соответствовать функция-аргумент forEach
? Это функция, которая ничего не возвращает (или возвращает undefined
, как будет правильнее в JavaScript), или возвращает void
(как будет правильнее в мире типизированных языков).
Сам метод forEach
также возвращает undefined
.
Это явный симптом функций с побочными эффектами. Если она ничего не возвращает, значит, делает что-то, влияющее на программу. Возможно, изменяет сам массив или какую-нибудь глобальную переменную.
Делает то, про что мы однажды забудем. И что приведет к ошибкам. Функциональные программисты – параноики. Они любят держать все под контролем и всегда хотят знать, что и почему они делают, а побочные эффекты их ужасно раздражают.
forEach
легче разделить на компоненты, чем цикл for … of
, поскольку он принимает пользовательскую функцию. Однако этот метод все еще крепко привязан к массиву. Другими словами, мы не можем передать в функцию сам forEach
. Так что композиционность и масштабируемость все еще не на высоте.
Итак, если мы действительно хотим войти в мир функционального программирования, необходимо создать собственный forEach
.
Идеальный forEach в духе функционального программирования
Начнем с простого сокрытия деталей реализации:
// кастомный префункциональный вариант for each const forEach = (arr, func) => { for (let i = 0; i < arr.length; i++) { func(arr[i]); } } forEach(langs, console.log);
Но ведь это чистой воды императив!! Что ж, никуда от этого не денешься. Функции просто скрывают императивные детали реализации, чтобы дать нам мощные декларативные инструменты.
Как только мы напишем собственный forEach
, мы больше никогда не вернемся к его императивным внутренностям. А снаружи это будет замечательный, совершенный, декларативный forEach
, который обязательно всем понравится.
Впрочем, есть способ реализации и без использования for
– рекурсия. Но давайте лучше оставим ее на другой раз, без TCO это может привести к новым проблемам.
Примечание: TCO (Tail Call Optimization) – оптимизация хвостового вызова – необходимое условие для использования рекурсии вместо цикла for
. Размер стека вызовов ограничен, а при рекурсии он может расти очень быстро, пока не исчерпает все ресурсы. В JavaScript TCO нет (кроме Node 6), так что пока нам придется иметь дело с циклами.
Выглядело бы это примерно так:
// рекурсивный forEach const forEach = (arr, func) => { if (arr.length) { const [heade, ...tail] = arr; func(head); forEach(tail, func); } }; forEach(langs, console.log);
Давайте доведем наш forEach
до идеала, превратив его в чистую функцию. Помните, чем отличается чистая функция?
- она возвращает какое-то значение
- и ничего не изменяет в окружающем ее коде, включая даже аргументы, переданные по ссылкам.
Примечание: в JavaScript все объекты (включая массивы) передаются по ссылкам, а не копируются. Если вы в этом еще не разобрались, обязательно выделите время, чтобы понять эту концепцию.
// кастомный функциональный вариант forEach const forEach = (arr, func) => { const newArr = [...arr]; // копия массива for (let i = 0; i < newArr.length; i++) { func(newArr[i], i, newArr); } return newArr; // возвращает копию }; forEach(langs, console.log);
Великолепный, во всех отношениях приятный, чистый forEach
! Однако и он не решает полностью проблему поддерживаемости и масштабируемости :(
Иммутабельность
Неизменчивость, или иммутабельность, одно из важнейших требований чистых функций наряду с контролем, TDD, многоразовостью и т. д. Оно может показаться неоправданным, но задумайтесь: если ваша программа состоит только из чистых функций, зачем вам может понадобиться изменчивость? Она вам не нужна.Чистая функция (она же ссылочная прозрачность) – это та же самая иммутабельность, только с другой стороны.
Каррирование
Давайте воспользуемся еще одним полезным инструментом функционального программирования: каррированием.
Мы знаем, что одна функция может возвращать другую, образуя при этом замыкание, в котором сохраняются переданные первой функции аргументы.
const f = a => b => a; const otherF = f('Hello'); console.log(otherF('Goodbye')); // "Hello" // обязательно разберитесь, как это работает
Используя это знание напишем, наконец, идеальную функцию forEach
:
// чистая функция forEach с использование каррирования const forEach = func => arr => { const newArr = [...arr]; for (let i = 0; i < newArr.length; i++) { func(newArr[i], i, newArr); } return newArr; }; const logEach = forEach(console.log); logEach(langs); logEach(['see', 'you', 'soon']); const doubleAndLogEach = forEach(a => console.log(a * 2)); doubleAndLogEach([1, 2, 3]);
Обратите внимание на обновленный порядок аргументов:
- сначала идет
func
, который будет сохранен в замыкании; - затем – собственно массив
arr
.
Благодаря этой технике мы можем создать отдельные функции logEach
или doubleAndLogEach
, которые можно повторно использовать при необходимости. forEach
теперь даже можно объединять с другими функциями в цепочки вызовов (пайпы).
Из-за того, что все объекты передаются по ссылке, наш идеальный forEach
все еще не идеален. Если массив содержит объекты, они по-прежнему подвержены мутациям. Об этом нельзя забывать.
Кроме того, язык JavaScript не поддерживает ни статическую типизацию, ни сильную систему типов, поэтому мы спокойно может передавать в forEach
нечистые функции или даже неправильные типы данных. Решить эту проблему можно только радикально, перейдя, например, на TypeScript или Elm.
В заключение следует сказать одну ужасную вещь. Сама концепция forEach
никогда не была ориентирована на концепции функционального программирования (зря мы тут возились все это время!) Само ее определение подразумевает побочные эффекты.