Функциональное программирование Монады без страха: что это на самом деле и зачем
0%

Монады без страха: что это на самом деле и зачем

Монады без страха: что это на самом деле и зачем

Про монады написано столько плохих объяснений, что появился фольклор: «монада — это буррито», «монада — это моноид в категории эндофункторов, в чём проблема?». Обе фразы бесполезны для человека, который пришёл писать код.

Эта статья построена наоборот. Сначала мы напишем обычный код, упрёмся в конкретную повторяющуюся боль, вручную вынесем повторение в функцию — и обнаружим, что только что изобрели монаду. Слово появится ближе к середине, законы — ещё позже, теория категорий — в самом конце и одним абзацем. Обзорный взгляд на парадигму есть в статье Функциональное программирование; здесь — глубина.

Три лестницы, которые вы уже писали

Лестница первая: значения, которых может не быть

# Найти город руководителя сотрудника. Любое звено может отсутствовать.
def manager_city(emp_id):
    emp = find_employee(emp_id)
    if emp is None:
        return None
    mgr = find_employee(emp.manager_id)
    if mgr is None:
        return None
    addr = mgr.address
    if addr is None:
        return None
    return addr.city

Полезной логики здесь три строки. Остальное — ритуал проверки на None. И этот ритуал нельзя забыть: пропустил одну проверку — получил AttributeError в проде.

Лестница вторая: ошибки, у которых есть причина

// Регистрация пользователя: каждый шаг может провалиться со своей причиной.
function register(raw: unknown): Result<User, string> {
  const parsed = parseJson(raw);
  if (!parsed.ok) return parsed;
  const validated = validate(parsed.value);
  if (!validated.ok) return validated;
  const saved = saveToDb(validated.value);
  if (!saved.ok) return saved;
  const notified = sendEmail(saved.value);
  if (!notified.ok) return notified;
  return notified;
}

Та же форма: шаг — проверка — досрочный выход. Такой же код на Go пишется через if err != nil { return nil, err }, и это самая цитируемая претензия к языку.

Лестница третья: асинхронность

// До промисов это выглядело так — и называлось "callback hell".
getUser(id, (err, user) => {
  if (err) return done(err);
  getOrders(user, (err, orders) => {
    if (err) return done(err);
    getInvoices(orders, (err, invoices) => {
      if (err) return done(err);
      done(null, invoices);
    });
  });
});

Три разные задачи — отсутствие значения, ошибка с причиной, отложенный результат. Три разных «ада»: ад проверок на null, ад проверок на err, ад колбэков. Но приглядитесь к форме — она у всех одна.

Что общего: функция возвращает не то, что нужно следующей

Обычная композиция функций (о ней подробно в статье Композиция, каррирование, частичное применение) работает, когда типы стыкуются:

f : A -> B
g : B -> C
=> g . f : A -> C     # стыкуется, композиция очевидна

Но в наших трёх примерах функции устроены иначе:

findEmployee : Id -> Option<Employee>       # может не вернуть
validate     : Raw -> Result<User, Error>   # может вернуть ошибку
getOrders    : User -> Promise<Order[]>     # вернёт когда-нибудь
choices      : Node -> Array<Node>          # вернёт много вариантов

Каждая возвращает значение в контейнере: A -> M<B>. Следующая функция ждёт на входе голое B, а не M<B>. Типы не стыкуются, и весь бойлерплейт — это ручная работа по распаковке контейнера, чтобы дотянуться до значения внутри.

Такие функции называют клейслиевыми стрелками (Kleisli arrows), но запоминать термин не обязательно. Важно другое: во всех четырёх случаях контейнер разный, а работа по стыковке — одинаковая. Значит, её можно вынести один раз.

Выносим повторение руками

Возьмём первую лестницу и напишем ровно одну вспомогательную функцию — «применить функцию к содержимому, если содержимое есть»:

def bind(value, fn):
    """value: T | None; fn: T -> U | None; результат: U | None"""
    return None if value is None else fn(value)

def manager_city(emp_id):
    return bind(
        bind(
            bind(find_employee(emp_id), lambda e: find_employee(e.manager_id)),
            lambda m: m.address),
        lambda a: a.city)

Читается пока хуже оригинала — к этому вернёмся, когда дойдём до синтаксического сахара. Но принципиально важно: все проверки на None теперь живут в одном месте. Забыть их нельзя, потому что писать их больше негде.

