Статьи

Композиция функций в JavaScript с Array.prototype.reduceRight

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

Что такое функция?

По сути, функция — это процедура, которая позволяет выполнить ряд обязательных шагов либо для выполнения побочных эффектов, либо для возврата значения. Например:

function getFullName(person) { return `${person.firstName} ${person.surname}`; } 

Когда эта функция вызывается с объектом, обладающим свойствами firstName и lastName , getFullName возвращает строку, содержащую два соответствующих значения:

 const character = { firstName: 'Homer', surname: 'Simpson', }; const fullName = getFullName(character); console.log(fullName); // => 'Homer Simpson' 

Стоит отметить, что начиная с ES2015, JavaScript теперь поддерживает синтаксис функции стрелки :

 const getFullName = (person) => { return `${person.firstName} ${person.surname}`; }; 

Учитывая, что наша функция getFullName имеет арность один (т.е. один аргумент) и один оператор return, мы можем упростить это выражение:

 const getFullName = person => `${person.firstName} ${person.surname}`; 

Эти три выражения, несмотря на различие в средствах, все достигают одной и той же цели:

  • создание функции с именем, доступным через свойство name , getFullName
  • принимая единственный параметр, person
  • возвращает вычисленную строку person.firstName и person.firstName , которые разделяются пробелом

Объединение функций через возвращаемые значения

Наряду с назначением возвращаемых значений функции объявлениям (например, const person = getPerson(); ) мы можем использовать их для заполнения параметров других функций или, вообще говоря, для предоставления значений везде, где их разрешает JavaScript. Скажем, у нас есть соответствующие функции, которые выполняют побочные эффекты logging и sessionStorage :

 const log = arg => { console.log(arg); return arg; }; const store = arg => { sessionStorage.setItem('state', JSON.stringify(arg)); return arg; }; const getPerson = id => id === 'homer' ? ({ firstName: 'Homer', surname: 'Simpson' }) : {}; 

Мы можем выполнить эти операции после возврата значения getPerson с помощью вложенных вызовов:

 const person = store(log(getPerson('homer'))); // person.firstName === 'Homer' && person.surname === 'Simpson'; => true 

Учитывая необходимость предоставления необходимых параметров функциям при их вызове, сначала будут вызываться самые внутренние функции. Таким образом, в приведенном выше примере возвращаемое значение getPerson будет передано в log , а возвращаемое значение log будет перенаправлено на store . Построение операторов из комбинированных вызовов функций позволяет нам в конечном итоге строить сложные алгоритмы из атомарных строительных блоков, но вложение этих вызовов может стать громоздким; если бы мы хотели объединить 10 функций, как бы это выглядело?

 const f = x => g(h(i(j(k(l(m(n(o(p(x)))))))))); 

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

Накопление массивов с помощью Array.prototype.reduce

Метод reduce прототипа Array берет экземпляр массива и накапливает его в одно значение. Если мы хотим суммировать массив чисел, можно использовать следующий подход:

 const sum = numbers => numbers.reduce((total, number) => total + number, 0); sum([2, 3, 5, 7, 9]); // => 26 

В этом фрагменте numbers.reduce принимает два аргумента: обратный вызов, который будет вызываться при каждой итерации, и начальное значение, которое передается total аргументу указанного обратного вызова; значение, возвращаемое из обратного вызова, будет передано total значению на следующей итерации. Чтобы разобраться в этом подробнее, изучив приведенный выше призыв к sum :

  • наш обратный вызов будет выполняться 5 раз
  • так как мы предоставляем начальное значение, total значение будет 0 при первом вызове
  • первый вызов вернет 0 + 2 , что приведет к total разрешению до 2 при втором вызове
  • результат, возвращаемый этим последующим вызовом, 2 + 3 , будет предоставлен total параметру при третьем вызове и т. д.

В то время как обратный вызов принимает два дополнительных аргумента, которые соответственно представляют текущий индекс и экземпляр массива, для которого был вызван Array.prototype.reduce , два ведущих являются наиболее важными и обычно называются:

  • accumulator — значение, возвращаемое из обратного вызова после предыдущей итерации. На первой итерации это приведет к исходному значению или первому элементу в массиве, если он не указан
  • currentValue — значение массива текущей итерации; поскольку он линейный, он будет переходить из array[0] в array[array.length - 1] течение всего вызова Array.prototype.reduce

Составление функций с Array.prototype.reduce

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

 const compose = (...funcs) => initialArg => funcs.reduce((acc, func) => func(acc), initialArg); 

