Функциональное программирование Карта функторов: иерархия абстракций ФП от полугруппы до комонады
0%

Карта функторов: иерархия абстракций ФП от полугруппы до комонады

Карта функторов: иерархия абстракций ФП от полугруппы до комонады

К этому моменту трека вы уже умеете пользоваться функторами и аппликативами и монадами. Но между «умею применять 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   -- свёртка списка, корректная и для []

Практическая ценность моноида не в математической красоте, а в трёх вещах:

  1. Свёртка пустого списка определена — исчезает целый класс if list.isEmpty().
  2. Свёртка распараллеливается: ассоциативность позволяет разрезать вход на куски произвольно, а empty даёт корректное начальное значение каждому воркеру. Это буквально математическая основа фазы reduce в MapReduce и всех оконных агрегаций в стриминге — подробнее в треке data engineering.
  3. Агрегаты комбинируются: моноиды замкнуты относительно произведения — пара моноидов сама моноид.
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) === idmap не смеет менять структуру, только содержимое.
  • Композиция: 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 без purefp-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>), рефакторинг перестаёт быть безопасным.

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 === id
  • extract ∘ extend f === f
  • extend f ∘ extend g === extend (f ∘ extend g)

Где комонады встречаются в реальном коде, даже когда их так не называют: клеточные автоматы и обработка изображений (свёртка — это extend), обработка сигналов, Store-комонада в архитектуре UI (Store s a = (s -> a, s) — «состояние плюс способ отрисовать любое состояние», прямой родственник паттерна с фокусом в дифференциально-обновляемых интерфейсах), фокус в редакторах структурного кода. В повседневной разработке комонада — редкий инструмент, но её стоит знать, чтобы узнавать задачу: если правило формулируется «для точки и её окрестности», вы в комонадном мире, и цикл с индексами вам врёт.

Карта целиком

Пунктирные стрелки — не наследование, а зависимости использования: 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. Обобщение, которое компилятор не может проверить, — это документация, притворяющаяся кодом.

Производительность

Три отдельных источника накладных расходов, которые важно не путать.

  1. Аллокации обёрток. Каждый Some(x), Right(x), каждый промежуточный массив после map — объект в куче. В горячем цикле на миллионах элементов это заметно: цепочка .map().filter().map() в JavaScript — три полных прохода и три новых массива вместо одного цикла. В Haskell от этого спасает list fusion в GHC (законы функтора дают компилятору право схлопывать проходы), в JS и Python аналога нет. Ответ — трансдьюсеры (Clojure), ленивые последовательности или обычный цикл в критическом месте.
  2. Глубина стека и замыкания. Монадические цепочки строят вложенные замыкания. Без оптимизации хвостовых вызовов (её нет в JS-движках де-факто и в CPython) глубокая рекурсия в монаде приводит к переполнению стека — см. рекурсию и хвостовые вызовы.
  3. Потеря локальности. Персистентные структуры фрагментируют память, теряя преимущества кэша процессора. Разбор реальной стоимости — в статье о персистентных структурах.

Практический порядок величин: на прикладном коде (обработка 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-монада вводится ради передачи одного конфига, который спокойно был бы аргументом функции, — это чистый убыток. Проверочный вопрос: какую конкретную боль эта абстракция убирает прямо в этом коде? Нет внятного ответа — не вводите.

Мини-итог

  • Абстракции ФП — это набор операций плюс законы, и законы важнее операций: именно они дают право на рефакторинг, распараллеливание и оптимизацию.
  • Иерархия строится по силе: SemigroupMonoid (склейка) → Functor (изменить содержимое) → Applicative (соединить независимые) → Monad (зависимые шаги). В сторону отходят Contravariant/Bifunctor/Profunctor (варианты дисперсии), Foldable/Traversable (обход) и Comonad (контекст фокуса).
  • Правило выбора: берите самую слабую абстракцию, которой хватает. Аппликатив вместо монады даёт параллелизм и накопление ошибок; монада забирает и то, и другое взамен на зависимость шагов.
  • traverse — самая практически ценная функция всей карты: превращает «список результатов» в «результат списка», а стратегия обработки ошибок при этом становится параметром типа.
  • Комонада — не экзотика, а точный инструмент для задач «значение зависит от соседей»: сглаживание, свёртки, автоматы, фокус в UI.
  • Цена реальна: отсутствие HKT в мейнстриме, аллокации, глубина стека, кривая обучения и риск абстрагирования ради абстрагирования. Вводите по одной абстракции за раз, под конкретную боль.

Что дальше

Мы построили карту, но самая полезная её область заслуживает отдельного разбора: как на практике устроены Option, Either и Result, чем они отличаются от исключений, когда нужно короткое замыкание, а когда — накопление всех ошибок, и как это выглядит в реальном продакшн-коде на разных языках.

Обработка ошибок в ФП: Option, Either, Result, накопление ошибок

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов