Что такое каррирование и композиция функций?

SeniorJavaScript #javascript #advanced

Вопрос

Что такое каррирование (currying) функций и композиция (composition), зачем они нужны и как реализовать их в JavaScript?

Короткий ответ

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

Подробный ответ

Каррирование. Обычная функция f(a, b, c) вызывается сразу со всеми аргументами. Каррированная версия вызывается как f(a)(b)(c) — каждый вызов "запоминает" переданный аргумент через замыкание и возвращает новую функцию, ожидающую следующий. Каррирование полезно для:

  • Частичного применения (partial application) — заранее зафиксировать часть аргументов и получить специализированную функцию (например, multiply(2) → функция "удвоить").
  • Переиспользования и композиции — каррированные функции с одним аргументом легко комбинировать в цепочки/пайплайны.
  • Более выразительного API — например, в конфигурируемых обработчиках событий: addValidator(rule)(value).

Общая реализация — универсальная функция curry, которая накапливает аргументы, пока их количество не сравняется с fn.length (арностью исходной функции), и только тогда вызывает оригинальную функцию.

Композиция. Принцип «выход одной функции — вход другой»: compose(f, g, h)(x) эквивалентно f(g(h(x))) — выполнение справа налево, как в математике. pipe — тот же принцип, но слева направо (pipe(f, g, h)(x) = h(g(f(x)))), что часто читается естественнее в коде (порядок действий как в предложении). Оба паттерна реализуются через reduce по массиву функций.

Композиция и каррирование хорошо дополняют друг друга: каррированные функции с одним "содержательным" аргументом (данные) идеально подходят как звенья пайплайна композиции, потому что у каждой функции в цепочке ровно один вход и один выход.

Компромиссы. Эти паттерны дают чистоту и переиспользуемость, но:

  • Усложняют отладку — трассировка стека через цепочку композиций менее наглядна, чем последовательные const с промежуточными именами.
  • Требуют аккуратности с побочными эффектами — паттерн предполагает чистые функции (без мутаций и I/O внутри), иначе выгода от предсказуемости теряется.
  • Могут ухудшать читаемость для команд, не привыкших к функциональному стилю — важен баланс, а не догматизм.

Пример

// Универсальная функция curry
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args)
    }
    return (...more) => curried.apply(this, [...args, ...more])
  }
}

function sum3(a, b, c) {
  return a + b + c
}

const curriedSum = curry(sum3)
console.log(curriedSum(1)(2)(3)) // 6
console.log(curriedSum(1, 2)(3)) // 6 — можно передавать несколько сразу
console.log(curriedSum(1)(2, 3)) // 6

// Частичное применение через каррирование
const multiply = curry((a, b) => a * b)
const double = multiply(2)
console.log(double(5)) // 10
console.log(double(21)) // 42

// compose: справа налево
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x)

// pipe: слева направо
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x)

const trim = (s) => s.trim()
const toLowerCase = (s) => s.toLowerCase()
const removeSpaces = (s) => s.replace(/\s+/g, '-')

const slugify = pipe(trim, toLowerCase, removeSpaces)
console.log(slugify('  Hello World  ')) // "hello-world"

// Композиция каррированных функций в реальном пайплайне обработки данных
const users = [
  { name: 'Alice', age: 30, active: true },
  { name: 'Bob', age: 17, active: true },
  { name: 'Eve', age: 25, active: false },
]

const filterBy = curry((predicate, arr) => arr.filter(predicate))
const mapBy = curry((mapper, arr) => arr.map(mapper))

const getActiveAdultNames = pipe(
  filterBy((u) => u.active),
  filterBy((u) => u.age >= 18),
  mapBy((u) => u.name)
)

console.log(getActiveAdultNames(users)) // ["Alice"]

Дополнительные вопросы

  • Как каррирование связано с частичным применением (Function.prototype.bind) и чем отличается от него?
  • Как обобщить curry, чтобы он корректно работал с функциями переменной арности (rest-параметрами)?
  • Какие трудности возникают при отладке длинных цепочек pipe/compose и как их смягчить (например, именование промежуточных шагов)?