Тот же приём на TypeScript, только с настоящим типом вместо null:

type Option<T> = { kind: "some"; value: T } | { kind: "none" };

const some = <T,>(value: T): Option<T> => ({ kind: "some", value });
const none: Option<never> = { kind: "none" };

// flatMap = bind: разворачивает контейнер и подставляет значение в fn
function flatMap<T, U>(opt: Option<T>, fn: (t: T) => Option<U>): Option<U> {
  return opt.kind === "none" ? none : fn(opt.value);
}

const managerCity = (id: Id): Option<string> =>
  flatMap(findEmployee(id), (emp) =>
  flatMap(findEmployee(emp.managerId), (mgr) =>
  flatMap(mgr.address, (addr) => some(addr.city))));

Теперь то же самое для второй лестницы — контейнер другой, а функция подозрительно похожа:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

function flatMapR<T, U, E>(r: Result<T, E>, fn: (t: T) => Result<U, E>): Result<U, E> {
  return r.ok ? fn(r.value) : r;   // ошибка проезжает мимо всех оставшихся шагов
}

И для третьей — она уже встроена в язык:

// promise.then(fn), где fn возвращает промис, — это и есть flatMap
getUser(id)
  .then(user => getOrders(user))
  .then(orders => getInvoices(orders));

И для четвёртой — тоже встроена:

// Array.prototype.flatMap: fn возвращает массив, результаты склеиваются
[1, 2, 3].flatMap(n => [n, n * 10]);   // [1, 10, 2, 20, 3, 30]

Четыре контейнера, одна и та же операция под четырьмя именами: bind, flatMap, then, chain. Вот это совпадение и есть содержание слова «монада».

Определение, которое наконец можно дать

Монада — это тип-контейнер M, для которого определены две операции:

Операция Тип Что делает Имена в дикой природе
of A -> M<A> заворачивает обычное значение в контейнер return, pure, unit, Promise.resolve, Some, Ok
flatMap M<A> -> (A -> M<B>) -> M<B> продолжает вычисление, не создавая вложенности bind, >>=, then, chain, SelectMany

Всё. Никакой философии: монада — это интерфейс для последовательного соединения вычислений, которые возвращают значение в обёртке. Обёртка при этом сама решает, что значит «последовательно»: для Option — «прерваться, если пусто», для Result — «прерваться с причиной», для Promise — «дождаться», для списка — «перебрать все комбинации».

Каноническая запись на Haskell, где это оформлено как класс типов:

class Applicative m => Monad m where
  return :: a -> m a
  (>>=)  :: m a -> (a -> m b) -> m b

-- Maybe: короткое замыкание на Nothing
instance Monad Maybe where
  Nothing >>= _ = Nothing
  Just x  >>= f = f x

-- Список: перебор всех комбинаций
instance Monad [] where
  xs >>= f = concat (map f xs)

Обратите внимание на строчку class Applicative m => Monad m — монада не появляется на пустом месте, она усиливает аппликатив, а тот усиливает функтор. Про эту лестницу — предыдущая статья Функторы и аппликативы и следующая Карта функторов.

Почему map недостаточно

Резонный вопрос: функтор уже умеет применять функцию внутрь контейнера. Зачем ещё одна операция?

Потому что map рассчитан на функцию A -> B. А наши функции возвращают A -> M<B>. Подставьте одно в другое и посчитайте типы:

const opt: Option<Employee> = findEmployee(1);
const r = map(opt, (emp) => findEmployee(emp.managerId));
//    r : Option<Option<Employee>>   — контейнер в контейнере

Каждый следующий шаг добавляет ещё один слой. Десять шагов — Option<Option<...>> глубиной десять. Работать с этим невозможно.

map порождает вложенный контейнер, flatMap склеивает слои

Отсюда второе, эквивалентное определение монады: функтор, у которого есть join — операция M<M<A>> -> M<A>, умеющая схлопывать двойную обёртку. Одно выражается через другое:

flatMap m f = join (map f m)
join mm     = flatMap mm id

Именно поэтому Promise.all(...).then(...) не выдаёт вам Promise<Promise<T>>, а [[1,2],[3]].flat() существует как отдельный метод. Это join под другими именами.

