Статьи

Композиция функций: строительные блоки для поддерживаемого кода

Эта статья была рецензирована Джеффом Моттом , Дэном Принсом и Себастьяном Зейтцем . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!

Изображение проводника перед светящимися экранами кода

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

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

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

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

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

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

function 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 для временного хранения значения, которое передаем. С такой простой функцией дополнительная локальная переменная может показаться избыточной, но даже в императивном 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 

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

Итог: мы можем сделать лучше.

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

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

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

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

Недостаток выполнения аргументов слева направо состоит в том, что значения, с которыми нужно работать, должны быть на первом месте. Но размещение значений во-первых делает менее удобным составление результирующей функции с другими функциями в будущем. Для хорошего объяснения мышления, лежащего в основе этой логики, вы не можете победить классическое видео Брайана Лонсдорфа « Эй, Подчеркни, ты делаешь это неправильно» . (Хотя следует отметить, что теперь для Underscore существует опция fp, которая помогает решить проблему функционального программирования, которую Брайан обсуждает при использовании Underscore совместно с библиотекой функционального программирования, такой как lodash-fp или Ramda .)

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

Таким образом, мы можем создать элементарную функцию 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 

Начните сочинять сегодня

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

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

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