Основы функционального программирования на JavaScript

Плавно погружаемся в основы функционального программирования на JavaScript и пишем собственную идеальную реализацию функции forEach.

Основы функционального программирования на JavaScript

Мы контролируем сложность, создавая абстракции, которые скрывают детали, когда это необходимо.

S.I.C.P

Каждый JavaScript разработчик имеет фантастическую возможность ежедневно работать с мультипарадигмальным языком, которому доступна мощь функционального программирования. Знаете ли вы об этой силе? Используете ли ее?

Простая итерация списка

Возьмем массив строк:

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 никогда не была ориентирована на концепции функционального программирования (зря мы тут возились все это время!) Само ее определение подразумевает побочные эффекты.

Но, быть может, у вас получилась идеальная функция forEach?

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