Один из лучших аспектов JavaScript — это его принадлежность к функциональным языкам программирования, что, в свою очередь, открывает двери к ряду классных шаблонов программирования. Среди которых есть и каррирование. Как бы круто это не звучало и не выглядело, понять все сразу — задача не из легких. В данной статье я постараюсь объяснить простыми словами, что такое каррирование и как им пользоваться для решения собственных задач.
Каррирование
Каррирование — это декомпозиция (разложение) функции из множества аргументов на несколько функций с одним аргументом.
Данное определение правдиво в отношении простого каррирования. Далее в статье мы еще встретим более сложные шаблоны каррирования.
Давайте объясню это подробнее на простом примере. Допустим, у вас есть функция multiply
:
const multiply = ( a, b ) => a * b;
Вызывать функцию вы будете примерно так:
multiply(2, 3)
Но с каррированием multiply
разобьется на несколько функций в зависимости от арности (количества аргументов). Арность этой функции — 2
. Выглядит это так:
const mul = curry(multiply);
mul(2)(3);
Если вдруг название шаблона покажется вам несколько странным, то каррирование названо в честь Хаскелла Карри, который работал над математическими основами функционального программирования.
Цель
После прочтения статьи вы сможете:
- понять, что такое каррирование;
- создать вспомогательную функцию для преобразования функций к каррированому виду (см. выше про
mul(2)(3)(4
); - разобраться в каррировании с переменным количество аргументов. Например:
mul(2,3)(4)
илиmul(2)(3,4)
; - создать функцию с бесконечным каррированием, т.е. провести каррирование функции с неизвестной арностью. Пример:
mul(2)(3)(4)....(8)()
; - внедрить шаблон каррирования с переменным количеством аргументов в функцию бесконечного каррирования. Пример:
mul(2)(3,4,5)(6)(7,8)()
Реализация каррирования
Давайте узнаем, как создавать особую функцию curry
для функции multiply
, принимающей три аргумента.
const mul = (x) => {
return (y) => {
return (z) => {
return x * y * z;
};
};
};
Вот три функции, которые одновременно возвращаются из родительской функции. Первые две с аргументами x
и y
ведут себя как функции Accumulator и сохраняют значения в цепочке областей видимости внутренних функций или, как мы из называем в JavaScript, — замыканий.
Вот понятное определение замыканий:
Замыкание — это внутренняя функция, имеющая доступ к переменным внешней (вложенной) функции, т.е. к цепочке областей видимости. В замыкании имеется три цепочки областей видимости. Замыкание получает доступ к собственной области видимости (переменные в фигурных скобках), переменным внешней функции и глобальным переменным.
Это означает, что вторая функция с аргументомy
получает доступ к переменной x
из своей родительской функции. По аналогии третья функция с аргументом z
имеет доступ сразу к двум переменным x
и y
, поскольку они связаны областью видимости этой функции, а тело функции третьей внутренней функции может просматривать или проходить иерархическую цепочку областей видимости в обратном порядке всякий раз при обнаружении переменной, не определенной в теле текущей функции. В данном случае — это x
и y
.
Создание вспомогательной функции curry
Рассмотрим следующую функцию с тремя аргументами.
const mul = ( a, b, c ) => a * b * c;
Наша цель — создать функцию-обертку, способную принять эту функцию mul
как параметр и вернуть каррированную версию функции, которую можно будет вызвать через _mul(1)(2)(3)
. Помните, что мы не будем изменять начальную функцию mul
, а вернем новую функцию как _mul
.
_mul = curry(mul);
Идем дальше. Теперь попробуем генерализировать выполненные шаги. Нам нужно задать количество аргументов функции, т.е. ее арность, и создать рекурсивный набор функций, повторяющийся такое же количество раз.
Количество аргументов функции (арность) в JavaScript можно задать через свойство length
. Например:
N = mul.length; //3
Раз нам под силу динамически определять арность функции, то можно создать логику для рекурсивного возврата внутренних функций N
раз.
const _sum3 = (x, y, z) => x + y + z;
const _sum4 = (p, q, r, s) => p + q + r + s;
function curry(fn) {
const N = fn.length;
function innerFn(n, args) {
return function actualInnerFn(a) {
if(n <= 1) {
return fn(...args, a);
}
return innerFn(n - 1, [...args, a]);
}
}
return innerFn(N, [])
}
const sum3 = curry(_sum3);
const sum4 = curry(_sum4);
console.log(sum3(1)(3)(2)); // 6
console.log(sum4(1)(3)(2)(4)); // 10
Давайте углубимся в детали, поскольку это — ключевой момент в реализации шаблона каррирования.
Для начала создадим две функции, которые мы хотим каррировать: _sum3
с 3 аргументами и _sum4
с 4 аргументами.
Затем в нашей функции curry возьмем функцию fn
в качестве входного значения для каррирования. Находим ее арность через геттер fn.length
и сохраняем ее в N
.
Теперь главное. Мы хотим вернуть внутреннюю функцию N
раз. То есть количество вызовов каррированного метода sum равно количеству принимаемых аргументов _sum(1,2,3,...,N) => sum(1)(2)(3)...(N)
. Создаем функцию innerFn
, которая отслеживает переменную N
и в зависимости от ее значения возвращает другую функцию, либо завершает процесс, вызывая текущую функцию со всеми накопленными аргументами. Поэтому ее и называют функцией Accumulator. Она накапливает все аргументы, обособленно передаваемые каррированной функции, и заключает их в накопительный массив [...args, a]
, а затем вызывает начальную функцию.
Давайте разберемся с этим на примере. Начнем с sum3
, которую мы выполняем N = 3
и возвращаем «результат» innerFn
. Она является внутренней функцией. В строке 8 это actualInnerFn
. Перед нами ничто иное, как другая функция, принимающая один аргумент. Внутри тела функции выполняется проверка и определяется, следует ли повторить итерацию заново или нужно остановить процесс. Это настоящая функция, которая последовательно вызывается пользователем как sum(1)(2)(3)
.
При выполнении для n = 3
мы проверяем, не вызывается ли в данный момент последний аргумент. Если нет, то потребуется две итерации для возвращения actualInnerFn
(3–1 = 2) раз.
Тоже повторяется и для n = 2
, которая опять возвращает actualInnerFn
с одним аргументом. Он, в свою очередь, вызывает функцию-аккумулятор innerFn
через n = 1
. В этот раз снова возвращается actualInnerFn
, но рекурсия прекращается.
Это базовый сценарий, при котором n = 1
указывает на то, что такая функция вызывается в последний раз. Получается, что внутри тела функции мы прописали сценарий завершения, задав условие и выполнив начальную функцию с накопленными аргументами в переменной args
. Последний аргумент a
передается в виде fn(...args, a)
.
На этом все. Надеюсь, после моего объяснения стало немного понятнее.
Каррирование с переменным количеством аргументов
Пойдем немного дальше и попробуем вызвать что-то вроде sum(2,3)(4)
или sum(2)(3,4)
, но при этом получить одинаковые результаты. Это называется каррированием с переменным количеством аргументов, поскольку для рекурсивного вызова функции мы можем использовать varargs или переменное количество аргументов.
То есть мы разрешаем actualInnerFn
иметь переменное количество аргументов, а не только один.
return function actualInnerFn(...a) {
if(n <= a.length) {
return fn(...args, ...a);
}
return innerFn(n - a.length, [...args, ...a]);
}
}
А чтобы actualInnerFn
смог принять переменное количество аргументов, потребуется изменить логику завершения. Например, если функция вызывается как sum(1)(2,3)
, то нужно учитывать длину (length
) аргументов 2
и 3
, поскольку вызовы (2)
и (3)
идут вместе. Таким образом, начальная функция будет вызываться до достижения количества аргументов N
.
Вот так выглядит конечная реализация:
const curry = fn => {
const innerFn = (N, args) => {
return (...x) => {
if (N <= x.length) {
return fn(...args, ...x);
}
return innerFn(N - x.length, [...args, ...x]);
};
};
return innerFn(fn.length, []);
};
const sum3 = curry(_sum3);
sum3(2, 3)(4) //9
sum3(2)(3, 4) //9
Круто, мы смогли реализовать переменное количество аргументов. Но сможем ли мы пойти еще дальше?
Бесконечное каррирование
Проблема, которую я вижу в примере выше, заключается в том, что при изменении арности приходится каждый раз определять эту функцию. Для 3 аргументов я задаю функцию sum3
, для 4 аргументов — функцию sum4
.
Если ли какой-то способ передачи общего смысла или операций с функцией (суммирование, умножение, конкатенация двух аргументов) в виде (x, y) => x + y
с его последующим применением к N
количеству аргументов? Тут и возникает небольшая проблема — для обработки сценария завершения нам нужно знать N
. В противном случае код будет рекурсивно возвращать innerFn
, что может привести к переполнению стека.
sum(2)(3)(4)......
Следовательно, нам нужно как-то указать на то, что мы перебрали все количество аргументов и хотели бы получить результат передаваемой операции. Как насчет вызова функции с пустыми аргументами?
sum(2)(3)(4)(5)() // 14
Вариантов этого вызова довольно много. От нас требуется лишь как-то просигналить actualInnerFn
о том, что мы бы хотели остановиться и получить результат.
Например, передачей ограничителя в виде sum(2)(3)(4)('STOP')
. А внутри actualInnerFn
можно добавить проверку в виде if(a === 'STOP')
.
const infiniteCurry = fn => {
const next = (...args) => {
return x => {
if (!x) {
return args.reduce((acc, a) => {
return fn.call(fn, acc, a)
}, 0);
}
return next(...args, x);
};
};
return next();
};
const iSum = infiniteCurry((x, y) => x + y);
console.log(iSum(1)(3)(4)(2)());
Тут есть ряд особенностей, которые мне бы хотелось обозначить.
- Нам не нужно передавать длину аргументов как
N
, поскольку ее у нас нет. - Пользователь может вызывать каррированную функцию произвольное количество раз. Поэтому у нас нет никаких подсчетов
N
. - Нам нужно накапливать аргументы также, как и ранее в переменной
args
. - Основной сценарий завершения внутри
actualInnerFn
проверяет, нужно ли передавать туда какие-либо аргументы. Если нет, то происходит обработка накопленных аргументов. - Поскольку у нас нет готовой функции для неизвестной арности (скажем, М), то мы не можем передавать аргументы сразу в функцию, как это делалось с функциями
sum3
илиsum4
. - Все, что у нас есть, — это операция определения функции для каррирования в виде
(x, y) => x + y
. - Здесь нам очень пригодится метод
reduce
в JavaScript массиве. Нам нужно будет перебрать все накопленные значения и пошагово вызвать операции с этими значениями, сохранив их в переменнойaccumulator
.
args.reduce((accumulator, a) => {
return fn(accumulator, a)
}, 0);
- Начинаем с передачи начального значения. При суммировании начальным значением можно передавать
0
, ведь добавление его к любому значению даст результат этого самого значения. При умножении можно передавать1
. Для конкатенации подойдет пустая строка''
.
Бесконечное каррирование с переменным количеством аргументов
Давайте попробуем перенести varargs в реализацию бесконечного каррирования.
const infiniteCurry = (fn, seed) => {
const reduceValue = (args, seedValue) =>
args.reduce((acc, a) => {
return fn.call(fn, acc, a);
}, seedValue);
const next = (...args) => {
return (...x) => {
if (!x.length) {
return reduceValue(args, seed);
}
return next(...args, reduceValue(x, seed));
};
};
return next();
};
const iSum = infiniteCurry((x, y) => x + y, 0);
const iMul = infiniteCurry((x, y) => x * y, 1);
console.log(iSum(1)(3, 4)(5, 6)(7, 8, 9)()); // 43
console.log(iMul(1)(3, 4)(5, 6)()); // 360
Я говорю об обработке части данных или аргументов на ходу. Например, вычисление (5, 6)
перед передачей их следующей итерации. Вы можете спокойно объединять все аргументы и вычислять их в reduce
.
Для генерализации кода я представляю метод curry
и параметр seedValue
в методе reduce
.
Вот и все, друзья.
Заключение
Мы познакомились с классным шаблоном функционального проектирования и научились реализовывать его путем создания обертки/вспомогательного метода, вызываемого как curry(fn)
. Этот метод берет функцию в качестве входного значения и позволяет нам рекурсивно вызывать эту функцию в несколько этапов. Затем мы углубили знания и дошли до каррирования с переменным значением аргументов, при котором можно передавать несколько аргументов в вызовы функции. Позже мы попробовали реализовать другой пример, в котором указывали только операцию над набором данных и вызывали ее бесконечное, а точнее, конечное, но непредсказуемое количество раз, с последующим включением в него переменного количества аргументов.
Перевод статьи Param Singh: Perpetual Currying in JavaScript