Карта функторов: иерархия абстракций ФП от полугруппы до комонады
К этому моменту трека вы уже умеете пользоваться функторами и аппликативами и монадами. Но между «умею применять map и flatMap» и «понимаю, как устроен весь этот зоопарк» есть разрыв, о который спотыкается почти каждый.
Разрыв выглядит так. Вы открываете документацию fp-ts, Cats или Haskell-библиотеки и видите: Semigroup, Monoid, Functor, Contravariant, Bifunctor, Profunctor, Apply, Applicative, Selective, Monad, Alternative, Foldable, Traversable, Comonad. Пятнадцать имён, каждое со своими законами. Кажется, что это академическая коллекция, которую заставляют выучить наизусть.
На самом деле это не коллекция, а карта. Каждое имя — ответ на конкретный вопрос «что я могу делать с данными, о которых знаю ровно столько-то». Эта статья строит карту целиком: не как справочник определений, а как последовательность задач, каждая из которых требует чуть большей силы, чем предыдущая. Обзорный взгляд на парадигму в целом есть в статье Функциональное программирование; здесь — устройство её понятийного каркаса.
Что вообще такое «абстракция» в этом смысле
Прежде чем строить карту, договоримся о единице измерения. Абстракция здесь — это набор операций плюс набор законов, которым эти операции обязаны подчиняться.
Законы — не украшение. Именно они превращают имя в контракт. Если я знаю, что ваш тип — Monoid, я знаю, что могу разбить свёртку на куски, посчитать их в разных потоках и склеить результаты, не спрашивая вас ни о чём. Если я знаю, что тип — Functor, я знаю, что map(f).map(g) можно схлопнуть в один проход, и компилятор или я сам вправе так оптимизировать.
Отсюда главный принцип чтения карты, который стоит запомнить раньше всех определений:
Чем больше умеет абстракция, тем меньше типов ей удовлетворяют и тем меньше о вычислении известно снаружи. Мощность операции покупается за потерю информации.
Поэтому правило хорошего кода в ФП звучит контринтуитивно для человека из ООП: бери самую слабую абстракцию, которой хватает. Не потому что «монада — это сложно», а потому что более слабый контракт даёт больше свободы реализации: параллелизм, статический анализ, оптимизация, лучшие сообщения об ошибках.
Уровень 0: типы, которые умеют склеиваться
Боль: агрегация повторяется в каждом проекте
Вам нужно слить три источника конфигурации. Потом — просуммировать метрики с двадцати шардов. Потом — собрать все ошибки валидации формы в один список. Потом — найти максимум по логам. Каждый раз вы пишете один и тот же цикл с аккумулятором, каждый раз чуть-чуть по-другому, каждый раз с риском забыть корректно обработать пустой вход.
Что общего у всех четырёх задач? Есть бинарная операция «склеить два в одно» и она ассоциативна: неважно, склеили вы сначала первое со вторым или второе с третьим.
Semigroup
Полугруппа — тип с операцией combine: (A, A) -> A, для которой выполняется ассоциативность:
combine(combine(a, b), c) === combine(a, combine(b, c))
Всё. Одна операция, один закон.
class Semigroup a where
(<>) :: a -> a -> a
-- закон: (x <> y) <> z == x <> (y <> z)
instance Semigroup [b] where (<>) = (++)
instance Semigroup Ordering where LT <> _ = LT; GT <> _ = GT; EQ <> y = y
Экземпляр для Ordering — жемчужина, которую стоит увидеть один раз, чтобы понять пользу: он даёт сортировку по нескольким ключам бесплатно.
-- сортировка: сначала по фамилии, при равенстве по имени, при равенстве по возрасту
compareUsers a b = comparing lastName a b <> comparing firstName a b <> comparing age a b
Тот же трюк на TypeScript, без всякой библиотеки:
type Comparator<A> = (x: A, y: A) => number;
// склейка компараторов — это Semigroup на Comparator
const then =
<A>(first: Comparator<A>, second: Comparator<A>): Comparator<A> =>
(x, y) => first(x, y) || second(x, y); // 0 означает «равны, спрашивай дальше»
const byLast: Comparator<User> = (a, b) => a.lastName.localeCompare(b.lastName);
const byAge: Comparator<User> = (a, b) => a.age - b.age;
users.sort(then(byLast, byAge));
Ассоциативность здесь — не формальность: именно она позволяет писать then(a, then(b, c)) и then(then(a, b), c) не задумываясь.
Monoid: полугруппа плюс нейтральный элемент
Semigroup не умеет главного — обработать пустой вход. Чему равна сумма пустого списка метрик? Слияние нуля конфигов?
Моноид — полугруппа с элементом empty, таким что combine(empty, a) === a === combine(a, empty).
class Semigroup a => Monoid a where
mempty :: a
-- mconcat :: Monoid a => [a] -> a -- свёртка списка, корректная и для []
Практическая ценность моноида не в математической красоте, а в трёх вещах:
- Свёртка пустого списка определена — исчезает целый класс
if list.isEmpty(). - Свёртка распараллеливается: ассоциативность позволяет разрезать вход на куски произвольно, а
emptyдаёт корректное начальное значение каждому воркеру. Это буквально математическая основа фазы reduce в MapReduce и всех оконных агрегаций в стриминге — подробнее в треке data engineering. - Агрегаты комбинируются: моноиды замкнуты относительно произведения — пара моноидов сама моноид.
from dataclasses import dataclass
from functools import reduce
@dataclass(frozen=True)
class Stats:
"""Моноид статистики: считаем count/sum/min/max за один проход, шардируемо."""
count: int
total: float
lo: float
hi: float
@staticmethod
def empty() -> "Stats":
return Stats(0, 0.0, float("inf"), float("-inf"))
def combine(self, other: "Stats") -> "Stats":
return Stats(
self.count + other.count,
self.total + other.total,
min(self.lo, other.lo),
max(self.hi, other.hi),
)
def aggregate(values: list[float]) -> Stats:
# сворачиваем в любом порядке, хоть по частям в разных процессах
return reduce(
lambda acc, x: acc.combine(Stats(1, x, x, x)),
values,
Stats.empty(),
)
Сложность: O(n) по времени, O(1) по памяти на один аккумулятор. Ключевое свойство — шардируемость: aggregate(xs + ys) == aggregate(xs).combine(aggregate(ys)). Именно поэтому такой Stats можно считать на двадцати машинах и склеить в конце. Обратите внимание: float("inf") как empty для минимума — это ровно та деталь, которую в цикле с if забывают.
Типичная ошибка на этом уровне: объявить моноидом операцию, которая не ассоциативна (вычитание, деление, «взять последнее непустое, если оно валидно»), или у которой empty не нейтрален. Код будет работать на последовательной свёртке и разъедется, как только вы включите параллелизм. Это классический баг, который воспроизводится раз в неделю на проде и никогда — в тестах.
В Elixir моноиды не оформлены отдельным протоколом, но живут повсюду:
# Enum.reduce/3 с явным нейтралом — тот же моноид, только руками
Enum.reduce(shards, Stats.empty(), &Stats.combine(&1, &2))
# Map.merge/3 — моноид на картах, где конфликт разрешает функция
Map.merge(defaults, overrides, fn _k, v1, v2 -> v1 <> v2 end)
Уровень 1: типы, внутри которых есть значения
Боль: обычная функция не работает с обёрнутым значением
У вас есть parseInt: string -> number и Maybe<string>. Функция не подходит: она хочет string, а у вас «может быть строка». Писать проверку на каждый вызов — тот самый бойлерплейт, который ФП обещает убрать.
Functor
Функтор — тип-контейнер F, для которого определена операция map: (A -> B) -> F<A> -> F<B>, поднимающая обычную функцию в контекст.
Законы:
- Тождество:
map(id) === id—mapне смеет менять структуру, только содержимое. - Композиция:
map(f) ∘ map(g) === map(f ∘ g)— два прохода схлопываются в один.
Второй закон — не только элегантность, но и разрешение на оптимизацию: компилятор Haskell пользуется им в правилах переписывания, а Stream в Java и Iterator в Rust ровно за счёт него избегают промежуточных коллекций.
Ключевая мысль, которую стоит закрепить: функтор — это не «коробка». Promise не коробка, Function тем более. Функтор — это «контекст, форму которого map обязан сохранить». Array сохраняет длину и порядок, Maybe сохраняет наличие/отсутствие, Either сохраняет ветку, Promise сохраняет момент готовности.
// Функтор для функций: map — это композиция.
// F<A> = (r: R) => A, то есть «значение, зависящее от окружения R».
type Reader<R, A> = (r: R) => A;
const mapReader =
<R, A, B>(f: (a: A) => B) =>
(ra: Reader<R, A>): Reader<R, B> =>
(r) => f(ra(r));
Если этот пример кажется странным — он и должен. Он показывает, что «контейнер» был метафорой-костылём: у Reader нет никакого содержимого до тех пор, пока не подадут r.
Три соседние ветви: Contravariant, Bifunctor, Profunctor
Карта здесь ветвится в сторону, а не вверх.
Contravariant возникает, когда тип не производит A, а потребляет его. Предикат A -> boolean, сериализатор A -> string, компаратор — у них нельзя сделать map, зато можно contramap: (B -> A) -> F<A> -> F<B>.
type Predicate<A> = (a: A) => boolean;
const contramap =
<A, B>(f: (b: B) => A) =>
(pa: Predicate<A>): Predicate<B> =>
(b) => pa(f(b));
const isAdult: Predicate<number> = (age) => age >= 18;
const isAdultUser: Predicate<User> = contramap((u: User) => u.age)(isAdult);
// адаптируем не результат, а вход — стрелка развернулась
Bifunctor — контейнер с двумя параметрами, где мапить можно любую сторону: Either<E, A>, Tuple<A, B>, Result<Err, Ok>. Операция bimap: (E -> E2, A -> A2) -> F<E, A> -> F<E2, A2>. На практике это то, чем вы обогащаете ошибку контекстом, не трогая успешную ветку — тема следующей статьи об обработке ошибок.
Profunctor объединяет оба: dimap: (A2 -> A, B -> B2) -> P<A, B> -> P<A2, B2>. Классический профунктор — функция A -> B: вход адаптируется контравариантно, выход ковариантно. Это математическое имя для того, что в промышленном коде называется «адаптер» или «middleware», и на нём построены оптики (линзы и призмы) в monocle-ts и Haskell-библиотеке lens.
Уровень 2: несколько контекстов сразу
Боль: у меня два обёрнутых значения и функция от двух аргументов
Форма регистрации. Есть validateName(raw): Maybe<Name>, validateEmail(raw): Maybe<Email>, validateAge(raw): Maybe<Age> и конструктор User(name, email, age). Функтор бессилен: map умеет применять функцию одного аргумента к одному контейнеру.
Наивная попытка через map даёт вложенность:
// map(curriedUser)(maybeName) : Maybe<(e: Email) => (a: Age) => User>
// функция оказалась заперта внутри контекста — вытащить её нечем
Apply и Applicative
Нужна ровно одна недостающая операция: применить функцию, которая сама лежит в контексте, к аргументу в контексте.
class Functor f => Applicative f where
pure :: a -> f a -- положить чистое значение в контекст
(<*>) :: f (a -> b) -> f a -> f b -- «ap»: применить обёрнутую функцию
Apply — это Applicative без pure (в fp-ts и Cats они разделены, потому что некоторые типы умеют комбинировать, но не умеют создавать из ничего — например, NonEmptyList-подобные аккумуляторы или Map с фиксированными ключами).
Ключевое свойство аппликатива: эффекты независимы. Каждый шаг знает свою работу заранее, ни один не зависит от результата другого. Отсюда две сверхспособности, которых нет у монады:
- эффекты можно выполнять параллельно (
Promise.all— это в точности аппликативная композиция промисов); - эффекты можно собрать все, а не остановиться на первом.
Второе — то, ради чего аппликативы попали в промышленный код. Валидация с накоплением ошибок:
type Validated<E, A> =
| { readonly _tag: "Invalid"; readonly errors: readonly E[] }
| { readonly _tag: "Valid"; readonly value: A };
const valid = <E, A>(value: A): Validated<E, A> => ({ _tag: "Valid", value });
const invalid = <E, A>(...errors: E[]): Validated<E, A> => ({ _tag: "Invalid", errors });
// ap для Validated: если обе стороны сломаны — СКЛЕИВАЕМ ошибки (вот он, Semigroup!)
const ap = <E, A, B>(
vf: Validated<E, (a: A) => B>,
va: Validated<E, A>,
): Validated<E, B> => {
if (vf._tag === "Invalid" && va._tag === "Invalid")
return { _tag: "Invalid", errors: [...vf.errors, ...va.errors] };
if (vf._tag === "Invalid") return vf;
if (va._tag === "Invalid") return va;
return valid(vf.value(va.value));
};
const mkUser = (name: string) => (email: string) => (age: number) => ({ name, email, age });
const result = ap(ap(ap(valid(mkUser), vName), vEmail), vAge);
// Invalid → пользователь получает ВСЕ три сообщения об ошибке разом, а не по одному за сабмит
Обратите внимание на строчку, где сходятся два уровня карты: аккумулятор ошибок работает потому, что список ошибок — полугруппа. Validated — не абстрактная конструкция, а Either, у которого ap заменён на «склеивающий». Именно поэтому Validation в fp-ts и Cats не является монадой: монадический bind обязан остановиться на первой ошибке, иначе ему нечего подставить в следующий шаг.
Тот же приём на Python — библиотека returns (документация) даёт готовые контейнеры, но идея одинаково пишется руками:
from typing import Callable, Generic, TypeVar
E = TypeVar("E"); A = TypeVar("A"); B = TypeVar("B")
class Validated(Generic[E, A]):
__slots__ = ("errors", "value", "ok")
def __init__(self, ok: bool, value=None, errors=()):
self.ok, self.value, self.errors = ok, value, tuple(errors)
def ap(self, other: "Validated[E, A]") -> "Validated[E, B]":
"""self держит функцию, other — аргумент."""
if self.ok and other.ok:
return Validated(True, self.value(other.value))
# обе сломаны → накапливаем; иначе передаём сломанную дальше
return Validated(False, errors=self.errors + other.errors)
Elixir устроен иначе: без системы типов высшего рода аппликатив не выражается как абстракция, зато выражается как конкретная функция. Идиоматичный аналог накопления ошибок — Ecto.Changeset, который собирает все ошибки полей и по духу является ровно аппликативной валидацией:
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :age])
|> validate_required([:name, :email]) # ошибки не прерывают конвейер,
|> validate_format(:email, ~r/@/) # а накапливаются в changeset.errors
|> validate_number(:age, greater_than: 17)
end
Между аппликативом и монадой: Selective
Есть промежуточная ступень, которую редко упоминают, но она отлично объясняет, что именно монада добавляет к аппликативу. Selective даёт условное выполнение — выбрать одну из двух ветвей — сохранив при этом знание об обеих ветвях до запуска:
class Applicative f => Selective f where
select :: f (Either a b) -> f (a -> b) -> f b
Практический смысл: сборочные системы и планировщики задач хотят знать граф зависимостей заранее (аппликатив), но при этом уметь ветвиться (монада). Selective — компромисс. Подробности в статье Мохова и соавторов Selective Applicative Functors (ICFP 2019), выросшей из системы сборки Hadrian для GHC.
Уровень 3: следующий шаг зависит от предыдущего
Боль: аппликатива не хватает
Загрузить пользователя по id → взять из него id организации → загрузить организацию → взять её тариф. Здесь нельзя запустить всё параллельно: второй запрос не существует, пока не пришёл первый ответ.
Аппликатив требует, чтобы все эффекты были известны заранее. Здесь они не известны. Нужна операция, которая умеет посмотреть внутрь и решить, что делать дальше.
Monad
class Applicative m => Monad m where
(>>=) :: m a -> (a -> m b) -> m b -- bind / flatMap / then
Функция a -> m b — вот вся суть. Она получает распакованное значение и возвращает новый упакованный результат, то есть строит следующий кусок программы на основе прошлого.
Законы монады (в терминах композиции Клейсли f >=> g = \x -> f x >>= g):
- Левая единица:
pure a >>= f === f a - Правая единица:
m >>= pure === m - Ассоциативность:
(m >>= f) >>= g === m >>= (\x -> f x >>= g)
Третий закон — тот же самый закон ассоциативности, что и у полугруппы на уровне 0, только для стрелок вместо значений. Именно этот факт кроется за знаменитой шуткой «монада — моноид в категории эндофункторов»: карта замкнулась.
Практическое следствие законов, ради которого их стоит проверять: они гарантируют, что рефакторинг цепочки безопасен. Вынести три шага в отдельную функцию и вставить её обратно можно без изменения поведения. Если ваш самописный flatMap законам не удовлетворяет (например, Promise в JS, который автоматически «раскатывает» вложенные промисы и путает Promise<Promise<A>> с Promise<A>), рефакторинг перестаёт быть безопасным.
обычное значение?"} B -->|"да, одно"| C["map — Functor"] B -->|"да, несколько
независимых"| D["ap / zipWith — Applicative"] B -->|"да, но возвращает
тот же контекст F"| E["flatMap / bind — Monad"] D --> D1["Эффекты параллелятся,
ошибки накапливаются"] E --> E1["Шаги строго последовательны,
первая ошибка обрывает цепочку"] A --> G{"Нужно свернуть
структуру в одно значение?"} G -->|"да, без порядка"| H["fold — Foldable плюс Monoid"] G -->|"да, с эффектом
на каждом элементе"| I["traverse — Traversable"] A --> J{"Нужно значение
в фокусе и его соседи?"} J -->|"да"| K["extract / extend — Comonad"]
Alternative: выбор и провал
Ещё одна ветка, которую вы использовали, не зная имени. Alternative — это моноид на самом контексте, а не на его содержимом:
class Applicative f => Alternative f where
empty :: f a -- провал
(<|>) :: f a -> f a -> f a -- «попробуй первое, иначе второе»
Именно так устроены комбинаторные парсеры (parseNumber <|> parseString <|> parseList), fallback-цепочки конфигурации (fromEnv <|> fromFile <|> defaults) и оператор ?? в TypeScript — частный случай Alternative для nullable-типов.
Foldable и Traversable: свёртка со стороной
Боль: список результатов вместо результата от списка
Классическая ситуация: есть string[] идентификаторов и функция load(id): Promise<User>. Прямолинейный map даёт Promise<User>[] — массив промисов. А хотите вы Promise<User[]>.
Та же форма встречается везде: Either<E, A>[] → Either<E, A[]>, Maybe<A>[] → Maybe<A[]>, Validated<E, A>[] → Validated<E, A[]>. Это не три задачи, а одна.
Foldable — структура, которую можно свернуть в моноид (foldMap: (A -> M) -> F<A> -> M). Она даёт sum, length, toList, any, all для любого контейнера бесплатно.
Traversable — структура, которую можно обойти, выполняя на каждом элементе аппликативный эффект, и вывернуть контексты наизнанку:
traverse :: (Traversable t, Applicative f) => (a -> f b) -> t a -> f (t b)
sequenceA :: (Traversable t, Applicative f) => t (f a) -> f (t a)
Читается буквально: «обойди структуру t, на каждом элементе получи эффект f, верни один эффект, внутри которого целая структура». Promise.all — это sequence для Promise и массива. Ничего больше.
Самое ценное: результат traverse зависит от того, какой аппликатив вы подставите. Один и тот же обход даёт разное поведение:
// Один traverse — два режима, разница только в подставленном аппликативе
// с Either: короткое замыкание, первая ошибка обрывает обход
const traverseEither = <E, A, B>(
xs: readonly A[],
f: (a: A) => Either<E, B>,
): Either<E, B[]> => {
const out: B[] = [];
for (const x of xs) {
const r = f(x);
if (r._tag === "Left") return r; // выходим немедленно
out.push(r.right);
}
return right(out);
};
// с Validated: обходим всё, копим ошибки
const traverseValidated = <E, A, B>(
xs: readonly A[],
f: (a: A) => Validated<E, B>,
): Validated<E, B[]> => {
const out: B[] = [];
const errs: E[] = [];
for (const x of xs) {
const r = f(x);
if (r._tag === "Valid") out.push(r.value);
else errs.push(...r.errors);
}
return errs.length ? { _tag: "Invalid", errors: errs } : valid(out);
};
Сложность обоих: O(n) по времени, O(n) по памяти на результат. Это тот случай, когда абстракция окупается буквально: смена стратегии обработки ошибок в промышленном коде — это смена одного типа, а не переписывание цикла.
В Haskell обе версии — один вызов:
traverse validate xs :: Either Err [User] -- обрывается на первой ошибке
traverse validate xs :: Validation [Err] [User] -- собирает все ошибки
Python-эквивалент, идиоматичный для языка без HKT — передать «стратегию» явно:
def traverse(xs, f, *, accumulate: bool = False):
"""Обход с эффектом. accumulate выбирает аппликатив: копить ошибки или обрываться."""
out, errors = [], []
for x in xs:
res = f(x) # res: Result[E, B]
if res.ok:
out.append(res.value)
elif accumulate:
errors.extend(res.errors)
else:
return res # короткое замыкание
return Err(errors) if errors else Ok(out)
Типичная ошибка: применять traverse к очень большому или бесконечному входу. traverse строит всю результирующую структуру в памяти, а для монадических эффектов ещё и цепочку замыканий. На миллионе элементов это либо всплеск памяти, либо переполнение стека. Для потоковой обработки нужны ленивые конструкции — тема статьи о ленивости и потоках.
Комонада: тип, из которого всегда можно достать значение
Боль: соседи
Задача, где монады откровенно не помогают. Сгладить временной ряд скользящим средним. Посчитать шаг игры «Жизнь». Отрисовать список с подсветкой элемента под курсором. Общее у них: значение в каждой точке вычисляется из окрестности этой точки, а не из самого значения.
Императивно это цикл с индексами и вечными проверками границ — источник ошибок «на единицу». Хочется описать правило один раз («новое значение = среднее трёх соседей») и применить его ко всей структуре сразу.
Определение через дуальность
Комонада — это в точности монада с развёрнутыми стрелками.
class Functor w => Comonad w where
extract :: w a -> a -- дуально к pure :: a -> m a
duplicate :: w a -> w (w a) -- дуально к join :: m (m a) -> m a
extend :: (w a -> b) -> w a -> w b -- дуально к =<< :: (a -> m b) -> m a -> m b
Интуиция: монада — «значение с эффектом, из которого нельзя просто так достать содержимое»; комонада — «значение в контексте, из которого всегда можно достать содержимое, но которое нельзя создать из ничего». Maybe — монада, но не комонада: из Nothing нечего извлечь. Непустой список с фокусом — комонада, но не монада в нужном смысле.
Каноническая комонада — зиппер, структура с выделенным фокусом:
data Zipper a = Zipper [a] a [a] -- слева (в обратном порядке), фокус, справа
left, right :: Zipper a -> Zipper a
left (Zipper (l:ls) x rs) = Zipper ls l (x:rs)
left z = z -- у края остаёмся на месте
right (Zipper ls x (r:rs)) = Zipper (x:ls) r rs
right z = z
instance Functor Zipper where
fmap f (Zipper ls x rs) = Zipper (map f ls) (f x) (map f rs)
instance Comonad Zipper where
extract (Zipper _ x _) = x
-- каждую позицию заменяем на весь зиппер, сфокусированный на этой позиции
duplicate z = Zipper (tail $ iterate left z) z (tail $ iterate right z)
-- правило пишется один раз, для одной точки:
smooth :: Zipper Double -> Double
smooth z = (extract (left z) + extract z + extract (right z)) / 3
-- и применяется ко всей структуре сразу:
smoothed = extend smooth series
Вот вся идея комонады: extend — это map для функций, которым нужен не элемент, а элемент вместе с его окружением. Границы обрабатываются один раз в left/right, а не в каждом правиле.
Тот же паттерн на TypeScript, без ленивости и потому честно дорогой:
type Focused<A> = { readonly xs: readonly A[]; readonly i: number };
const extract = <A>(w: Focused<A>): A => w.xs[w.i];
const extend = <A, B>(f: (w: Focused<A>) => B, w: Focused<A>): Focused<B> => ({
xs: w.xs.map((_, i) => f({ xs: w.xs, i })), // f видит всю структуру и позицию
i: w.i,
});
const movingAvg = (w: Focused<number>): number => {
const lo = Math.max(0, w.i - 1);
const hi = Math.min(w.xs.length - 1, w.i + 1);
let s = 0;
for (let k = lo; k <= hi; k++) s += w.xs[k];
return s / (hi - lo + 1);
};
const smoothed = extend(movingAvg, { xs: series, i: 0 }).xs;
Сложность: O(n·k), где k — размер окна, память O(n). Наивная реализация с полным сканом в f деградирует до O(n²) — типичная ловушка «красиво, но не для прода».
Законы комонады (ровно зеркало монадических):
extend extract === idextract ∘ extend f === fextend f ∘ extend g === extend (f ∘ extend g)
Где комонады встречаются в реальном коде, даже когда их так не называют: клеточные автоматы и обработка изображений (свёртка — это extend), обработка сигналов, Store-комонада в архитектуре UI (Store s a = (s -> a, s) — «состояние плюс способ отрисовать любое состояние», прямой родственник паттерна с фокусом в дифференциально-обновляемых интерфейсах), фокус в редакторах структурного кода. В повседневной разработке комонада — редкий инструмент, но её стоит знать, чтобы узнавать задачу: если правило формулируется «для точки и её окрестности», вы в комонадном мире, и цикл с индексами вам врёт.
Карта целиком
combine: A, A → A"] --> MO["Monoid
плюс empty"] FU["Functor
map"] --> AY["Apply
плюс ap"] AY --> AP["Applicative
плюс pure"] AP --> SE["Selective
плюс select"] SE --> MN["Monad
плюс flatMap"] AP --> AL["Alternative
empty и orElse"] FU --> FL["Foldable
foldMap в Monoid"] FL --> TR["Traversable
traverse, sequence"] AP -.->|"нужен как параметр"| TR MO -.->|"питает накопление ошибок"| AP FU --> CM["Comonad
extract, extend"] CN["Contravariant
contramap"] --> PF["Profunctor
dimap"] FU --> PF FU --> BF["Bifunctor
bimap"] MN --> MT["MonadTrans / Free
композиция эффектов"]
Пунктирные стрелки — не наследование, а зависимости использования: Traversable требует, чтобы вы передали ему аппликатив; накопление ошибок в аппликативной валидации требует полугруппы на типе ошибок.
Сводная таблица «задача → абстракция»:
| Задача | Минимально достаточная абстракция | Что она даёт сверх предыдущей |
|---|---|---|
| Склеить два значения одного типа | Semigroup | ассоциативность → шардирование |
| То же, но вход может быть пустым | Monoid | корректный нейтральный элемент |
| Изменить содержимое, сохранив форму | Functor | подъём обычной функции в контекст |
| Адаптировать вход потребителя | Contravariant | стрелка разворачивается |
| Обработать обе стороны Either | Bifunctor | доступ к «левому» каналу |
| Соединить N независимых контекстов | Applicative | параллелизм, накопление ошибок |
| Ветвиться, сохраняя статический граф | Selective | выбор без потери анализируемости |
| Следующий шаг зависит от прошлого | Monad | доступ внутрь контекста |
| Fallback, попытка за попыткой | Alternative | моноид на самом контексте |
| Свернуть коллекцию | Foldable | универсальный fold через моноид |
| Список эффектов → эффект списка | Traversable | выворачивание контекстов |
| Значение зависит от соседей | Comonad | контекст фокуса вместо элемента |
Связь с теорией категорий: только теперь
Всё, что выше, объяснимо без единого категорного термина — и это принципиально: абстракции ФП выросли из практики программирования, а теория дала им имена и доказала законы, а не наоборот.
Тем не менее, связь настоящая и полезная. В категорном словаре:
- Категория — объекты (типы) и стрелки (функции) с ассоциативной композицией и тождественной стрелкой. Типы и функции языка образуют категорию (с оговорками про
undefinedи незавершающиеся вычисления — в Haskell её иронично называют Hask). - Функтор — отображение категории в себя, сохраняющее композицию и тождество. Ровно два закона функтора из статьи — это буквальный перевод определения.
- Естественное преобразование — функция вида
forall a. F a -> G a, работающая одинаково для любого содержимого:listToMaybe,maybeToList,Array.fromдля итератора. Полиморфность «по всемa» и есть естественность. - Монада — эндофунктор с двумя естественными преобразованиями
pureиjoin, удовлетворяющими законам моноида. Отсюда фраза «моноид в категории эндофункторов»:joinиграет рольcombine,pure— рольempty. - Комонада — то же самое в противоположной категории, где все стрелки развёрнуты.
Если хочется копать вглубь, на портале это отдельная тема: Основы теории категорий и Теория категорий в программировании; моноиды и полугруппы разбираются в Абстрактной алгебре. Каноническая книга для программистов — Category Theory for Programmers Бартоша Милевского (бесплатна онлайн). Классика для математиков — Mac Lane, «Categories for the Working Mathematician».
Важная честная оговорка: знание теории категорий не делает вас лучшим программистом автоматически. Оно даёт словарь и уверенность в законах, но не заменяет опыт проектирования. Люди годами пишут отличный код на Either и traverse, не открыв ни одной книги по категориям.
Цена всей этой конструкции
Здесь начинается часть, которую в восторженных статьях про монады обычно опускают.
Отсутствие типов высшего рода в мейнстриме
Вся иерархия держится на возможности абстрагироваться над F в F<A> — на higher-kinded types. Их поддерживают Haskell, Scala, PureScript, частично OCaml (через функторы модулей). Их нет в TypeScript, Python, Java, C#, Go, Kotlin.
Последствия конкретны:
fp-tsэмулирует HKT через трюк с «URI» и слиянием интерфейсов. Работает, но диагностика типов при ошибке превращается в стену текста, а IDE подсказывает плохо. ВEffect(наследникfp-ts) от этого частично ушли, пожертвовав общностью ради практичности.- В Python обобщённого
traverseне написать: нужны либо отдельные функции на каждый контейнер, либо динамическая диспетчеризация без гарантий типов. - В Elixir и Clojure абстракция выражается протоколами и соглашениями, а законы никем не проверяются — только тестами.
Вывод для практики: в языке без HKT берите конкретные реализации (Result, Option, Validated со своими методами), а не пытайтесь построить свою мини-Cats. Обобщение, которое компилятор не может проверить, — это документация, притворяющаяся кодом.
Производительность
Три отдельных источника накладных расходов, которые важно не путать.
- Аллокации обёрток. Каждый
Some(x),Right(x), каждый промежуточный массив послеmap— объект в куче. В горячем цикле на миллионах элементов это заметно: цепочка.map().filter().map()в JavaScript — три полных прохода и три новых массива вместо одного цикла. В Haskell от этого спасает list fusion в GHC (законы функтора дают компилятору право схлопывать проходы), в JS и Python аналога нет. Ответ — трансдьюсеры (Clojure), ленивые последовательности или обычный цикл в критическом месте. - Глубина стека и замыкания. Монадические цепочки строят вложенные замыкания. Без оптимизации хвостовых вызовов (её нет в JS-движках де-факто и в CPython) глубокая рекурсия в монаде приводит к переполнению стека — см. рекурсию и хвостовые вызовы.
- Потеря локальности. Персистентные структуры фрагментируют память, теряя преимущества кэша процессора. Разбор реальной стоимости — в статье о персистентных структурах.
Практический порядок величин: на прикладном коде (обработка HTTP-запроса, где всё равно есть поход в базу) разница теряется в шуме. На численных ядрах, парсинге гигабайтов и рендер-циклах — разница в разы, иногда на порядок. Мерьте, а не спорьте.
Кривая обучения и цена для команды
Самая недооценённая статья расходов. Код на комбинаторах читается мгновенно теми, кто знает словарь, и не читается вообще теми, кто не знает. Человек, впервые увидевший traverse (validate >=> normalize) xs, не может даже загуглить прочитанное, потому что не знает, как называется то, что он видит.
Практические выводы из опыта команд, переходивших на ФП:
- Вводите абстракции по мере надобности и по одной, с объяснением боли, которую они закрывают.
Resultокупается сразу;Traversable— когда список валидаций реально появился; свободные монады, вероятно, не окупятся никогда. - Ограничьте набор. Пятнадцать имён из этой статьи — карта территории, а не список того, что должно быть в вашем проекте. Реальный продуктовый код прекрасно живёт на
Option/Result/Validated/traverse. - Не строите свою библиотеку абстракций. Возьмите готовую и настроенную:
Effectилиfp-tsв TypeScript,returnsв Python, Cats или ZIO в Scala.
Где ФП прямо мешает
Честный список:
- Алгоритмы, требующие мутации на месте. Union-Find, сортировка на месте, хеш-таблицы с открытой адресацией, in-place DP на матрицах — функциональные версии сложнее и медленнее в разы. Решение стандартное: локальная мутация внутри чистой снаружи функции (
ST-монада в Haskell, приватныйvarв Kotlin, обычный список внутри чистой Python-функции). - Отладка. Стек-трейс через двадцать безымянных комбинаторов бесполезен. Инструменты подтянулись (Effect ведёт трассировку через файберы), но пошагово пройти цикл в отладчике до сих пор проще.
- Хардварно-близкий код. Драйверы, DSP, embedded с фиксированным бюджетом памяти: там неявные аллокации недопустимы.
- Абстракция ради абстракции. Самый частый реальный вред. Когда
Reader-монада вводится ради передачи одного конфига, который спокойно был бы аргументом функции, — это чистый убыток. Проверочный вопрос: какую конкретную боль эта абстракция убирает прямо в этом коде? Нет внятного ответа — не вводите.
Мини-итог
- Абстракции ФП — это набор операций плюс законы, и законы важнее операций: именно они дают право на рефакторинг, распараллеливание и оптимизацию.
- Иерархия строится по силе:
Semigroup→Monoid(склейка) →Functor(изменить содержимое) →Applicative(соединить независимые) →Monad(зависимые шаги). В сторону отходятContravariant/Bifunctor/Profunctor(варианты дисперсии),Foldable/Traversable(обход) иComonad(контекст фокуса). - Правило выбора: берите самую слабую абстракцию, которой хватает. Аппликатив вместо монады даёт параллелизм и накопление ошибок; монада забирает и то, и другое взамен на зависимость шагов.
traverse— самая практически ценная функция всей карты: превращает «список результатов» в «результат списка», а стратегия обработки ошибок при этом становится параметром типа.- Комонада — не экзотика, а точный инструмент для задач «значение зависит от соседей»: сглаживание, свёртки, автоматы, фокус в UI.
- Цена реальна: отсутствие HKT в мейнстриме, аллокации, глубина стека, кривая обучения и риск абстрагирования ради абстрагирования. Вводите по одной абстракции за раз, под конкретную боль.
Что дальше
Мы построили карту, но самая полезная её область заслуживает отдельного разбора: как на практике устроены Option, Either и Result, чем они отличаются от исключений, когда нужно короткое замыкание, а когда — накопление всех ошибок, и как это выглядит в реальном продакшн-коде на разных языках.
Обработка ошибок в ФП: Option, Either, Result, накопление ошибок