Одним из преимуществ подхода к JavaScript в функциональном ключе является возможность создавать сложную функциональность, используя небольшие и простые для понимания отдельные функции. Но иногда это заставляет смотреть на проблему задом наперед, чтобы придумать наиболее элегантное решение.

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

Вложение функций

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

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

Это можно легко продемонстрировать, используя технику программирования, с которой вы уже знакомы в JavaScript — передавая вызов функции в качестве аргумента в другую функцию:

unction addOne(x) {
  return x + 1;
}
function timesTwo(x) {
  return x * 2;
}
console.log(addOne(timesTwo(3))); //7
console.log(timesTwo(addOne(3))); //8

В этом примере мы задали функцию addOne(), прибавляющую единицу к переданному аргументу и функцию timesTwo(), умножающую аргумент на два. Передавая результат одной функции как аргумент в другую функцию, мы можем видеть, как вложение одной функции в другую может давать совершенно разные результаты даже с одним значением. Сначала выполняется внутренняя функция, а затем ее результат передается внешней.

Императивная композиция

Если вы хотите многократно выполнить последовательность операций, вам может быть удобнее задать новую функцию, автоматически применяющую сначала одну, а затем другую меньшую функцию. Это может выглядеть примерно так:

// ...previous function definitions from above
function addOneTimesTwo(x) {
  var holder = x;
  holder = addOne(holder);
  holder = timesTwo(holder);
  return holder;
}
console.log(addOneTimesTwo(3)); //8
console.log(addOneTimesTwo(4)); //10

В данном случае мы вручную соединили эти две функции в определенном порядке. Мы создали новую функцию, которая сначала присваивает переданное значение переменной holder, затем обновляет значение этой переменной, передав ее по очереди в обе функции и вернув полученный результат.

Обратите внимание, что для временного хранения значения используется переменная с именем holder. В такой крайне простой функции дополнительная локальная переменная кажется излишней, но даже в императивном JavaScript является хорошей практикой отношение к переданным в функцию аргументам, как к константам. Их можно изменять локально, но это приносит недоумение относительно значения аргумента, когда он вызывается на разных стадиях внутри функции.

Сходным образом, если мы хотим создать другую новую функцию, вызывающую две меньшие функции в противоположном порядке, мы сделаем так:

// ...previous function definitions from above
function timesTwoAddOne(x) {
  var holder = x;
  holder = timesTwo(holder);
  holder = addOne(holder);
  return holder;
}
console.log(timesTwoAddOne(3)); //7
console.log(timesTwoAddOne(4)); //9

Конечно, код начинает выглядеть довольно однообразно. Две наши новые композиционные функции выглядят практически одинаково, за исключением порядка вызова меньших функций. Нам не хватает DRY. Использование временных переменных, которые изменяют свои значения не очень функционально, даже если они скрыты внутри созданных нами композиционных функций.

В общем, мы можем сделать лучше.

Создание функциональной композиции

Давайте смастерим композиционную функцию, которая может принимать существующие функции и объединять их вместе в нужном нам порядке. Чтобы сделать это последовательным образом, не играясь каждый раз с внутренним содержимым, мы решили использовать тот порядок, в котором функции передаются в качестве аргументов.

У нас есть два варианта. Аргументы в обоих случаях будут функциями и они могут выполняться в любой последовательности. Надо определиться, так как предлагаемая нами новая функция compose(timesTwo, addOne) может работать как timesTwo(addOne()), считывая аргументы справа налево или как addOne(timesTwo()) при считывании аргументов слева направо.

Преимущество выполнения аргументов слева направо состоит в том, что они читаются также как и обычный текст на английском, по этой же причине мы назвали свою функцию timesTwoAddOne(), чтобы показать, что умножение в ней предшествует сложению. Мы все знаем важность логичного именования для получения ясного читаемого кода.

А недостаток этого же подхода в том, что значение для обработки должно прийти первым. Но получение значений сначала делает менее удобным композицию результирующей функции с другими функциями в будущем. Для лучшего понимания этой логики нет ничего лучше, чем видео Брайана Лонсдорфа Hey Underscore, You’re Doing it Wrong (хотя надо отметить, что сейчас в Underscore появилась опция fp, помогающая решать указанные Брайном проблемы при совмещении Underscore с библиотеками для функционального программирования, такими как lodash-fp и Ramda).

В любом случае то, что нам действительно надо сделать, это передать сначала все конфигурационные данные, а в конце данные, которыми мы будем оперировать. Поэтому более разумно сделать нашу композиционную функцию считывающей и применяющей аргументы справа налево.

Таким образом мы можем создать рудиментарную функцию compose, выглядящую примерно так:

function compose(f1, f2) {
  return function(value) {
    return f1(f2(value));
  };
}

Используя очень простую функцию compose мы можем сконструировать обе комплексные функции из нашего примера более простым способом и убедиться, что результат тот же:

function addOne(x) {
  return x + 1;
}
function timesTwo(x) {
  return x * 2;
}
function compose(f1, f2) {
  return function(value) {
    return f1(f2(value));
  };
}
var addOneTimesTwo = compose(timesTwo, addOne);
console.log(addOneTimesTwo(3)); //8
console.log(addOneTimesTwo(4)); //10
var timesTwoAddOne = compose(addOne, timesTwo);
console.log(timesTwoAddOne(3)); //7
console.log(timesTwoAddOne(4)); //9

Хотя эта простая функция compose работает, она не берет в расчет ряд вопросов, что ограничивает ее гибкость и применимость. Например, нам может понадобиться композиция из более, чем двух функций. Также мы в ходе этой функции теряем отслеживание this.

Мы можем решить эти проблемы, но они не обязательны для понимания принципа работы композиции. Вместо написания собственного варианта, возможно, более продуктивным будет унаследовать compose от одной из библиотек для функционального программирования типа Ramda, в которой по умолчанию реализован порядок аргументов справа налево.

Типы это ваша ответственность

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

Вы не ограничены передачей чисел и даже не ограничены сохранением одного типа переменной при передаче из одной функции в следующую. Но вы обязаны обеспечить, что функции в вашей композиции готовы работать с любым значением, возвращенным предыдущей функцией.

Учитывайте свою аудиторию

Всегда помните, что со временем кому-нибудь понадобиться использовать или модифицировать ваш код. Использование композиции внутри традиционного JavaScript может оказаться сложным для программистов, не знакомых с парадигмами функционального программирования. Цель это ясный код, который проще читать и поддерживать.

Но с появлением синтаксиса ES2015 создание простой композиционной функции в виде однострочного вызова может быть сделано без использования специального метода compose, а за счет стрелочных функций.

function addOne(x) {
  return x + 1;
}
function timesTwo(x) {
  return x * 2;
}
var addOneTimesTwo = x => timesTwo(addOne(x));
console.log(addOneTimesTwo(3)); //8
console.log(addOneTimesTwo(4)); //10

Заключение

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

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

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