Обратите внимание, что мы используем синтаксис остальных параметров ( ... ), чтобы привести любое количество аргументов в массив, освобождая потребителя от явного создания нового экземпляра массива для каждого сайта вызова. compose также возвращает другую функцию, отрисовывая compose функцию высшего порядка , которая принимает начальное значение ( initialArg ). Это очень важно, поскольку мы можем, следовательно, составлять новые, многократно используемые функции, не вызывая их до тех пор, пока это не потребуется; это известно как ленивая оценка .

Как мы можем поэтому объединить другие функции в одну функцию более высокого порядка?

 const compose = (...funcs) => initialArg => funcs.reduce((acc, func) => func(acc), initialArg); const log = arg => { console.log(arg); return arg; }; const store = key => arg => { sessionStorage.setItem(key, JSON.stringify(arg)); return arg; }; const getPerson = id => id === 'homer' ? ({ firstName: 'Homer', surname: 'Simpson' }) : {}; const getPersonWithSideEffects = compose( getPerson, log, store('person'), ); const person = getPersonWithSideEffects('homer'); 

В этом коде:

  • декларация person будет преобразована в { firstName: 'Homer', surname: 'Simpson' }
  • вышеуказанное представление о person будет выведено на консоль браузера
  • person будет сериализован как JSON перед записью в хранилище сеанса под ключом person

Важность порядка вызова

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

 const g = x => x + 2; const h = x => x / 2; const i = x => x ** 2; const fNested = x => g(h(i(x))); 

Может показаться естественным повторить это с нашей функцией compose :

 const fComposed = compose(g, h, i); 

В этом случае почему fNested(4) === fComposed(4) значение false ? Возможно, вы помните, как я выделил, как внутренние вызовы интерпретируются первыми, поэтому compose(g, h, i) фактически эквивалентен x => i(h(g(x))) , поэтому fNested возвращает 10 а fComposed возвращает 9 . Мы могли бы просто поменять порядок вызова вложенного или составного варианта f , но, учитывая, что compose предназначен для отражения специфики вложенных вызовов, нам нужен способ уменьшения функций в порядке справа налево; К счастью, JavaScript обеспечивает это с помощью Array.prototype.reduceRight :

 const compose = (...funcs) => initialArg => funcs.reduceRight((acc, func) => func(acc), initialArg); 

В этой реализации fNested(4) и fComposed(4) разрешаются до 10 . Однако наша функция getPersonWithSideEffects теперь неправильно определена; хотя мы можем изменить порядок внутренних функций, существуют случаи, когда чтение слева направо может облегчить умственный анализ процедурных шагов. Оказывается, наш предыдущий подход уже довольно распространен, но обычно его называют трубопроводом :

 const pipe = (...funcs) => initialArg => funcs.reduce((acc, func) => func(acc), initialArg); const getPersonWithSideEffects = pipe( getPerson, log, store('person'), ); 

Используя нашу pipe функцию, мы будем поддерживать порядок слева направо, требуемый getPersonWithSideEffects . Трубопровод стал основным продуктом RxJS по изложенным причинам; возможно, более интуитивно понятно думать о потоках данных в составных потоках, которыми операторы манипулируют в этом порядке.

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

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

 class Storable { constructor(key) { this.key = key; } store() { sessionStorage.setItem( this.key, JSON.stringify({ ...this, key: undefined }), ); } } class Loggable extends Storable { log() { console.log(this); } } class Person extends Loggable { constructor(firstName, lastName) { super('person'); this.firstName = firstName; this.lastName = lastName; } debug() { this.log(); this.store(); } } 

Непосредственная проблема этого кода, помимо его многословия, заключается в том, что мы злоупотребляем наследованием для повторного использования; если другой класс расширяет Loggable , он также является подклассом Storable , даже если нам не нужна эта логика. Потенциально более катастрофическая проблема заключается в именовании коллизий:

 class State extends Storable { store() { return fetch('/api/store', { method: 'POST', }); } } class MyState extends State {} 

Если бы нам нужно было создать экземпляр MyState и вызвать его метод store , мы бы не стали Storable метод store Storable если бы мы не добавили вызов super.store() в MyState.prototype.store , но тогда это создаст жесткий, хрупкий метод. связь между State и Storable . Это можно смягчить с помощью систем сущностей или шаблона стратегии, как я уже упоминал в другом месте , но, несмотря на силу наследования, выражающую более широкую таксономию системы, композиция функций обеспечивает плоское, лаконичное средство совместного использования кода, которое не зависит от имен методов.

Резюме

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