Сочинение, трубка и карри с нуля

Есть множество статей, объясняющих, что такое карри и композиция. Есть даже написанная мной.

Но, хотя я считаю, что мое описание было довольно исчерпывающим, я думаю, что еще есть место для объяснений. При взгляде на карри и композицию, а также на фактическую реализацию они кажутся довольно краткими, и может быть трудно понять, что на самом деле происходит. Цель этой статьи - развить некоторую интуицию в этом направлении, создав функции compose, pipe , и curry с нуля. Начиная с самых базовых версий и заканчивая наиболее краткими / сложными, но также и наиболее гибкими версиями.

Карри

Процесс, при котором функция принимает свои аргументы по одному.

Давайте посмотрим код.

// Uncurried
const add = (x, y) => x + y;
[1,2,3].map(x => add(x, 4)); // [5, 6, 7]
// Curried
const add = x => y => x + y;
[1,2,3].map(add(4)); // [5, 6, 7]

Как видите, у этого есть некоторые преимущества, поскольку он намного менее подробен. Однако написание функций таким способом утомляет и не особо гибко (довольно крайний пример);

// Curried
const add5args = a => b => c => d => e => a + b + c + d + e;
add5args(1)(2)(3)(4)(5); // 15
// Possible uncurried variant
const add5args = (a,b,c,d,e) => a + b + c + d + e;
add5args(1, 2, 3, 4, 5); // 15

Существует функция, которая преобразует наш второй вариант в вариант, который может принимать их по одному, поэтому мы можем использовать ее, например, в Array.map.

Функция Compose и Pipe (уровень 0)

Процесс объединения нескольких функций в цепочку для создания новой функции.

Давайте посмотрим, как это работает.

const add5 = x => x + 5;
const multiply6 = x => x * 6;
const multiply6Add5 = compose(add, multiply);
multiply6Add5(2); // 17 😎

Итак, из add5 и multiply6 мы создаем новую функцию, которая является составом обоих. Это дает нам новую функцию, которая одновременно умножает на 6 и добавляет 5. Но почему это наоборот?

Концепция функциональной композиции пришла из математики. Рядом с такими вещами, как сложение, умножение и деление, есть оператор composition. Это точка с дыркой.

(f ° g)(x) = f(g(x));

Это читается так: f после g из x - это то же самое, что взять x, применить его к g, а затем применить к f. Здесь мы видим, что композиция происходит в обратном порядке. Вторая функция (g) применяется перед первой. Это причина того, что композиция функций также является обратной в программировании. В коде мы ввели концепцию pipe функции, которая переворачивает приложение, делая его таким, каким мы интуитивно думали.

Итак, давайте напишем наши собственные функции compose и pipe.

// Uncurried
const composeU = (f, g, x) => f(g(x));
const pipeU = (f, g, x) => g(f(x));
// Curried
const compose = f => g => x => f(g(x));
const pipe = f => g => x => g(f(x));
// Or reverse arguments for the pipe function
const pipe = f => g => x => compose(g)(f)(x);

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

const add5 = x => x + 5;
const multiply6 = x => x * 6;
// Uncurried
composeU(add, multiply, 2); // 17
pipeU(add, multiply, 2); // 42
// Curried
const multiply6Add5 = compose(add)(multiply);
const add5Multiply6 = pipe(add)(multiply);
multiply6Add5(2); // 17
add5Multiply6(2); // 42
[1,2,3].map(multiply6Add5) // [11, 17, 23]

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

Функция Карри (уровень 0)

Мы сразу перейдем к нашей базовой функции карри.

const curry = fn => x => y => fn(x, y);
const addU = (x, y) => x + y;
const add = curry(addU);
addU(4, 2); // 6
add(4)(2); // 6
const add4 = add(4);
add4(10) // 14

Наша функция карри просто берет другую функцию, затем принимает некоторые аргументы и применяет их к аргументам. Итак, теперь нам нужно только определить нашу функцию addU, а затем мы можем определить нашу каррированную версию в терминах другой. Хороший!

Но ждать…

Что, если мы хотим составить более двух функций? Или каррировать функцию с тремя аргументами?

Функция Compose (уровень 1)

Добавим еще несколько версий.

const compose = f => g => x => f(g(x));
const compose3 = f => g => h => x => f(g(h(x)));
const compose4 = f => g => h => i => x => f(g(h(i(x))));
// …

Хотя это работает, это не идеально. Воспользуемся оператором отдыха.

const composeU = (...fns) => x => {
 let val = x;
   for (const fn of fns.reverse()) {
     val = fn(val)
   }
 return val;
};
const add3 = x => x + 3;
const add4 = x => x + 4;
const add5 = x => x + 5;
const add12 = compose(add3, add4, add5);
add12(2); // 14 😎

Так что это здорово! Мы берем наши функции, просматриваем их одну за другой в обратном порядке и применяем функцию к значению, которое хотим использовать. Но наша функция совершенно обязательна, и мы полностью меняем массив перед выполнением каких-либо операций ... Возможно, мы сможем что-то с этим поделать. Концептуально мы берем объект, похожий на массив, просматриваем его один за другим, сводя его к одному значению. Хм… reduce

const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);

Так-то лучше. Мы уменьшаем массив справа налево, применяя каждую функцию к аккумулятору, начиная с нашего входного значения. Но теперь возникла проблема ...

const composeU = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const compose = curry(composeU);
const add3 = x => x + 3;
const add4 = x => x + 4;
const add5 = x => x + 5;
const add12 = compose(add3, add4, add5);
add12(2); // [Function] 😥

