Композиция, каррирование, частичное применение и конвейеры
Предыдущие статьи курса дали строительный материал: чистые функции (https://courses.digitable.life/post/functional-programming/01-pure-functions/), неизменяемые данные (https://courses.digitable.life/post/functional-programming/02-immutability/), функции как значения (https://courses.digitable.life/post/functional-programming/03-higher-order-functions/) и рекурсию (https://courses.digitable.life/post/functional-programming/04-recursion/). Материал есть — раствора нет. Эта статья про раствор.
Тезис звучит скучно: «функции можно склеивать». Но именно здесь ФП перестаёт быть набором приёмов и становится способом проектирования. В ООП единица сборки — объект, и, чтобы соединить два объекта, нужно договориться об интерфейсе. В ФП единица сборки — функция, и договариваться почти не о чем: выход одной должен подходить на вход другой. Всё. Из этого крошечного правила вырастают конвейеры обработки данных, middleware в веб-фреймворках, парсер-комбинаторы, оптика (линзы), билдеры запросов и половина того, что дальше в курсе назовут монадами.
Обзорная статья трека парадигм (https://courses.digitable.life/post/paradigms/03-functional/) упоминала композицию одним абзацем. Здесь мы разберём её до дна: почему для композиции нужны одноместные функции, откуда берётся каррирование, чем оно отличается от частичного применения, почему Elixir сознательно отказался от каррирования и выиграл, и сколько всё это стоит в тактах процессора и в часах отладки.
Сначала боль: код, который читается изнутри наружу
Задача бытовая: есть список заказов, надо посчитать выручку по оплаченным заказам магазина, отсортировать позиции и вывести округлённую сумму.
// Как это обычно пишется в первый раз
const result = formatMoney(
round2(
sum(
map(
filter(orders, (o) => o.status === "paid"),
(o) => o.amount * (1 - o.discount)
)
)
)
);
Прочитайте это вслух. Чтобы понять, что происходит первым, глаз должен нырнуть в самую глубокую скобку, а потом всплывать наружу — то есть читать код в порядке, обратном порядку выполнения. Каждый уровень вложенности добавляет пару скобок, и, когда шагов становится семь, любое изменение превращается в упражнение на балансировку скобок. Программисты на Lisp прожили с этим всю жизнь и научились, остальные — нет.
Вторая, менее очевидная боль: эта конструкция одноразовая. Внутри неё живёт пять шагов логики «как посчитать выручку», но выделить их нельзя — они склеены со значением orders. Чтобы посчитать выручку по другому списку, придётся скопировать всё выражение.
Классический императивный ответ — временные переменные:
const paid = orders.filter((o) => o.status === "paid");
const amounts = paid.map((o) => o.amount * (1 - o.discount));
const total = sum(amounts);
const result = formatMoney(round2(total));
Читается лучше. Но появились четыре имени, которые не значат ничего (amounts, total), четыре промежуточных массива в куче и ощущение, что мы описываем не что считаем, а как складываем ящики. И переиспользовать по-прежнему нельзя.
Композиция решает обе боли сразу: она даёт линейный порядок чтения и оставляет результат склейки самостоятельным значением.
Композиция: одна операция, два направления
Композиция двух функций — это третья функция, которая прогоняет аргумент через обе. Канонически, на Haskell:
(.) :: (b -> c) -> (a -> b) -> a -> c
(f . g) x = f (g x)
Читайте тип как схему проводки: g превращает a в b, f превращает b в c, значит склейка превращает a в c, а промежуточный тип b исчезает из внешнего интерфейса. Композиция типобезопасна по построению: если выход g не подходит на вход f, программа не соберётся. Это, пожалуй, главный аргумент за композицию в типизированных языках — компилятор проверяет стыковку труб.
Направлений у композиции два, и путаница между ними — источник половины ошибок новичков:
compose(справа налево) — математическая нотация,compose(f, g)(x) === f(g(x)). Так работает.в Haskell иR.composeв Ramda.pipe(слева направо) — порядок выполнения,pipe(g, f)(x) === f(g(x)). Так работает>>>в Haskell (Control.Category),R.pipe, оператор|>в Elixir и F#.
Практический совет без идеологии: в прикладном коде используйте pipe. Порядок «сверху вниз, как выполняется» экономит когнитивную нагрузку каждому, кто откроет файл через полгода. compose оправдан, когда вы буквально переносите математическую формулу или пишете на Haskell, где . въелся в глаза.
Реализация в четырёх языках
// TypeScript: pipe с перегрузками — так типизирован fp-ts и большинство библиотек
export function pipe<A, B>(f: (a: A) => B): (a: A) => B;
export function pipe<A, B, C>(f: (a: A) => B, g: (b: B) => C): (a: A) => C;
export function pipe<A, B, C, D>(
f: (a: A) => B, g: (b: B) => C, h: (c: C) => D
): (a: A) => D;
export function pipe(...fns: Array<(x: unknown) => unknown>) {
// reduce слева направо: значение течёт через все функции по очереди
return (x: unknown) => fns.reduce((acc, fn) => fn(acc), x);
}
Перегрузки — не эстетство: без них TypeScript выведет unknown и потеряет всю проверку стыковки. Вариадическая типизация через кортежи возможна (fp-ts до версии 2 так и делал), но сообщения об ошибках становятся нечитаемыми, поэтому индустрия остановилась на десятке перегрузок вручную. Это первая честная цена композиции — о ней ещё поговорим.
from functools import reduce
from typing import Callable, TypeVar
A = TypeVar("A")
def pipe(*fns: Callable) -> Callable:
"""Слева направо: pipe(f, g)(x) == g(f(x))."""
return lambda x: reduce(lambda acc, fn: fn(acc), fns, x)
# В Python принято и другое: применить сразу, без создания функции.
# Именно так работает toolz.pipe — https://toolz.readthedocs.io
def pipe_value(x, *fns):
for fn in fns:
x = fn(x)
return x
У Python тут слабое место: статически типизировать pipe нормально нельзя (ParamSpec помогает только для одного шага), а оператора конвейера в языке нет и предложения добавить его отклонялись. Поэтому в Python композиция чаще остаётся локальным приёмом, а не архитектурой.
# Elixir: композиция не нужна как функция — есть оператор конвейера,
# встроенный в язык на уровне макросов.
orders
|> Enum.filter(&(&1.status == :paid))
|> Enum.map(&(&1.amount * (1 - &1.discount)))
|> Enum.sum()
|> Float.round(2)
|> Money.format()
# Если всё-таки нужна композиция как ЗНАЧЕНИЕ:
compose = fn f, g -> fn x -> f.(g.(x)) end end
pipeline = Enum.reduce([&Float.round(&1, 2), &Money.format/1], & &2.(&1))
-- Haskell: композиция — базовый способ определять функции.
revenue :: [Order] -> Text
revenue = formatMoney . round2 . sum . map netAmount . filter isPaid
-- Слева направо, если так читается лучше:
import Control.Category ((>>>))
revenue' :: [Order] -> Text
revenue' = filter isPaid >>> map netAmount >>> sum >>> round2 >>> formatMoney
Обратите внимание на строку с Haskell: аргумент не упомянут ни разу. revenue определена не как «функция, которая берёт список и делает с ним то-то», а как «вот эта склейка пяти функций». Это point-free стиль; к его цене вернёмся отдельно.
Законы: почему композиция ведёт себя предсказуемо
Композиция — не просто удобный синтаксис, у неё есть два свойства, на которые можно опираться при рефакторинге.
Ассоциативность: f . (g . h) == (f . g) . h. Скобки не важны, значит любой кусок конвейера можно вынести в отдельную именованную функцию, не меняя поведения. Это и есть механический рефакторинг «выделить шаг»: берёте три подряд идущих шага пайплайна, называете normalize, вставляете обратно — гарантированно ничего не сломалось.
Нейтральный элемент: f . id == id . f == f, где id x = x. Скучно, но полезно: identity — законная «пустая» стадия конвейера, поэтому pipe() без аргументов должен возвращать identity, а не падать. Так же как sum([]) == 0.
Вместе эти два свойства означают, что функции с композицией образуют моноид (а для функций разных типов — категорию). Формальную сторону разберём в статье про иерархию абстракций (https://courses.digitable.life/post/functional-programming/09-functor-map/); сейчас важен практический вывод: конвейер можно резать и склеивать в любом месте, и это безопасно.
Каррирование: почему для композиции нужны одноместные функции
Теперь главный подвох. Композиция определена для функций одного аргумента: выход g — ровно одно значение, вход f — ровно один параметр. А реальные функции многоместные: filter(list, pred), map(list, fn), replace(str, from, to).
Как вставить filter в конвейер, если у неё два аргумента, а через конвейер течёт только один? Ответ: превратить её в функцию одного аргумента, зафиксировав остальные. Это и есть каррирование.
Каррирование (в честь Хаскелла Карри, хотя придумал приём Мозес Шёнфинкель) — преобразование функции от N аргументов в цепочку из N функций по одному аргументу.
-- В Haskell ВСЕ функции каррированы. Это не сахар, это устройство языка.
volume :: Int -> Int -> Int -> Int
volume w h d = w * h * d
-- volume 2 — законное выражение типа Int -> Int -> Int
slab :: Int -> Int
slab = volume 2 3 -- «плита 2×3», осталась толщина
-- Многоместность — иллюзия: (,,) кортеж надо просить явно
volumeT :: (Int, Int, Int) -> Int
volumeT (w, h, d) = w * h * d
В языках, где функции по-настоящему многоместные, каррирование приходится строить руками:
// Ручное каррирование — просто вложенные стрелки
const volume = (w: number) => (h: number) => (d: number) => w * h * d;
const slab = volume(2)(3); // (d: number) => number
slab(4); // 24
// Универсальный curry: собирает аргументы, пока их не наберётся arity
function curry(fn: Function, arity = fn.length) {
return function curried(...args: unknown[]): unknown {
// достаточно аргументов — вызываем
if (args.length >= arity) return fn(...args);
// не достаточно — возвращаем функцию, ждущую остальные
return (...rest: unknown[]) => curried(...args, ...rest);
};
}
Универсальный curry — тот случай, когда динамика JavaScript выглядит красиво, а типизация разваливается: fn.length неизвестен на этапе компиляции, поэтому точный тип результата TypeScript вывести не может. Ramda и lodash/fp решают это сотнями рукописных перегрузок и всё равно ломаются на функциях с необязательными параметрами и rest-аргументами (у них fn.length не считает такие параметры вовсе — классический источник багов: curry((a, b = 1) => a + b) имеет арность 1 и вызовется слишком рано).
Каррирование ≠ частичное применение
Эти слова путают постоянно, хотя разница простая.
Частичное применение — зафиксировать часть аргументов функции и получить функцию от оставшихся. Это операция «применить не до конца», доступная в любом языке с замыканиями. Результат — одна новая функция.
Каррирование — структурная трансформация типа: (A, B, C) -> D превращается в A -> B -> C -> D. Каррированная функция позволяет частичное применение как побочный эффект своей формы.
from functools import partial
def replace(text: str, old: str, new: str) -> str:
return text.replace(old, new)
# Частичное применение — стандартная библиотека, никакого каррирования
strip_tabs = partial(replace, old="\t", new=" ")
strip_tabs("a\tb") # 'a b'
# partial фиксирует аргументы СЛЕВА (или по имени),
# поэтому data-first сигнатуры под него неудобны:
# нужный нам "зафиксировать old/new, оставить text" работает
# только через keyword-аргументы.
# Elixir: каррирования НЕТ, частичное применение есть — через capture.
add = fn a, b -> a + b end
add_five = &add.(5, &1) # анонимная функция одной переменной
add_five.(10) # 15
# Захват именованной функции с фиксированным аргументом:
to_cents = &Kernel.round(&1 * 100)
Отсутствие каррирования в Elixir — осознанное решение, а не недоработка. В Erlang/Elixir арность — часть идентичности функции: Enum.map/2 и Enum.map/3 это разные функции, &foo/1 и &foo/2 разные значения. Если бы foo(1) возвращало функцию вместо ошибки, опечатка «забыл аргумент» превращалась бы из мгновенного падения в тихий баг, всплывающий через три модуля. Плюс дефолтные аргументы (def f(a, b \\ 10)) делают арность неоднозначной. Elixir выбрал строгую арность и компенсировал её оператором |> — и это работает лучше, чем каррирование, для той же задачи. Подробнее об идиомах языка — в треке https://courses.digitable.life/post/elixir/00-overview/.
| Свойство | Каррирование | Частичное применение |
|---|---|---|
| Что меняет | форму (тип) функции | ничего, создаёт новую функцию |
| Сколько аргументов за раз | ровно один | сколько угодно |
| Результат | цепочка одноместных функций | функция от оставшихся аргументов |
| Есть в Haskell | по умолчанию, всегда | как следствие каррирования |
| Есть в Python | нет (только вручную) | functools.partial |
| Есть в Elixir | нет и не будет | capture-синтаксис |
| Есть в JS/TS | нет (через curry) |
bind, стрелки, замыкания |
Порядок аргументов решает всё: data-first против data-last
Каррирование бесполезно, если аргументы стоят в неудобном порядке. Сравните:
// data-first: данные первыми (Elixir, JS-методы, стандартная библиотека Python)
filter(list, pred) // частично применить pred, оставив list, — нельзя без flip
// data-last: данные последними (Haskell, Ramda, lodash/fp)
filter(pred)(list) // filter(pred) — готовая функция [A] -> [A], идеально для pipe
Это два самосогласованных мира, и смешивать их — верный путь к каше.
Мир data-last + каррирование + compose. Каждая частично применённая функция сразу готова стать стадией конвейера. Пайплайн собирается без данных и сам является значением: его можно вернуть из функции, положить в конфиг, скомпоновать с другим пайплайном. Плата — обязательный curry на всех функциях, тяжёлые типы, и чтение справа налево в compose.
import * as R from "ramda";
// Пайплайн существует сам по себе, до появления данных
const revenue = R.pipe(
R.filter(R.propEq("status", "paid")),
R.map((o) => o.amount * (1 - o.discount)),
R.sum,
(x) => Math.round(x * 100) / 100
);
revenue(orders);
Мир data-first + оператор конвейера. Функции остаются обычными, компилятор/рантайм ничего не оборачивает, стектрейсы честные, а |> подставляет данные слева. Плата — «пайплайн как значение» отсутствует: чтобы получить переиспользуемую функцию, нужно завести именованную функцию.
defmodule Report do
def revenue(orders) do
orders
|> Enum.filter(&(&1.status == :paid))
|> Enum.map(&net_amount/1)
|> Enum.sum()
|> Float.round(2)
end
# Когда данные НЕ первый аргумент, спасает then/2 (Elixir 1.12+):
def report(orders) do
orders
|> revenue()
|> then(&Money.format(:usd, &1)) # валюта первым аргументом — не беда
end
end
Когда порядок всё-таки не тот, есть flip:
flip :: (a -> b -> c) -> b -> a -> c
flip f x y = f y x
-- Data.Function.(&) — обратное применение, «конвейер» в Haskell:
-- x & f & g == g (f x)
Практическое правило: в языке с оператором конвейера пишите data-first, в языке без него — data-last и каррирование. JavaScript пока живёт без оператора: предложение TC39 (proposal-pipeline-operator, hack-style с топик-токеном %) годами держится на стадии 2, поэтому в JS/TS выбор — либо Ramda/fp-ts с data-last, либо методы массива, либо просто локальные переменные.
Конвейеры на практике: обработка данных
Соберём один и тот же нетривиальный пайплайн: из сырых строк лога вытащить события ошибок, сгруппировать по сервису, посчитать и отдать топ-3.
defmodule LogStats do
@doc "Топ-3 сервиса по числу ошибок. O(n log n) по времени из-за сортировки."
def top_errors(lines) do
lines
|> Stream.map(&String.trim/1) # ленивая стадия — без промежуточного списка
|> Stream.reject(&(&1 == ""))
|> Stream.map(&parse/1)
|> Stream.filter(&match?({:ok, %{level: :error}}, &1))
|> Stream.map(fn {:ok, ev} -> ev.service end)
|> Enum.frequencies() # здесь поток схлопывается в map
|> Enum.sort_by(fn {_svc, n} -> -n end)
|> Enum.take(3)
end
end
Stream вместо Enum — важная деталь: Enum материализует список на каждой стадии, Stream строит ленивое описание и проходит данные один раз. Для файла на миллион строк это разница между «упало по памяти» и «отработало за секунду». Ленивость подробно разбирается в https://courses.digitable.life/post/functional-programming/11-laziness-and-streams/.
from collections import Counter
from itertools import islice
def top_errors(lines, n=3):
"""Генераторы — питоновский аналог конвейера: ленивые, без промежуточных списков."""
stripped = (line.strip() for line in lines)
non_empty = (s for s in stripped if s)
events = (ev for ev in map(parse, non_empty) if ev is not None)
errors = (ev.service for ev in events if ev.level == "error")
return Counter(errors).most_common(n)
Здесь Python честнее многих: генераторные выражения дают ту же ленивость и тот же линейный порядок чтения, что и |>, только синтаксис другой. Имена промежуточных генераторов — не «мусорные переменные», а документация стадий.
// TypeScript через pipe: каждая стадия — именованная функция, тестируемая отдельно
const topErrors = (lines: string[]): Array<[string, number]> =>
pipe(
lines,
A.map((s) => s.trim()),
A.filter((s) => s.length > 0),
A.filterMap(parse), // разбор + отсев неудач одним шагом
A.filter((ev) => ev.level === "error"),
A.map((ev) => ev.service),
countBy,
(m) => [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3)
);
Обратите внимание, что здесь pipe принимает значение первым аргументом — это стиль fp-ts 2.x, компромисс между удобством чтения и типизацией. Он не даёт «пайплайна как значения», зато типы выводятся идеально.
Сложность. Все три варианта: O(n) на проход + O(k log k) на сортировку, где k — число уникальных сервисов. По памяти ленивые варианты — O(k), нетерпеливые (Enum вместо Stream, списковые включения вместо генераторов) — O(n) на каждую стадию, то есть O(n) с константой, равной числу стадий.
Композиция не только для функций A → B
Как только вы освоили идею «склеиваем маленькое в большое», она начинает встречаться повсюду.
Предикаты композируются логическими связками:
const and = <A>(...ps: Array<(a: A) => boolean>) => (a: A) => ps.every((p) => p(a));
const or = <A>(...ps: Array<(a: A) => boolean>) => (a: A) => ps.some((p) => p(a));
const not = <A>(p: (a: A) => boolean) => (a: A) => !p(a);
const isActivePremium = and(isActive, isPremium, not(isBanned));
Компараторы композируются в лексикографический порядок — это моноид, и его нейтральный элемент — «всегда равно»:
type Cmp<A> = (x: A, y: A) => number;
const by = <A, B>(f: (a: A) => B): Cmp<A> => (x, y) => (f(x) < f(y) ? -1 : f(x) > f(y) ? 1 : 0);
const then = <A>(...cs: Array<Cmp<A>>): Cmp<A> =>
(x, y) => { for (const c of cs) { const r = c(x, y); if (r !== 0) return r; } return 0; };
users.sort(then(by((u) => u.lastName), by((u) => u.firstName), by((u) => u.id)));
Middleware в веб-фреймворках — это композиция функций Handler -> Handler. Plug в Elixir, middleware в Express, http.Handler декораторы в Go — везде одна и та же идея: обёртка вокруг обработчика, и обёртки склеиваются в цепочку.
Трансдьюсеры: композиция преобразований без промежуточных коллекций
Самый интересный случай. Обычный конвейер map → filter → map над массивом из n элементов и k стадий создаёт k промежуточных массивов: O(n·k) аллокаций. Ленивость (Stream, генераторы) это чинит, но требует поддержки от языка/библиотеки.
Трансдьюсеры (Рич Хикки, Clojure, clojure.org/reference/transducers) чинят иначе: композируется не преобразование коллекций, а преобразование редьюсеров. Тип трансдьюсера — Reducer<B> -> Reducer<A>, то есть обычная функция одного аргумента, а значит её можно совать в compose.
// Редьюсер: (acc, x) => acc. Трансдьюсер: редьюсер => редьюсер.
const mapping = (f) => (step) => (acc, x) => step(acc, f(x));
const filtering = (p) => (step) => (acc, x) => (p(x) ? step(acc, x) : acc);
// Композиция трансдьюсеров ЧИТАЕТСЯ слева направо, хотя это compose:
// каждый трансдьюсер оборачивает следующий, и обёртки разворачиваются наизнанку.
const xform = compose(
filtering((o) => o.status === "paid"),
mapping((o) => o.amount)
);
const transduce = (xf, step, init, xs) => xs.reduce(xf(step), init);
transduce(xform, (a, b) => a + b, 0, orders); // один проход, ноль промежуточных массивов
Тот же эффект в Haskell получается автоматически: GHC на -O2 применяет rewrite-правила вроде map f . map g = map (f . g) и list fusion, схлопывая конвейер в один цикл без промежуточных списков. То есть в Haskell вы пишете идиоматичную композицию, а компилятор превращает её в императивный цикл — редкий случай, когда абстракция реально бесплатна.
Честная цена
Курс обещал не продавать ФП, поэтому — счёт.
Производительность. Каждая стадия композиции в языке без агрессивного инлайнинга — это как минимум одно замыкание в куче и один непрямой вызов. Каррированная функция от трёх аргументов создаёт три объекта-функции вместо одного вызова. На горячем пути с миллионами итераций разница между arr.filter(p).map(f) и ручным циклом на V8 обычно 1.5–4×, а между Ramda-пайплайном и ручным циклом — легко 5–10×, потому что curry добавляет диспетчеризацию по числу аргументов и делает call-site мегаморфным, отключая инлайнинг. Замеряйте, а не верьте — но и не пишите трёхуровневый каррированный конвейер внутри цикла отрисовки кадра.
Что делать: держать композицию на уровне архитектуры (пайплайн запроса, обработка батча), а внутренние горячие циклы писать прямолинейно. Это ровно тот же принцип «функциональное ядро, императивная оболочка», что появится в https://courses.digitable.life/post/functional-programming/16-architecture-and-practice/, только применённый к производительности.
Отладка. Композиция стирает промежуточные значения — те самые, которые вы хотите посмотреть, когда что-то пошло не так. В formatMoney . round2 . sum . map f . filter p некуда поставить точку останова. Лечится «функцией-шпионом», которая ничего не меняет:
const tap = <A>(label: string) => (x: A): A => { console.debug(label, x); return x; };
const revenue = pipe(filterPaid, tap("после фильтра"), mapAmount, sum);
Второй удар — стектрейсы. В точке падения вы увидите десяток кадров curried, dispatch, anonymous и ни одного имени своей стадии. Отчасти лечится именованием: const filterPaid = ... вместо анонимной стрелки прямо в pipe. Это единственная реальная причина не писать всё инлайном.
Типы. Вариадический pipe — известная боль систем типов. TypeScript обходится перегрузками (обычно до 9–20 стадий, дальше типы теряются), Python не может почти ничего, Java/C# — только через явные Function.andThen. В Haskell и PureScript проблемы нет вовсе, потому что композиция бинарна и ассоциативна.
Кривая обучения и стоимость команды. Каррированный data-last код — это отдельный диалект. Разработчик, знающий JavaScript, не читает Ramda-пайплайн с листа: ему нужно держать в голове арности, порядок аргументов и то, что R.map работает не только со списками. На проекте с текучкой это реальные недели. Полумера, которая обычно окупается: разрешить pipe и именованные стадии, запретить универсальный curry и point-free ради point-free.
Где композиция прямо мешает.
- Функции с побочными эффектами и порядком выполнения: конвейер прячет то, что важно видеть.
- Ветвление. Композиция линейна; если после второй стадии нужно уйти по трём разным путям, пайплайн превращается в набор вложенных тернарников. Это сигнал перейти к алгебраическим типам и сопоставлению с образцом (https://courses.digitable.life/post/functional-programming/06-adt-and-pattern-matching/) или к монадическим цепочкам (https://courses.digitable.life/post/functional-programming/08-monads/).
- Ранний выход и ошибки.
pipeне умеет «прерваться на третьей стадии» — для этого нуженEither/Result(https://courses.digitable.life/post/functional-programming/10-error-handling/). - Многоаргументные стадии, где данные не первые и не последние. Ad-hoc
flipи лямбды-обёртки съедают всю красоту.
Point-free: соблазн и мера
Point-free (он же tacit) — стиль, в котором аргументы не называются:
-- point-full
countWords s = length (words s)
-- point-free
countWords = length . words
Здесь point-free лучше: короче и честнее выражает «это композиция двух известных операций». А вот классический пример, где стиль убивает читаемость:
-- «среднее значение», point-free
mean = (/) <$> sum <*> (fromIntegral . length)
Это работает (аппликатив функций — тема https://courses.digitable.life/post/functional-programming/07-functors-and-applicatives/), но чтобы прочитать формулу, нужно развернуть её обратно в голове. Сообщество Haskell не зря называет крайнюю форму этого стиля pointless — см. wiki.haskell.org/Point-free.
Рабочий критерий: пишите point-free, если это делает определение похожим на его название, и не пишите, если приходится вставлять flip, . в третьей степени или комбинаторы вроде (.) . (.). Имя аргумента — это документация; отказываться от неё стоит только тогда, когда без имени яснее.
Типичные ошибки
- Перепутанное направление.
composeиpipeв одном файле — гарантированный баг, который компилятор не поймает, если типы стадий совпадают (например, всеstring -> string). Выберите одно и запретите второе линтером. curryна функции с необязательными или rest-параметрами.fn.lengthих не считает, функция вызовется преждевременно сundefined. Каррируйте только функции с фиксированной арностью.- Каррирование функции с эффектом.
curry(saveToDb)(userId)выглядит как «частично применил», но кто-то прочитает это как «уже сохранил». Эффекты и отложенные вызовы плохо уживаются с интуицией — держите эффекты вне каррированных цепочек. - Композиция функций разной арности.
pipe(map, filter)— типичная опечатка, где забыли вызватьmap(f). В TypeScript ловится типами, в JS/Python падает где-то через три стадии. - Нетерпеливые стадии на больших данных.
Enum.map |> Enum.filter |> Enum.take(10)над миллионом записей прогоняет весь миллион дважды.Streamили генераторы решают это одной заменой. - Мутирующие стадии. Если стадия меняет вход на месте (
arr.sort(),arr.reverse()), конвейер перестаёт быть безопасным: повторный запуск даст другой результат. См. https://courses.digitable.life/post/functional-programming/02-immutability/. - Гигантский безымянный пайплайн. Двадцать анонимных стрелок подряд — это не функциональный стиль, это функция на 60 строк, записанная боком. Режьте по смыслу:
parse,validate,enrich,aggregate.
Мини-итог
- Композиция склеивает функции по типам: выход одной подходит на вход другой, промежуточный тип исчезает из интерфейса. Она ассоциативна и имеет нейтральный элемент
identity— поэтому конвейер можно резать и склеивать в любом месте безопасно. pipe(слева направо) читается лучшеcomposeв прикладном коде; выбирайте одно направление на проект.- Каррирование превращает
(A, B) -> CвA -> B -> Cи существует ради того, чтобы многоместные функции могли участвовать в композиции. Частичное применение — более слабая и более распространённая операция: просто зафиксировать часть аргументов. - Порядок аргументов определяет стиль. data-last + каррирование +
composeдаёт пайплайн как значение; data-first + оператор конвейера даёт честные стектрейсы и обычные функции. Elixir выбрал второе сознательно и не прогадал. - Композиция шире функций
A -> B: предикаты, компараторы, middleware, редьюсеры (трансдьюсеры). Трансдьюсеры дают композицию без промежуточных коллекций — O(1) дополнительной памяти вместо O(n·k). - Цена реальна: аллокации замыканий и потеря инлайнинга на горячих путях, слепые зоны при отладке, тяжёлая типизация вариадических
pipe, отдельный диалект для команды. Композиция — инструмент уровня архитектуры, а не уровня внутреннего цикла.
Материалы
- Brian Lonsdorf, Professor Frisby’s Mostly Adequate Guide to Functional Programming — главы 5 и 6 про композицию и каррирование: mostly-adequate.gitbook.io
- Документация Ramda — эталон data-last + auto-curry: ramdajs.com/docs
fp-tsи егоpipe/flow: gcanti.github.io/fp-ts- Rich Hickey, Transducers — исходное объяснение и справочник: clojure.org/reference/transducers
toolzдля Python (pipe,compose_left,curry): toolz.readthedocs.io- Документация Elixir по оператору конвейера и
then/2: hexdocs.pm/elixir/Kernel.html - Haskell wiki про point-free стиль: wiki.haskell.org/Point-free
- TC39 pipeline operator proposal — история и текущий статус: github.com/tc39/proposal-pipeline-operator
Что дальше
Композиция отлично работает, пока данные текут по одной трубе. Но реальные данные ветвятся: заказ либо оплачен, либо отменён, либо ждёт; разбор строки либо удался, либо нет. Линейный конвейер такое выражает плохо — нужен способ описывать «одно из нескольких» на уровне типов и разбирать варианты без лестницы if. Именно этим занимаются алгебраические типы данных и сопоставление с образцом.