И вот главное следствие, ради которого всё затевалось: монада даёт зависимость следующего шага от результата предыдущего. Аппликатив умеет комбинировать независимые контейнеры (провалидировать пять полей формы разом), но не умеет сказать «если пользователь оказался админом — сходи ещё вот в этот сервис». Монада умеет: функция внутри flatMap видит распакованное значение и сама решает, каким будет следующий контейнер. Цена — потеря параллелизма: шаги строго последовательны.

Как контейнер задаёт «смысл последовательности»

Один и тот же flatMap в разных монадах реализует принципиально разное поведение. Вот Result — здесь flatMap реализует короткое замыкание:

Проверок if в коде не будет ни одной — вся развилка спрятана внутри flatMap. Первая же ошибка «проезжает» до конца, не выполняя оставшиеся шаги. Это то, что в императивном коде делают исключения, только без нелокального прыжка и с ошибкой, видимой в типе.

А вот список — там flatMap означает «перебери все варианты», и цепочка из трёх шагов превращается в тройной вложенный цикл:

-- Все пифагоровы тройки со сторонами до n
triples n = do
  a <- [1 .. n]
  b <- [a .. n]
  c <- [b .. n]
  guard (a*a + b*b == c*c)
  return (a, b, c)

Один и тот же синтаксис, одна и та же абстракция — а семантика «перебор с отсевом». Это и объясняет, почему монады кажутся мистикой: слово одно, а поведение задаётся конкретным контейнером.

Синтаксический сахар: как избавиться от лапши из flatMap

Вложенные flatMap с лямбдами читаются плохо. Поэтому языки дают сахар, который разворачивается ровно в эти вызовы.

Haskell — do-нотация. Работает для любой монады:

managerCity :: Id -> Maybe String
managerCity eid = do
  emp  <- findEmployee eid
  mgr  <- findEmployee (managerId emp)
  addr <- address mgr
  return (city addr)

Это буквально сокращение для findEmployee eid >>= \emp -> .... Никакой магии, чисто синтаксис.

Elixir — with. Не универсальный механизм, а специальная форма под сопоставление с образцом, но решает ту же задачу:

def register(raw) do
  with {:ok, parsed}    <- parse_json(raw),
       {:ok, validated} <- validate(parsed),
       {:ok, user}      <- save_to_db(validated),
       :ok              <- send_email(user) do
    {:ok, user}
  else
    # сюда попадает первое значение, не совпавшее с образцом
    {:error, :invalid_json} -> {:error, "тело запроса не JSON"}
    {:error, reason}        -> {:error, reason}
  end
end

Здесь хорошо видна честная граница: with — это «монадический синтаксис для конкретного набора образцов», а не абстракция над произвольным контейнером. Подробнее об идиомах языка — в курсе Elixir.

TypeScript/JavaScript — async/await. Мало кто это замечает, но await — это do-нотация, зашитая в язык для ровно одной монады:

async function register(raw: unknown): Promise<User> {
  const parsed = await parseJson(raw);      // = flatMap: распаковали Promise
  const validated = await validate(parsed);
  const user = await saveToDb(validated);
  await sendEmail(user);
  return user;                              // = of: завернули обратно
}

Отсюда важный вывод: вы уже писали монадический код каждый день. async/await показывает, насколько абстракция удобна, когда у неё есть синтаксис. И насколько она бесполезна, когда синтаксис прибит к одному типу: для Option или Result await не работает, приходится опять писать лестницу.

Python — генераторы как do-нотация. Питон не даёт универсального сахара, но yield неплохо его имитирует:

from functools import reduce

def do(gen_fn):
    """Разворачивает генератор, где yield отдаёт монадическое значение."""
    def run(*args):
        gen = gen_fn(*args)

        def step(value):
            try:
                m = gen.send(value)          # получили следующий контейнер
            except StopIteration as stop:
                return Ok(stop.value)        # of(...)
            return m.flat_map(step)          # flatMap(...)

        return step(None)
    return run

@do
def register(raw):
    parsed = yield parse_json(raw)
    validated = yield validate(parsed)
    user = yield save_to_db(validated)
    yield send_email(user)
    return user

Работает, но честно скажем: в продовом Python это выглядит чужеродно, ломает type checker и отладку. Ниже, в разделе про цену, вернёмся к вопросу, когда так делать не надо.

