Функторы и аппликативы: контейнеры, к которым применяют функции
Код, который вы писали сто раз
Начнём не с определений, а с боли. Вот три фрагмента из разных проектов, на разных языках, решающие разные задачи.
# 1. Список: удвоить каждое число
результат = []
for x in числа:
результат.append(x * 2)
// 2. Значение, которого может не быть
function upperName(user: User | null): string | null {
if (user === null) return null;
return user.name.toUpperCase();
}
# 3. Результат, который мог не получиться
case fetch_user(id) do
{:ok, user} -> {:ok, String.upcase(user.name)}
{:error, reason} -> {:error, reason}
end
Три разные структуры данных. Три разных синтаксиса. И ровно один повторяющийся сюжет:
- Внутри чего-то (списка, «может быть пусто», «может быть ошибка») лежит значение.
- У нас есть простая функция над значением:
x * 2,String.upcase. - Мы хотим применить её к содержимому, не разрушив упаковку: список остаётся списком,
nilостаётсяnil, ошибка остаётся ошибкой.
Каждый раз мы вручную пишем обвязку: цикл, if, case. Обвязка не несёт смысла — смысл в x * 2. Это шум, и его тем больше, чем глубже вложенность. Три уровня «а вдруг null» превращаются в лестницу проверок, где легко забыть ветку.
Абстракция, которая убирает этот шум, называется функтор. Инструмент, которым мы им пользуемся, называется map.
Первый шаг: map как единственная операция
Перепишем всё три раза одинаково:
результат = [x * 2 for x in числа] # или list(map(lambda x: x * 2, числа))
const upperName = (user: User | null) => user?.name.toUpperCase() ?? null;
// а лучше — явный Option, к которому мы вернёмся ниже
Result.map(fetch_user(id), &String.upcase(&1.name))
Заметьте, что изменилось. Ветвление никуда не делось — оно переехало внутрь map и написано один раз для каждого контейнера. Прикладной код теперь состоит только из смысла.
Обобщим. Пусть F — какой-то контейнер (или, точнее, контекст: «в списке», «возможно, отсутствует», «когда-нибудь придёт из сети»). Тогда нам нужна одна операция:
map : (A → B) → F<A> → F<B>
Читается так: «дай мне функцию из A в B и контейнер с A — верну контейнер с B». Тип контейнера не меняется: список остаётся списком той же длины, Maybe остаётся Maybe, промис остаётся промисом.
Это и есть определение. Функтор — это тип, для которого определена операция map, удовлетворяющая двум законам (о них через минуту). Никакой теории категорий пока не требуется: функтор — это «то, что умеет map».
Каноническая запись на Haskell — просто интерфейс:
class Functor f where
fmap :: (a -> b) -> f a -> f b
-- оператор <$> — то же самое, инфиксно
(<$>) :: Functor f => (a -> b) -> f a -> f b
(<$>) = fmap
Зоопарк: что вообще бывает функтором
Полезно увидеть, насколько разные вещи попадают под это описание. Общее у них — не «коробка», а именно «контекст, который не должен пострадать».
Последняя ветка обычно удивляет. Функция — тоже функтор:
-- fmap для функций — это обычная композиция!
instance Functor ((->) r) where
fmap = (.)
-- fmap (+1) (*2) $ 5 == 11 -- сначала *2, потом +1
Здесь «контейнер» — это «значение, которое появится, когда нам дадут аргумент типа r». Ничего не хранится, но map осмысленен: он преобразует результат, не трогая вход. Тот же приём стоит за парсер-комбинаторами и генераторами: map над парсером — это «распарси как раньше, но результат преобразуй».
На TypeScript тот же зоопарк выглядит так:
// массив — встроенный функтор
[1, 2, 3].map((x) => x * 2); // [2, 4, 6]
// промис — почти функтор (нюанс ниже)
fetchUser(id).then((u) => u.name);
// собственный Option
type Option<A> = { readonly _tag: "None" } | { readonly _tag: "Some"; readonly value: A };
const none: Option<never> = { _tag: "None" };
const some = <A>(value: A): Option<A> => ({ _tag: "Some", value });
const mapOption = <A, B>(fa: Option<A>, f: (a: A) => B): Option<B> =>
fa._tag === "Some" ? some(f(fa.value)) : fa;
Сложность map наследуется от структуры: для списка это O(n) по времени и O(n) по дополнительной памяти (новый список), для Option/Either — O(1), для дерева из n узлов — O(n). Важно, что map никогда не меняет форму: длину списка, глубину дерева, факт наличия ошибки.
Законы функтора: зачем формальности
Одной сигнатуры мало. Можно написать map, который «работает», но ведёт себя предательски. Два закона отделяют честный функтор от самозванца.
Закон 1 — тождество. map(id) == id, где id = x => x.
Применение функции, которая ничего не делает, не должно делать ничего. Кажется тавтологией, но вот контрпример на JavaScript, который встречается в реальном коде:
// «Умный» map, выкидывающий пустые значения
const smartMap = (arr, f) => arr.map(f).filter(Boolean);
smartMap([1, 0, 2], (x) => x); // [1, 2] — а должно быть [1, 0, 2]
Закон нарушен: smartMap с id потерял элемент. Последствие не абстрактное — код, который где-то выше по стеку рассчитывал на сохранение длины (например, zip с параллельным массивом), молча сломается.
Закон 2 — композиция. map(g ∘ f) == map(g) ∘ map(f).
Один проход с составной функцией должен давать тот же результат, что два прохода. Контрпример:
// map, который ещё и переворачивает
const weirdMap = (arr, f) => arr.map(f).reverse();
weirdMap(weirdMap([1, 2, 3], f), g); // порядок вернулся на место
weirdMap([1, 2, 3], (x) => g(f(x))); // порядок перевёрнут — не совпало
Практическая ценность второго закона огромна: он разрешает оптимизацию слиянием (fusion). Компилятор (или вы вручную) вправе заменить xs.map(f).map(g) на xs.map(x => g(f(x))), убрав целый промежуточный массив. GHC делает это автоматически через механизм foldr/build fusion, движки JavaScript — нет, поэтому в горячем коде цепочку .map().map().filter() часто схлопывают руками или трансдьюсерами.
Ещё один вывод из законов: map не имеет права выполнять эффекты. Если ваш map пишет в лог, увеличивает счётчик или дёргает сеть — первый закон сломан, потому что map(id) перестал быть безобидным. Это прямое следствие темы чистых функций из статьи Чистые функции, побочные эффекты и почему это меняет всё.
Честная оговорка про Promise. Promise очень похож на функтор, но then совмещает map и flatMap: если функция вернула промис, он автоматически «сплющивается». Из-за этого Promise<Promise<A>> невозможно построить, и первый закон формально нарушается на значениях-thenable. На практике это редко кусается, но именно поэтому в fp-ts и подобных библиотеках заводят отдельный тип Task — ленивую функцию () => Promise<A>, которая законам подчиняется.
Вторая боль: функция от двух аргументов
Функтор решает ровно одну задачу — «применить функцию одного аргумента к содержимому одного контейнера». Как только аргументов становится два, он ломается.
Задача: есть возможные width и height (например, из формы), нужно вычислить площадь.
const w: Option<number> = some(10);
const h: Option<number> = some(4);
const area = (a: number, b: number) => a * b;
mapOption(w, (a) => mapOption(h, (b) => area(a, b)));
// тип: Option<Option<number>> ← вложенность, которой мы не заказывали
map может засунуть внутрь контейнера только то, что вернула функция. Функция вернула Option, значит внутри Option оказался Option. Со списками ещё нагляднее: xs.map(x => ys.map(y => f(x, y))) даёт массив массивов.
Есть два принципиально разных выхода из положения, и разница между ними — центральная идея этой статьи.
Первый: научиться сплющивать вложенность (flatten / join). Это дорога к монадам, ей посвящена следующая статья.
Второй: заметить, что при каррировании проблема вообще не возникает. Если area записать как a => b => a * b (см. Композиция, каррирование, частичное применение и конвейеры), то:
const areaC = (a: number) => (b: number) => a * b;
mapOption(w, areaC); // Option<(b: number) => number>
Мы получили функцию, лежащую внутри контейнера. Осталась одна недостающая операция: применить упакованную функцию к упакованному аргументу.
ap : F<A → B> → F<A> → F<B>
Это и есть аппликативный функтор — функтор плюс две операции: ap (применение внутри контекста) и pure (положить обычное значение в контекст самым скучным способом).
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
-- и вся конструкция схлопывается в одну идиоматичную строку:
area <$> w <*> h -- Maybe Int
mkUser <$> name <*> email <*> age -- три аргумента, тот же приём
Каждое применение <*> «съедает» ровно один аргумент каррированной функции:
Название теперь читается осмысленно: аппликатив — это тип, для которого применение функции к аргументу (application) продолжает работать внутри контекста.
Ради чего всё это: валидация с накоплением ошибок
Самый убедительный практический пример аппликатива — проверка формы. Требование заказчика знакомо каждому: пользователь заполнил регистрацию с тремя ошибками — покажи ему все три сразу, а не по одной за отправку.
Классический Either/Result здесь не поможет, и это важно понять. Either закорачивает: первая ошибка обрывает цепочку, остальные проверки даже не запускаются. Так и должно быть — если пользователь не найден, дальше идти некуда. Но для формы нужно противоположное поведение.
Решение — отдельный тип Validation, у которого ap при двух ошибках склеивает их вместо выбора первой. Каноническая запись:
data Validation e a = Failure e | Success a
instance Functor (Validation e) where
fmap _ (Failure e) = Failure e
fmap f (Success a) = Success (f a)
-- Semigroup e — единственное требование: ошибки должны уметь склеиваться (<>)
instance Semigroup e => Applicative (Validation e) where
pure = Success
Failure e1 <*> Failure e2 = Failure (e1 <> e2) -- ключевая строка
Failure e1 <*> _ = Failure e1
_ <*> Failure e2 = Failure e2
Success f <*> Success a = Success (f a)
validateUser :: Form -> Validation [String] User
validateUser f = User <$> vName f <*> vEmail f <*> vAge f
Одна строка Failure e1 <*> Failure e2 = Failure (e1 <> e2) меняет всю семантику. Всё остальное — тот же код.
То же на TypeScript, вручную, без библиотек:
type Validation<E, A> =
| { readonly _tag: "Invalid"; readonly errors: readonly E[] }
| { readonly _tag: "Valid"; readonly value: A };
const valid = <E, A>(value: A): Validation<E, A> => ({ _tag: "Valid", value });
const invalid = <E, A>(...errors: E[]): Validation<E, A> => ({ _tag: "Invalid", errors });
const map = <E, A, B>(fa: Validation<E, A>, f: (a: A) => B): Validation<E, B> =>
fa._tag === "Valid" ? valid(f(fa.value)) : fa;
const ap = <E, A, B>(
ff: Validation<E, (a: A) => B>,
fa: Validation<E, A>,
): Validation<E, B> => {
if (ff._tag === "Invalid" && fa._tag === "Invalid")
return { _tag: "Invalid", errors: [...ff.errors, ...fa.errors] }; // копим обе
if (ff._tag === "Invalid") return ff;
if (fa._tag === "Invalid") return fa;
return valid(ff.value(fa.value));
};
// --- прикладной код ---
type User = { name: string; email: string; age: number };
const mkUser = (name: string) => (email: string) => (age: number): User => ({ name, email, age });
const vName = (s: string): Validation<string, string> =>
s.trim().length >= 2 ? valid(s.trim()) : invalid("имя короче двух символов");
const vEmail = (s: string): Validation<string, string> =>
s.includes("@") ? valid(s) : invalid("email без @");
const vAge = (n: number): Validation<string, number> =>
Number.isInteger(n) && n >= 18 ? valid(n) : invalid("возраст должен быть целым и не меньше 18");
const result = ap(ap(ap(valid<string, typeof mkUser>(mkUser), vName("Я")), vEmail("нет")), vAge(15));
// { _tag: "Invalid", errors: ["имя короче двух символов", "email без @", "возраст ..."] }
Все три ошибки собраны за один проход. Обратите внимание на цену без сахара: тройная вложенность ap(ap(ap(...))) читается плохо, а вывод типов TypeScript на такой конструкции требует явной аннотации valid<string, typeof mkUser>. Библиотеки вроде fp-ts прячут это за sequenceS/traverse, но платят своей сложностью типов — об этом в разделе про цену.
На Python (синтаксис дженериков 3.12+):
from dataclasses import dataclass
from typing import Callable
@dataclass(frozen=True)
class Valid[A]:
value: A
@dataclass(frozen=True)
class Invalid:
errors: tuple[str, ...]
type Validation[A] = Valid[A] | Invalid
def vmap[A, B](fa: Validation[A], f: Callable[[A], B]) -> Validation[B]:
return Valid(f(fa.value)) if isinstance(fa, Valid) else fa
def vap[A, B](ff: Validation[Callable[[A], B]], fa: Validation[A]) -> Validation[B]:
match ff, fa:
case Invalid(e1), Invalid(e2): return Invalid(e1 + e2) # накопление
case Invalid(_), _: return ff
case _, Invalid(_): return fa
case Valid(f), Valid(a): return Valid(f(a))
def curry3[A, B, C, R](f: Callable[[A, B, C], R]):
return lambda a: lambda b: lambda c: f(a, b, c)
# использование
user = vap(vap(vap(Valid(curry3(User)), v_name(raw)), v_email(raw)), v_age(raw))
Сопоставление с образцом здесь делает код почти таким же коротким, как на Haskell — см. Алгебраические типы данных и сопоставление с образцом.
На Elixir высших типов (HKT) нет, поэтому аппликатив пишется как обычный модуль для конкретного типа — зато отлично ложится на пайплайн:
defmodule Validation do
@type t(a) :: {:ok, a} | {:error, [String.t()]}
def pure(value), do: {:ok, value}
def map({:ok, v}, f), do: {:ok, f.(v)}
def map({:error, _} = err, _f), do: err
# порядок клауз критичен: сначала случай «обе ошибки»
def ap({:error, e1}, {:error, e2}), do: {:error, e1 ++ e2}
def ap({:error, _} = err, _), do: err
def ap(_, {:error, _} = err), do: err
def ap({:ok, f}, {:ok, v}), do: {:ok, f.(v)}
end
# каррируем руками — в Elixir функции не каррированы по умолчанию
mk_user = fn name -> fn email -> fn age -> %User{name: name, email: email, age: age} end end end
Validation.pure(mk_user)
|> Validation.ap(validate_name(params))
|> Validation.ap(validate_email(params))
|> Validation.ap(validate_age(params))
Кстати, если вы писали на Elixir с Ecto — вы уже пользовались аппликативной валидацией, не зная названия: Ecto.Changeset прогоняет все validate_* и собирает ошибки в список, а не падает на первой (документация Ecto.Changeset). Подробнее про сам язык — в курсе Elixir.
Аппликатив против монады: в чём настоящая разница
Это вопрос, на котором спотыкаются чаще всего. Формулировка, которую стоит запомнить:
Аппликатив описывает вычисления, эффекты которых не зависят друг от друга. Монада описывает вычисления, где следующий шаг выбирается по результату предыдущего.
Из этого различия следуют все практические последствия.
и собрать все ошибки"] MO -.-> R2["строго последовательно,
останов на первой ошибке"]
В аппликативе три проверки не знают друг о друге: их граф зависимостей известен до запуска. Значит, их можно выполнить параллельно, а ошибки — накопить. В монаде второй шаг физически не существует, пока не выполнен первый: fetch_user должен вернуть роль, чтобы стало понятно, что делать дальше.
Отсюда следует важный, но неочевидный факт: Validation — аппликатив, но не монада. Если бы у него был законный flatMap, из законов монады выводилось бы, что ap обязан закорачиваться на первой ошибке — а это ровно то поведение, ради отказа от которого тип и создавался. Тип, у которого нет монадического интерфейса, — это не «недоделанный», а более сильный по возможностям: чем меньше требований к структуре, тем больше свободы у реализации (параллелизм, статический анализ, накопление).
Именно на этом наблюдении построены реальные системы. Haxl от Facebook/Meta использует аппликативный интерфейс, чтобы автоматически объединять независимые запросы к бэкендам в один батч, — статья «There is no Fork: an Abstraction for Efficient, Concurrent, and Concise Data Access» описывает, как это дало десятикратное ускорение на проде. Парсер-комбинаторы используют аппликативную часть, чтобы строить грамматику статически, а монадическую — только там, где грамматика контекстно-зависима.
Иерархия абстракций в одном месте:
Полная карта абстракций — от полугруппы до комонады — разбирается в статье Карта функторов, а стратегии обработки ошибок целиком — в Обработка ошибок в ФП.
Аппликативы, которыми вы уже пользуетесь
Абстракция полезна тем, что позволяет узнать знакомое в незнакомом. Почти в каждом языке аппликативы встроены под другими именами:
| Знакомая конструкция | Что это на самом деле | Семантика |
|---|---|---|
Promise.all([a, b, c]) |
аппликатив для Task |
параллельный запуск, останов на первой ошибке |
Promise.allSettled([...]) |
аппликатив с накоплением | параллельный запуск, все результаты и все ошибки |
zip(xs, ys) |
«zip-аппликатив» для списков | попарно, длина = минимум |
списковые включения с двумя for |
«декартов аппликатив» для списков | все комбинации, длина = произведение |
Task.async_stream/3 в Elixir |
аппликатив над задачами | ограниченный параллелизм |
Ecto.Changeset |
Validation |
накопление ошибок формы |
asyncio.gather(*tasks) в Python |
аппликатив для корутин | параллельный запуск |
Строчка про списки заслуживает внимания: у списка два законных аппликативных экземпляра — декартово произведение и поэлементный zip. Оба удовлетворяют законам, но дают разный результат. В Haskell для второго заводят отдельную обёртку ZipList, потому что один тип не может иметь два инстанса одного класса. Это хорошая иллюстрация того, что «аппликатив» — не свойство структуры данных, а выбранная для неё стратегия комбинирования.
traverse: рабочая лошадка, ради которой всё затевалось
На практике 80% пользы от аппликативов приходит через одну производную функцию. Задача формулируется так: у меня список входов и функция, которая каждый может провалить. Хочу либо список результатов, либо все ошибки.
Наивный map даёт не то: List<Validation<E, B>> — список из валидаций. Нужно «вывернуть наизнанку»: Validation<E, List<B>>.
traverse :: (Traversable t, Applicative f) => (a -> f b) -> t a -> f (t b)
sequenceA :: (Traversable t, Applicative f) => t (f a) -> f (t a)
-- проверить все email-адреса и собрать все ошибки разом
traverse validateEmail ["a@b.c", "плохой", "тоже плохой"]
-- Failure ["email без @: плохой", "email без @: тоже плохой"]
Ровно та же функция с другим аппликативом делает совсем другую работу: с Task — запускает N запросов параллельно и ждёт всех; с Maybe — возвращает Nothing, если хоть один элемент пуст; со списком — даёт декартово произведение. Одна абстракция, четыре сценария. Классический разбор этого приёма — статья Гиббонса и Оливейры «The Essence of the Iterator Pattern».
Реализация на TypeScript через уже написанный ap — и сразу с честным замечанием о сложности:
const traverse = <E, A, B>(
xs: readonly A[],
f: (a: A) => Validation<E, B>,
): Validation<E, readonly B[]> =>
xs.reduce<Validation<E, readonly B[]>>(
(acc, a) => ap(map(acc, (bs) => (b: B) => [...bs, b]), f(a)),
valid([]),
);
Красиво, но [...bs, b] копирует накопленный массив на каждом шаге — это O(n²) по времени и мусору. В учебниках эту деталь обычно опускают; в проде она превращается в инцидент на списке из 50 000 элементов. Лечится либо аккумулятором с push внутри локально-мутабельной реализации (снаружи функция остаётся чистой — приём «функциональное ядро, изолированная мутация»), либо persistent-структурой с O(1) добавлением в конец. Подробности — в Персистентные структуры данных и их реальная стоимость.
Законы аппликатива
Их четыре. Знать наизусть не обязательно — важно понимать, что они означают «ap ведёт себя как обычное применение функции, только в контексте».
| Закон | Формулировка | Смысл |
|---|---|---|
| Identity | pure(id) ⊛ v == v |
обёрнутая тождественная функция ничего не делает |
| Homomorphism | pure(f) ⊛ pure(x) == pure(f(x)) |
если эффектов нет — это просто вызов функции |
| Interchange | u ⊛ pure(y) == pure(f => f(y)) ⊛ u |
«чистый» аргумент можно двигать через применение |
| Composition | pure(∘) ⊛ u ⊛ v ⊛ w == u ⊛ (v ⊛ w) |
скобки в цепочке не влияют на результат |
Плюс связь с функтором: map(f, x) обязан равняться pure(f) ⊛ x. Практический вывод из законов ровно один, но ценный: порядок и группировка независимых аргументов не влияют на результат, поэтому рантайм имеет право переупорядочить их или выполнить параллельно. Именно эту гарантию эксплуатируют Haxl и Promise.all.
Если ваш ap нарушает interchange — например, потому что первый аргумент незаметно логирует, — параллельная реализация начнёт давать результаты, отличающиеся от последовательной. Это как раз тот класс багов, который невозможно найти при code review.
Цена: где функторы и аппликативы мешают
Курс был бы нечестным без этого раздела. Абстракция не бесплатна.
1. Аллокации и отсутствие fusion. Каждый map над списком в языке без слияния циклов создаёт новый массив. Цепочка .map(f).map(g).filter(p) над миллионом элементов — три полных прохода и два мусорных массива. GHC схлопывает это на -O2, Rust — за счёт ленивых итераторов, а JavaScript, Python и Elixir — нет. В горячих участках приходится писать цикл руками или использовать трансдьюсеры/Stream (см. Ленивость, бесконечные структуры и потоки данных).
2. Стоимость обёртки. Some(x) в JavaScript или Python — это объект в куче на каждое значение: +16–32 байта, промах кэша, нагрузка на GC. В языках с продвинутым представлением типов этого нет: Rust благодаря niche optimization хранит Option<&T> в тех же 8 байтах, что и указатель. Если вы обернули каждое поле «горячей» структуры в Option, замерьте — не удивляйтесь падению вдвое.
3. Накопление ошибок наивным конкатенированием. e1 ++ e2 в левосвязанной цепочке ap даёт O(k²) по числу ошибок. Для формы из 10 полей — неважно; для валидации CSV на миллион строк — катастрофа. Стандартное лечение: difference lists или NonEmpty-структура с O(1) слиянием.
4. Отсутствие высших типов (HKT). Functor как интерфейс требует уметь абстрагироваться над самим конструктором типа F. Это есть в Haskell, Scala, PureScript и нет в TypeScript, Python, Go, Elixir. Обходные пути дороги:
- fp-ts эмулирует HKT через реестр URI и declaration merging. Работает, но ошибки типов становятся многострочными и нечитаемыми, а скорость проверки типов на больших проектах заметно падает.
- Arrow (Kotlin) прошёл этот путь и отказался: в версии 1.0 эмуляцию HKT (
Kind<F, A>) убрали, заменив конкретными типами и контекст-рецейверами — команда прямо признала, что цена в читаемости и в сообщениях компилятора не окупилась (arrow-kt.io). - В Elixir и Python остаётся честный путь: писать
map/apотдельно под каждый тип. Дублирование кода в обмен на понятность — часто правильный размен.
5. Кривая обучения и цена командного решения. Разработчик, впервые увидевший mkUser <$> a <*> b <*> c, не прочитает эту строку. Ему нужно объяснить каррирование, потом функтор, потом ap. Это часы, а не минуты, и умножьте на размер команды. Плюс отладка: стек-трейс сквозь пять слоёв map и ap показывает внутренности библиотеки, а не ваш код. Разумная стратегия — вводить абстракции по одной, под конкретную боль, и останавливаться там, где выгода перестала быть очевидной.
6. Где аппликативы просто не нужны. Если у вас две проверки и обе всё равно закорачиваются — обычный if понятнее. Если валидация делается на границе один раз — библиотека вроде zod или Ecto решит задачу без единого слова «аппликатив». Абстракция окупается, когда одна и та же форма вычисления повторяется в десятках мест.
Типичные ошибки
Ждать накопления ошибок от Either. Самый частый баг. Either/Result закорачивается по определению; чтобы собрать все ошибки, нужен Validation с Semigroup на ошибках. В fp-ts это буквально разные модули (Either и Validation через getApplicativeValidation), и выбор не тот — молча теряете ошибки.
Вложенные контейнеры вместо сплющивания. Option<Option<A>>, Promise<Either<E, Promise<A>>> — признак того, что вы применили map там, где нужен был flatMap. Это сигнал: задача монадическая, читайте следующую статью.
Эффекты внутри map. Логирование, счётчики, отправка метрик внутри функции, переданной в map, ломают законы и превращают ленивые/параллельные реализации в источник гейзенбагов.
Promise.all там, где нужен allSettled. Promise.all — аппликатив с закорачиванием: первая упавшая задача отменяет ожидание остальных (хотя сами задачи продолжают работать — это отдельный источник утечек). Если нужно «все ответы и все ошибки» — только allSettled.
Разворачивание контейнера ради удобства. getOrThrow(), unwrap(), ! в середине конвейера сводят на нет весь смысл. Контейнер должен разворачиваться один раз — на границе системы, где формируется HTTP-ответ или пишется лог.
Попытка сделать Validation монадой. Периодически кто-то добавляет flatMap к Validation, чтобы «было как у всех». Он не будет согласован с ap, и поведение начнёт зависеть от того, какой метод вызвали.
Теперь можно и про теорию категорий
Всё вышесказанное работает без единого слова из теории категорий. Но связь есть, и она красивая.
В теории категорий функтор — это отображение между категориями, которое переводит объекты в объекты, стрелки в стрелки и сохраняет структуру: тождественные стрелки остаются тождественными, композиция остаётся композицией. Узнаёте? Это буквально наши два закона, записанные словами.
Функторы из программирования — это эндофункторы категории типов: объекты (типы) отображаются в объекты (A → F<A>), стрелки (функции A → B) — в стрелки (F<A> → F<B>) через map.
Аппликативные функторы соответствуют слабым моноидальным функторам (lax monoidal functors): наличие pure и ap эквивалентно паре операций unit : F<1> и zip : (F<A>, F<B>) → F<(A, B)>. Вторая формулировка часто удобнее в языках без каррирования — из неё легко получить map2, и никакой лестницы ap(ap(ap(...))) не нужно:
const map2 = <E, A, B, C>(
fa: Validation<E, A>,
fb: Validation<E, B>,
f: (a: A, b: B) => C,
): Validation<E, C> => ap(map(fa, (a) => (b: B) => f(a, b)), fb);
Историческая справка: понятие аппликативного функтора для программирования ввели Конор Макбрайд и Росс Патерсон в 2008 году в статье «Applicative Programming with Effects» — она удивительно читаемая и написана как раз в жанре «сначала задача, потом абстракция». Функторы к тому моменту жили в Haskell уже больше десяти лет, аппликативы пришли позже — потому что практика показала: между функтором и монадой не пустота, а очень полезная промежуточная ступень.
Если хочется формального фундамента — категорная база разбирается в курсе Математика, а обзорный взгляд на парадигму целиком — в Функциональное программирование.
Мини-итог
- Функтор — тип с
map : (A → B) → F<A> → F<B>, который не меняет форму контейнера. Решает задачу «применить обычную функцию к значению внутри контекста, не переписывая обвязку каждый раз». - Законы функтора (тождество и композиция) — не формальность: они разрешают fusion-оптимизации и запрещают эффекты внутри
map. mapломается на функциях нескольких аргументов — получается вложенность. Аппликатив решает это через каррирование плюсap : F<A → B> → F<A> → F<B>.- Главная практическая ценность аппликатива — независимые вычисления: параллельный запуск и накопление всех ошибок сразу.
Validationдля форм — канонический пример. traverse— самая полезная производная: превращаетList<F<A>>вF<List<A>>и покрывает большинство реальных задач.- Аппликатив слабее монады, и в этом его сила: не позволяя следующему шагу зависеть от предыдущего, он даёт свободу для параллелизма и батчинга.
- Цена реальна: аллокации, отсутствие HKT в мейнстримных языках,
O(n²)в наивномtraverse, стек-трейсы сквозь абстракции, часы на обучение команды.
Что почитать
- Conor McBride, Ross Paterson. Applicative Programming with Effects — первоисточник.
- Typeclassopedia — карта всех классов от
FunctorдоComonadс законами и примерами. - Scott Wlaschin. The Elevated World — лучшее объяснение
map/ap/bindбез жаргона, на F#. - Документация Data.Functor и Control.Applicative.
- fp-ts для TypeScript, returns для Python, Arrow для Kotlin.
- Simon Marlow et al. There is no Fork — аппликативы в проде Facebook.
Что дальше
Мы уперлись в стену ровно один раз: когда функция, переданная в map, сама вернула контейнер и мы получили F<F<A>>. Аппликатив эту проблему обошёл — за счёт требования, чтобы аргументы были независимы. Но огромный класс задач такого требования не выдерживает: «найди пользователя, потом по его роли выбери политику, потом по политике загрузи данные». Здесь каждый следующий шаг существует только после предыдущего.
Абстракция, которая честно решает эту задачу, — монада. И, как выяснится, ничего страшного в ней нет: это тот же функтор плюс одна операция сплющивания.