Один из лучших аспектов 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). Этот метод берет функцию в качестве входного значения и позволяет нам рекурсивно вызывать эту функцию в несколько этапов. Затем мы углубили знания и дошли до каррирования с переменным значением аргументов, при котором можно передавать несколько аргументов в вызовы функции. Позже мы попробовали реализовать другой пример, в котором указывали только операцию над набором данных и вызывали ее бесконечное, а точнее, конечное, но непредсказуемое количество раз, с последующим включением в него переменного количества аргументов.
Специально для сайта ITWORLD.UZ. Новость взята с сайта NOP::Nuances of programming