C# — LINQ. SelectMany — это flatMap, а from x in ... — do-нотация. Query-синтаксис изначально проектировался как монадический комбинатор, просто без произнесения слова вслух. Подробности — в курсе C#.

Законы монад: зачем и что ломается без них

Чтобы flatMap вёл себя предсказуемо, он обязан подчиняться трём законам. Это не формальность — это гарантии, на которые опирается рефакторинг.

-- 1. Левая единица: обернуть и сразу развернуть = ничего не делать
return a >>= f          f a

-- 2. Правая единица: развернуть и сразу обернуть = ничего не делать
m >>= return            m

-- 3. Ассоциативность: группировка шагов не влияет на результат
(m >>= f) >>= g         m >>= (\x -> f x >>= g)

Практический смысл каждого:

  1. Левая единица разрешает инлайнить: если вы завернули значение в контейнер только чтобы тут же передать в flatMap, обёртку можно выкинуть.
  2. Правая единица разрешает удалять «пустые» хвосты цепочки — .then(x => Promise.resolve(x)) не должен ничего менять.
  3. Ассоциативность разрешает вытаскивать куски цепочки в отдельные функции. Именно она делает возможным рефакторинг «выделить три шага пайплайна в processUser» без изменения поведения.

Нарушение закона — это не абстрактная беда. Канонический пример — Promise в JavaScript, который почти монада, но не совсем:

// Promise автоматически "разворачивает" thenable — и ломает левую единицу
const p = Promise.resolve(Promise.resolve(42));
// p — это Promise<number>, а НЕ Promise<Promise<number>>

// Следствие: значение-промис невозможно положить в промис как данные
const inner = Promise.resolve(1);
Promise.resolve(inner).then(x => console.log(x === inner));  // false, x === 1

Для 99% кода это удобство. Но если вы пишете обобщённую библиотеку, которая работает с любым M<T>, Promise в неё не влезает: M<M<T>> для него не существует как тип. Ещё один прикладной пример — Optional в Java: flatMap законен, но Optional.of(null) бросает исключение вместо возврата пустого значения, поэтому of приходится заменять на ofNullable и помнить об этом руками.

Мораль: законы — это контракт, который позволяет писать код против интерфейса, а не против конкретного типа. Тема родственна принципу подстановки Лисков — см. курс Принципы разработки.

Зоопарк монад: не только про ошибки

Самое вредное заблуждение — «монада нужна для эффектов и ошибок». Монада — это про соединение вычислений, а какой именно смысл добавляется — дело контейнера.

Разберём три штуки, которые редко попадают в туториалы, но объясняют суть лучше, чем Maybe.

Reader — «вычисление, которому нужен конфиг». Это просто функция от окружения, но с монадическим интерфейсом:

newtype Reader r a = Reader { runReader :: r -> a }

instance Monad (Reader r) where
  return a       = Reader (\_ -> a)
  Reader ra >>= f = Reader (\r -> runReader (f (ra r)) r)
  -- смысл: протащить одно и то же r во все шаги, не передавая его явно

То, что в ООП делают dependency injection и контейнеры, здесь делает тип. Никакой рефлексии — конфиг протаскивается автоматически, потому что flatMap знает, как его размножить.

Writer — «вычисление, попутно накапливающее лог». Требует, чтобы у лога была операция склейки и пустое значение, то есть моноид:

type Writer<W, A> = { value: A; log: W[] };

const ofW = <W, A>(value: A): Writer<W, A> => ({ value, log: [] });

const flatMapW = <W, A, B>(
  w: Writer<W, A>,
  fn: (a: A) => Writer<W, B>,
): Writer<W, B> => {
  const next = fn(w.value);
  return { value: next.value, log: [...w.log, ...next.log] };  // склейка логов
};

State — «вычисление, протаскивающее изменяемое состояние сквозь чистые функции». Самый показательный пример: состояние есть, мутаций нет.

// Состояние — не поле объекта, а часть типа вычисления
type State<S, A> = (s: S) => [A, S];

const ofS = <S, A>(a: A): State<S, A> => (s) => [a, s];

const flatMapS = <S, A, B>(
  m: State<S, A>,
  fn: (a: A) => State<S, B>,
): State<S, B> => (s) => {
  const [a, s1] = m(s);      // выполнили первый шаг, получили новое состояние
  return fn(a)(s1);          // передали его во второй
};

// Генератор уникальных id без единой мутации и без глобальной переменной
const freshId: State<number, number> = (n) => [n, n + 1];

