Бесконечное каррирование в JavaScript | Nuances of programming

Curry

Один из лучших аспектов 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