Похоже, наша функция карри не работает, и мы не можем использовать ее с compose…

Функция Карри (уровень 1)

Добавим еще несколько версий.

const curry = fn => x => y => fn(x, y);
const curry3 = fn => x => y => z => fn(x, y, z);
const curry4 = fn => x => y => => z => a => fn(x, y, z, a);
// …

Хотя это тоже работает, возможно, мы сможем что-то сделать и с остальным параметром…

const curry = fn => {
 return function curried (...args) {
   if(args.length >= fn.length) {
     return fn.apply(null, args);
   }
   return curried.bind(null, ...args);
 };
};

Итак, что здесь происходит. Наша функция карри принимает функцию. Проверить… Но тогда… Концептуально, даже если это не реализовано как таковое, думайте об этом как о буфере. Наши …args проверяются на основе их length (количества, которое мы поставили), если мы достигли нужного нам количества (которое мы можем проверить, проверив количество параметров - или арность - fn), то мы применяем все параметры изначально предоставленной функции (fn) и вернуть их. Если нет, верните внутреннюю функцию, но привяжите к ней аргументы, которые мы уже получили (готовы получить больше).

Другими словами, рекурсивно возвращайте curried, пока мы не встретим арность изначально предоставленной функции.

Вот хороший и лаконичный эквивалент es6.

const curry = fn => {
 const curried = (...args) =>
   args.length >= fn.length
     ? fn.apply(null, args)
     : curried.bind(null, ...args);
 return curried;
};

Давайте посмотрим, как это работает с нашей ранее add5args функцией.

const add5argsU = (a, b, c, d, e) => a + b + c + d + e;
const add5args = curry(add5argsU);
const a = add5args(1); // [Function] — 4 arguments left
const b = a(2); // [Function] — 3 arguments left
const c = b(3); // [Function] — 2 arguments left
const d = c(4); // [Function] — 1 arguments left
a(2,3,4,5) // 15
b(3,4,5) // 15
c(4,5) // 15
d(5); // 15

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

// From earlier
const composeU = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const compose = curry(composeU);
const add1 = x => x + 1;
const add2 = x => x + 2;
const add3 = x => x + 3;
const add4 = x => x + 4;
const add5 = compose(add2, add3);
const add7 = compose(add3, add4);
const add11 = compose(add4, add4, add3);
add5(4); // 9
add7(4); // 11
add11(4); // 15
const add12 = compose(add5, add7); // Already composed functions 😎
add12(4); // 16

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

Функция трубы (уровень 1)

Мы так взволновались, что совершенно забыли о нашей pipe функции. Это достаточно легко определить.

const composeU = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const pipeU = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);
const compose = curry(composeU);
const pipe = curry(pipeU);

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

const composeU = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const pipeU = (...fns) => x => composeU(...fns.reverse())(x)
const compose = curry(composeU);
const pipe = curry(pipeU);

Намного лучше. Мы также приближаемся к тому, что pipeU имеет так мало синтаксиса, что мы можем начать пропускать некоторые тесты для него…
Но не сейчас. И, думаю, можно пойти дальше.

Бонусный уровень

Есть еще одна неприятная вещь. У нас есть эта прекрасная функция карри, и хотя мы используем ее для превращения нашей composeU в каррированную функцию, она уже вроде как карри, так как принимает аргумент «данные» отдельно. Было бы очень хорошо, если бы мы могли сделать это напрямую или взять все аргументы сразу, чтобы у нас, по крайней мере, было ощущение, что наша функция curry приносит пользу. Возможно, мы можем попробовать:

const composeU = (...fns, x) => fns.reduceRight((acc, fn) => fn(acc), x);

Но мы не можем ...

SyntaxError: параметр Rest должен быть последним формальным параметром. Это облом.

Тем не мение. Это еще не все ... Если мы прочитаем документацию по функции сокращения массива, то увидим следующее:

initialValue Необязательно
Значение, которое будет использоваться в качестве первого аргумента при первом вызове обратного вызова. Если initialValue не указан, первый элемент в массиве будет использоваться как начальное значение аккумулятора и пропускаться как currentValue. Вызов метода reduce () для пустого массива без initialValue вызовет ошибку TypeError.

Ага. Так что это необязательно. Если мы не указываем аргумент, он автоматически берет первый элемент из массива. На первый взгляд, это нам не помогает, потому что мы предоставляем аргумент, который хотим использовать, последний, поэтому он будет последним в массиве. Но при более внимательном рассмотрении, поскольку мы сокращаемся справа, это будет фактически первый элемент, когда мы доберемся туда!

Таким образом, мы не можем поставить x на вторую позицию. Но мы можем просто полностью его опустить. Даже лучше!

const compose = (...fns) => fns.reduceRight((acc, fn) => fn(acc));
const pipe = (...fns) => compose(...fns.reverse());

Приятно ... Это попадает в точку. И теперь, когда наша функция имеет переменную арность, нам не нужно ее каррировать!

Заключение

Итак, мы прошли несколько итераций наших compose, pipe и curry функций. Начиная с самого простого варианта для развития интуиции и заканчивая красиво абстрактным вариантом, который чрезвычайно гибок в использовании.

const compose = (...fns) => fns.reduceRight((acc, fn) => fn(acc));
const pipe = (...fns) => compose(...fns.reverse());
const curry = fn => {
 const curried = (...args) =>
   args.length >= fn.length
     ? fn.apply(null, args)
     : curried.bind(null, ...args);
 return curried;
};