const threeIds: State<number, number[]> =
  flatMapS(freshId, (a) =>
  flatMapS(freshId, (b) =>
  flatMapS(freshId, (c) => ofS([a, b, c]))));

threeIds(100);   // [[100, 101, 102], 103]

Прочитайте определение flatMapS ещё раз: вся «протяжка состояния» — это одна строка, которая передаёт s1 дальше. Именно её вы пишете руками в каждой императивной функции, просто не замечаете.

Parser — комбинаторный разбор. Парсер — это String -> Maybe (A, String), то есть State поверх Maybe. И flatMap для него означает «разобрать это, а потом, зная результат, решить, что разбирать дальше» — то есть контекстно-зависимую грамматику практически бесплатно. На этом построены megaparsec, nom, parsy и половина реальных DSL-парсеров.

Цена: где монады мешают

Правило трека — говорить честно. У монад есть счёт, и он немаленький.

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

Каждый шаг цепочки — это как минимум одна аллокация обёртки и один косвенный вызов через замыкание. Для Option в горячем цикле на 10 миллионов элементов разница с обычным if может достигать разов, а не процентов: JIT неплохо инлайнит короткие цепочки, но замыкания в flatMap мешают escape-анализу, и обёртки начинают реально попадать в кучу.

Что с этим делают на практике:

  • Специализация. В Rust Option/Result — обычные enum-ы, оптимизация ниши схлопывает Option<&T> до размера указателя, а ? разворачивается в match без аллокаций. Это пример монадического интерфейса с нулевой стоимостью.
  • Трансформация цепочек. В Haskell правила перезаписи (RULES) и слияние потоков (stream fusion) уничтожают промежуточные структуры на этапе компиляции.
  • Границы применения. В JVM/.NET/Node разумно держать монадический стиль на уровне бизнес-логики и не тащить его в горячие циклы обработки массивов. Про измерение вместо угадывания — курс Алгоритмы.

Отдельная беда — глубина стека. Наивная реализация flatMap для State или Free-монад строит цепочку вложенных замыканий и переполняет стек на длинных последовательностях. Лечится трамплинами и хвостовыми вызовами — тема статьи Рекурсия и хвостовые вызовы.

Монады не складываются

Самая болезненная практическая проблема: композиция двух монад — не монада. Если у вас есть Result и Async, то Async<Result<T, E>> не получает flatMap автоматически. Приходится либо писать руками, либо строить трансформеры:

Стек монад-трансформеров и его цена

type App a = ExceptT AppError (StateT Session (ReaderT Config IO)) a

