Монады без страха: что это на самом деле и зачем
Про монады написано столько плохих объяснений, что появился фольклор: «монада — это буррито», «монада — это моноид в категории эндофункторов, в чём проблема?». Обе фразы бесполезны для человека, который пришёл писать код.
Эта статья построена наоборот. Сначала мы напишем обычный код, упрёмся в конкретную повторяющуюся боль, вручную вынесем повторение в функцию — и обнаружим, что только что изобрели монаду. Слово появится ближе к середине, законы — ещё позже, теория категорий — в самом конце и одним абзацем. Обзорный взгляд на парадигму есть в статье Функциональное программирование; здесь — глубина.
Три лестницы, которые вы уже писали
Лестница первая: значения, которых может не быть
# Найти город руководителя сотрудника. Любое звено может отсутствовать.
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<...>> глубиной десять. Работать с этим невозможно.
Отсюда второе, эквивалентное определение монады: функтор, у которого есть 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)
Практический смысл каждого:
- Левая единица разрешает инлайнить: если вы завернули значение в контейнер только чтобы тут же передать в
flatMap, обёртку можно выкинуть. - Правая единица разрешает удалять «пустые» хвосты цепочки —
.then(x => Promise.resolve(x))не должен ничего менять. - Ассоциативность разрешает вытаскивать куски цепочки в отдельные функции. Именно она делает возможным рефакторинг «выделить три шага пайплайна в
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) и комонады, какие законы наследуются и зачем вообще нужна эта иерархия.
Карта функторов: иерархия абстракций ФП от полугруппы до комонады