Это работает, но платить приходится читаемостью ошибок компилятора, необходимостью lift и тем, что перестановка слоёв меняет семантику. Отсюда движение к алгебраическим эффектам (Koka, Unison, Effekt, эффект-системы в Scala — ZIO и Cats Effect) и к прагматичному «одна монада на всё приложение»: ReaderT Config IO вместо десятиэтажного стека. Классическая аргументация — статья Michael Snoyman «The ReaderT Design Pattern» (https://www.fpcomplete.com/blog/2017/06/readert-design-pattern/).

Не во всяком языке абстракцию вообще можно выразить

Чтобы написать функцию, работающую с любой монадой, языку нужны типы высшего рода (higher-kinded types) — возможность параметризоваться по M, а не только по M<T>. Их нет в TypeScript, Python, Go, Java и C#. Последствия:

  • В TypeScript библиотеки вроде fp-ts/Effect эмулируют HKT через приёмы с индексом типов. Работает, но ошибки типов становятся нечитаемыми, а IDE подсказывает хуже.
  • В Go монадический стиль практически невозможен: нет ни дженериков по конструктору типа, ни перегрузки, ни сахара. Идиоматичный Go честно пишет if err != nil, и это правильный выбор для языка — см. курс Go.
  • В Python аннотации не выдерживают такой нагрузки, а генераторный do ломает трассировки исключений.

Вывод: в языках без HKT берите конкретные монады (Option, Result, Promise) и не пытайтесь строить обобщённую иерархию. Пользы от Kind<F, A> в бизнес-коде обычно меньше, чем вреда от непонятного стектрейса.

Кривая обучения и командная цена

Монадический код читается легко только теми, кто уже знает узор. Для остальных цепочка flatMap — шум. На практике это означает:

  • Незнакомый с ФП разработчик не сможет уверенно править ваш пайплайн — и не станет: он напишет рядом императивный кусок.
  • Отладка деградирует: точки останова внутри лямбд, стектрейсы из внутренностей библиотеки, «где вообще произошла ошибка» вместо строки с исключением.
  • Появляется «монадный туризм» — соблазн заворачивать в монаду всё подряд, включая места, где if был бы яснее.

Разумный компромисс, который выдерживает продовые команды: Result и Option — да, почти всегда; Reader/Writer/State — по необходимости и с пояснением в README; трансформеры — только если команда осознанно на них подписалась. Архитектурная рамка для такого разделения — «функциональное ядро, императивная оболочка», о ней в статье Архитектура на ФП.

Типичные ошибки

  • Путать map и flatMap. Симптом — Option<Option<T>> или Promise<Promise<T>> в типах. Правило: возвращает ли ваша функция контейнер? Да — flatMap, нет — map.
  • Считать монаду синонимом эффекта. Список и Reader никаких эффектов не выполняют. Монада — про соединение, а не про грязь.
  • Использовать монаду там, где нужен аппликатив. Если шаги независимы (валидация формы, параллельные запросы), монада навяжет последовательность и остановится на первой ошибке. Для сбора всех ошибок нужен аппликатив — см. Обработка ошибок в ФП.
  • Смешивать монады в одной цепочке. Option и Result не стыкуются напрямую: нужен явный переход (okOr, Maybe.toEither) с указанием, какой ошибкой считать пустоту.
  • Прятать исключения внутрь монады, оставляя их выброс. Если функция возвращает Result, но при этом может бросить — вы получили худшее из двух миров: два канала ошибок вместо одного.
  • Начинать объяснение с теории категорий. Проверено: это отталкивает даже мотивированных людей.

Связь с теорией категорий — на десерт

Теперь, когда интуиция есть, знаменитая фраза становится безобидной. В теории категорий монада — это эндофунктор T (отображение категории в себя, у нас — «тип в тип, обёрнутый в контейнер») с двумя естественными преобразованиями: unit : A -> T A (это of) и join : T (T A) -> T A (это схлопывание слоёв), удовлетворяющими законам единицы и ассоциативности — ровно тем трём, которые мы уже разобрали. «Моноид в категории эндофункторов» — это указание, что join играет роль операции моноида, а unit — нейтрального элемента.

То есть теория категорий не добавляет к практике ничего нового — она объясняет, почему узор один и тот же в четырёх непохожих задачах. Если хочется формальной базы — раздел про алгебраические структуры в курсе Математика и вычислительные основы в курсе Лямбда-исчисление. Каноническое чтение: Bartosz Milewski, «Category Theory for Programmers» (https://bartoszmilewski.com/2014/10/28/category-theory-for-programmers-the-preface/) и Philip Wadler, «Monads for functional programming» (https://homepages.inf.ed.ac.uk/wadler/papers/marktoberdorf/baastad.pdf) — вторая работа как раз та, с которой монады пришли в практическое программирование.

Мини-итог

  • Монада отвечает на один вопрос: как соединить функции вида A -> M<B>, не утонув в проверках и вложенности.
  • Формально это of : A -> M<A> плюс flatMap : M<A> -> (A -> M<B>) -> M<B>, подчиняющиеся трём законам.
  • map даёт M<M<B>>; flatMap — это map плюс join, снимающий лишний слой.
  • Смысл «последовательности» задаёт контейнер: обрыв на пустоте, обрыв с ошибкой, ожидание, перебор, протяжка состояния.
  • async/await, LINQ, with в Elixir, ? в Rust — это монады с синтаксическим сахаром, просто без вывески.
  • Монада даёт зависимость шага от предыдущего результата, но платит за это последовательностью — независимые шаги дешевле делать аппликативом.
  • Цена реальна: аллокации, отсутствие композиции монад, невыразимость в языках без HKT, порог входа в команде. Берите Option и Result смело, а трансформеры — осознанно.

Что дальше

Мы разобрали одну абстракцию вглубь. Теперь пора увидеть всю картину: где монада стоит относительно полугруппы, моноида, функтора, аппликатива, обходимого (Traversable) и комонады, какие законы наследуются и зачем вообще нужна эта иерархия.

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

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

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

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

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