Функциональные паттерны: функторы, монады, линзы, комбинаторы
Все предыдущие статьи трека — от обзора до порождающих, структурных, поведенческие и конкурентные паттерны — говорили на одном языке: есть объекты, у объектов есть состояние и поведение, а паттерн — это способ расставить объекты так, чтобы изменение требований било по одному классу, а не по двадцати.
Функциональные паттерны выросли из другой традиции и отвечают на другой вопрос. Там, где GoF спрашивает «кто кем владеет и кто кого вызывает», функциональный подход спрашивает: «какое здесь значение и какая функция его преобразует». Объектов нет, наследования нет, изменяемого состояния в идеале тоже нет — значит, все привычные швы (подкласс, инъекция зависимости, полиморфный вызов) недоступны, и на их место приходит ровно один механизм: композиция функций.
Это не «другой стиль оформления кода». Это другая точка приложения силы. И если вы её не видите, то
монады выглядят академической эзотерикой, а если видите — оказывается, что вы уже лет десять пишете
монадический код в Promise.then, Optional.map, LINQ и Ecto.Changeset, просто без словаря.
Здесь мы разберём словарь. Не «монада — это моноид в категории эндофункторов», а: какая сила порождает паттерн, как он выглядит в коде на Python/TypeScript, что он стоит по времени и памяти, где ломается и как выглядит в проде.
Если хочется предварительно освежить саму парадигму — чистота, неизменяемость, рекурсия, ленивость — это «Функциональная парадигма» в треке парадигм. Здесь мы считаем базу известной и сразу идём в паттерны.
Смена системы координат
Функциональные паттерны опираются на три обязательства. Не «правила хорошего тона», а именно обязательства: если вы их нарушите, паттерны перестанут работать — не эстетически, а технически.
- Чистота. Функция от одних и тех же аргументов возвращает один и тот же результат и не делает ничего наблюдаемого снаружи. Следствие: функцию можно вызвать позже, дважды, в другом потоке, не вызвать вовсе — результат программы не изменится. Именно это делает композицию безопасной.
- Неизменяемость. Значение, однажды созданное, не меняется. Следствие: не нужны блокировки
(см. проблемы разделяемого состояния в паттернах конкурентности),
а «изменение» становится функцией
old -> new. - Тотальность и явность эффектов. Функция описывает все свои исходы в возвращаемом типе:
не бросает исключение, а возвращает
Result; не «может вернуть null», а возвращаетOption; не «ходит в базу», а возвращает описание эффекта.
Из этих трёх обязательств вырастает вся остальная конструкция. Обратите внимание на любопытный эффект: половина каталога GoF в функциональном мире просто исчезает — не потому, что задача исчезла, а потому, что решение схлопывается до одной языковой конструкции.
| Паттерн GoF | Что от него остаётся в ФП |
|---|---|
| Strategy | Функция-параметр. sort(items, key=len) — это Strategy без интерфейса и трёх классов |
| Command | Замыкание или дата-структура-описание действия; отмена — это лог значений |
| Template Method | Функция высшего порядка: скелет принимает шаги как аргументы |
| Observer | Поток значений (Stream, Observable), а подписка — это map/filter над ним |
| Iterator | fold/reduce и ленивые последовательности; обход перестаёт быть объектом |
| Visitor | Сопоставление с образцом по алгебраическому типу + катаморфизм (fold по дереву) |
| Decorator | Композиция функций: logged(retried(handler)) |
| Abstract Factory | Функция, возвращающая набор функций (запись замыканий) |
| Singleton | Просто значение. Оно неизменяемо — делить его безопасно |
| Chain of Responsibility | alt-комбинатор: попробовать первый, при неудаче — следующий |
А на освободившееся место приходят паттерны, которых в каталоге GoF нет вообще: функтор, аппликатив, монада, линза, комбинатор, катаморфизм. Их и разбираем.
Паттерн ноль: функция как значение
Всё начинается здесь. Если функцию можно передать, вернуть и сохранить в структуре — у вас появляется шов там, где в ООП пришлось бы заводить интерфейс.
from typing import Callable, Iterable, TypeVar
T = TypeVar("T")
# Template Method в одну строку: скелет фиксирован, шаги — параметры.
def process(
items: Iterable[T],
keep: Callable[[T], bool],
transform: Callable[[T], T],
combine: Callable[[T, T], T],
initial: T,
) -> T:
acc = initial
for item in items:
if keep(item):
acc = combine(acc, transform(item))
return acc
Три «точки вариации» — три параметра-функции. В ООП это был бы абстрактный класс с тремя защищёнными методами и по подклассу на каждое сочетание. Здесь — три аргумента и ноль новых типов.
Замыкание — это связка «функция + захваченное окружение», то есть ровно то же, что объект с одним методом. Разница только в синтаксисе и в том, что окружение неизменяемо.
def rate_limiter(max_per_minute: int) -> Callable[[str], bool]:
"""Возвращает функцию с приватным состоянием — объект без слова class."""
from collections import defaultdict
import time
hits: dict[str, list[float]] = defaultdict(list)
def allow(key: str) -> bool:
now = time.monotonic()
window = hits[key] = [t for t in hits[key] if now - t < 60.0]
if len(window) >= max_per_minute:
return False
window.append(now)
return True
return allow
Обратите внимание: это уже нечистая функция (есть состояние, есть время). Так и должно быть — прод состоит из эффектов. Функциональный подход не запрещает эффекты, он требует загонять их в известные места и держать остальное чистым.
Каррирование и частичное применение. Функция от двух аргументов эквивалентна функции, возвращающей
функцию: f(a, b) ⟺ f(a)(b). Практическая ценность — конфигурируемые куски, которые потом
композируются.
from functools import partial
def send(transport, retries: int, message: dict) -> None: ...
# Частичное применение фиксирует «политику», оставляя открытым только данные.
notify = partial(send, transport=kafka, retries=3)
notify(message={"type": "order.created"})
Каррирование понадобится нам буквально через два раздела — на нём держится аппликативная валидация.
Композиция и комбинаторы
Комбинатор — это функция, которая строит новые функции из старых и не имеет собственного состояния.
Классические комбинаторы приходят из комбинаторной логики: identity (вернуть как есть), const
(игнорировать второй аргумент), flip (поменять аргументы местами), on (применить преобразование
к обоим аргументам перед сравнением). Но в проде интереснее прикладные комбинаторы — те, что
превращают политику надёжности в значение.
import time
import random
from typing import Callable, TypeVar
R = TypeVar("R")
Handler = Callable[..., R]
def with_retry(attempts: int, base_delay: float = 0.1) -> Callable[[Handler], Handler]:
"""Комбинатор: принимает функцию, возвращает функцию с той же сигнатурой."""
def wrap(fn: Handler) -> Handler:
def wrapped(*args, **kwargs):
last: Exception | None = None
for i in range(attempts):
try:
return fn(*args, **kwargs)
except TransientError as exc: # только повторяемые ошибки!
last = exc
# экспоненциальная задержка с джиттером — иначе получим «громовое стадо»
time.sleep(base_delay * (2 ** i) * (0.5 + random.random()))
raise last # type: ignore[misc]
return wrapped
return wrap
def with_timeout(seconds: float) -> Callable[[Handler], Handler]: ...
def with_metrics(name: str) -> Callable[[Handler], Handler]: ...
def compose(*wrappers):
"""compose(a, b, c)(f) == a(b(c(f))) — порядок «снаружи внутрь»."""
def apply(fn):
for w in reversed(wrappers):
fn = w(fn)
return fn
return apply
# Политика стала значением: её можно передать, сравнить, протестировать отдельно.
resilient = compose(with_metrics("charge"), with_timeout(2.0), with_retry(3))
charge = resilient(raw_charge)
Это тот же Decorator, но без единого класса-обёртки. Разница
принципиальная в одном месте: resilient — обычное значение. Его можно положить в словарь политик,
выбрать по имени из конфига, применить к сотне обработчиков одной строкой.
Trade-off, о котором обычно молчат. Композиция функций разрушает стектрейс. Вместо
OrderService.charge вы увидите пять кадров wrapped в одном и том же файле. Лечится дисциплиной:
functools.wraps в Python, явные имена в замыканиях, аннотация каждого слоя метриками. Не лечится
совсем — в глубоко point-free коде («без упоминания аргументов»: pipe(map(f), filter(g), fold(h)))
отладка становится заметно тяжелее. Point-free стоит применять там, где выражение читается как
предложение, и не стоит — ради демонстрации мастерства.
Функтор: применить функцию внутри контекста
Теперь — центральная линия статьи. Наблюдение, из которого всё вырастает: значения редко приходят «голыми». Они приходят в контексте.
- значения может не быть →
Optional[T],Maybe - вместо значения может быть ошибка →
Result[T, E],Either - значений может быть много →
list[T],Stream[T] - значение появится позже →
Future[T],Promise<T> - значение зависит от ввода →
Parser[T] - значение зависит от окружения или меняет состояние →
Reader[Env, T],State[S, T]
Во всех случаях у вас есть функция T -> U, которая про контекст ничего не знает и знать не хочет.
Функтор — это контекст, умеющий поднять такую функцию внутрь себя.
map :: (a -> b) -> F a -> F b
Без функтора код обрастает распаковкой: if x is not None, if res.ok, for item in items. Эта
распаковка — шум: она повторяет, каков контекст, а не что мы с данными делаем. map убирает шум.
Законы функтора — это API-контракт, а не математика
1. map(identity, x) == x # сохранение тождества
2. map(f, map(g, x)) == map(lambda v: f(g(v)), x) # сохранение композиции
Звучит как формальность, но смысл сугубо инженерный: map не имеет права делать ничего, кроме как
применить функцию к содержимому. Он не меняет длину списка, не превращает ошибку в успех, не
логирует, не делает сетевой вызов. Если ваш map нарушает второй закон — значит, два map подряд
не равны одному, и любая оптимизация слияния проходов (то, чем занимаются Java Streams, LINQ,
трансдьюсеры) начнёт менять поведение программы. Законы — это то, на что имеет право опереться
компилятор, библиотека и читающий человек.
Полезное упражнение на понимание: функция сама является функтором. Для F a = (r -> a)
операция map — это обычная композиция: map(f, g) = lambda r: f(g(r)). Отсюда видно, что функтор
не про «коробочки с данными», а про «структуру, сквозь которую можно протащить преобразование».
Реализация в коде
from dataclasses import dataclass
from typing import Callable, Generic, TypeVar, Union
T = TypeVar("T"); U = TypeVar("U"); E = TypeVar("E")
@dataclass(frozen=True)
class Ok(Generic[T]):
value: T
def map(self, f: Callable[[T], U]) -> "Result[U, E]":
return Ok(f(self.value))
def bind(self, f: Callable[[T], "Result[U, E]"]) -> "Result[U, E]":
return f(self.value) # разворачиваем контекст, созданный f
def map_err(self, f): return self
def unwrap_or(self, default: T) -> T: return self.value
@dataclass(frozen=True)
class Err(Generic[E]):
error: E
def map(self, f): return self # ошибка проходит сквозь map нетронутой
def bind(self, f): return self # и сквозь bind тоже — короткое замыкание
def map_err(self, f): return Err(f(self.error))
def unwrap_or(self, default): return default
Result = Union[Ok[T], Err[E]]
Двадцать строк — и у нас есть Result, эквивалентный Result из Rust или Either из Haskell.
map — функтор. bind — уже монада, к ней перейдём после аппликатива.
Аппликатив: несколько независимых значений
Функтора хватает, пока функция одноаргументная. А если нужно собрать User из трёх проверенных
полей, каждое из которых лежит в своём контексте? map не поможет: он умеет поднять a -> b, но не
a -> b -> c.
Точнее, поможет — но результат окажется вложенным: map(curried_user, valid_name) даст
F (b -> c -> User), то есть функцию внутри контекста. Нужна операция, применяющая такую функцию
к аргументу, тоже находящемуся в контексте:
pure :: a -> F a
ap :: F (a -> b) -> F a -> F b
Это аппликативный функтор. И у него есть свойство, которого нет у монады: аргументы независимы. Никакой из них не вычисляется на основе результата другого — значит, все можно посчитать сразу, в любом порядке, параллельно, и собрать все ошибки, а не только первую.
Именно поэтому валидация форм — каноническое применение аппликатива. Пользователю нужен список всех проблем сразу, а не «сначала почините имя, потом узнаете про почту».
from dataclasses import dataclass
from typing import Any, Callable, Generic, TypeVar
A = TypeVar("A")
@dataclass(frozen=True)
class Valid(Generic[A]):
value: A
@dataclass(frozen=True)
class Invalid:
errors: tuple[str, ...]
Validation = Valid | Invalid
def ap(vf: Validation, vx: Validation) -> Validation:
"""Ключевая строка — вторая: ошибки не отбрасываются, а СКЛАДЫВАЮТСЯ."""
match vf, vx:
case Valid(f), Valid(x): return Valid(f(x))
case Invalid(e1), Invalid(e2): return Invalid(e1 + e2)
case Invalid(e), _: return Invalid(e)
case _, Invalid(e): return Invalid(e)
raise AssertionError("недостижимо")
def curry3(f: Callable[[Any, Any, Any], A]):
return lambda a: lambda b: lambda c: f(a, b, c)
# --- прикладные проверки: каждая возвращает контекст, а не бросает исключение ---
def check_name(raw: str) -> Validation:
return Valid(raw.strip()) if raw.strip() else Invalid(("имя пустое",))
def check_email(raw: str) -> Validation:
return Valid(raw) if "@" in raw else Invalid((f"email {raw!r} без @",))
def check_age(raw: str) -> Validation:
if not raw.isdigit():
return Invalid((f"возраст {raw!r} не число",))
age = int(raw)
return Valid(age) if 0 < age < 130 else Invalid((f"возраст {age} вне диапазона",))
@dataclass(frozen=True)
class User:
name: str
email: str
age: int
def make_user(name: str, email: str, age: str) -> Validation:
return ap(ap(ap(Valid(curry3(User)), check_name(name)),
check_email(email)),
check_age(age))
print(make_user("Аня", "a@example.com", "34"))
# Valid(value=User(name='Аня', email='a@example.com', age=34))
print(make_user("", "не-почта", "сто"))
# Invalid(errors=('имя пустое', "email 'не-почта' без @", "возраст 'сто' не число"))
Это и есть отличие аппликатива от монады, сформулированное практично:
- аппликатив — шаги независимы → можно накапливать ошибки, можно выполнять параллельно;
- монада — следующий шаг зависит от результата предыдущего → накопить нельзя физически (второго шага может просто не существовать), выполнение строго последовательное.
Отсюда важное продовое следствие: Promise.all — аппликативная операция (запросы независимы, летят
одновременно), а цепочка .then().then() — монадическая (каждый шаг ждёт предыдущего). Когда в code
review вы видите последовательные await для независимых запросов — это ровно ошибка «использовал
монаду там, где нужен был аппликатив», и она стоит реальных миллисекунд.
Монада: шаги, зависящие от предыдущих
Теперь самая пугающая часть словаря, которая на деле проще аппликатива. Возьмём функцию, которая
сама возвращает контекст: parse_user :: str -> Result[User]. Если применить к ней map внутри
Result, получится Result[Result[User]] — две обёртки. Нужна операция, снимающая лишний слой:
join :: F (F a) -> F a
bind :: F a -> (a -> F b) -> F b # bind = join . map
Всё. Монада — это функтор, у которого есть pure и join. Никакой мистики: она отвечает на
вопрос «как соединить в цепочку шаги, каждый из которых может провалиться / вернуть несколько
результатов / потребовать состояние — не выписывая проверку после каждого шага».
Законы монады
1. bind(pure(a), f) == f(a) # левая единица
2. bind(m, pure) == m # правая единица
3. bind(bind(m, f), g) == bind(m, lambda x: bind(f(x), g)) # ассоциативность
Практический смысл третьего закона — самый важный: группировка шагов не влияет на результат.
Именно он разрешает вам вынести три шага пайплайна в отдельную функцию и вставить её в середину
цепочки, не меняя поведение. Если закон нарушен (а его легко нарушить, добавив в bind логирование
«номера шага» или сброс контекста), то безобидный рефакторинг «выделить метод» начнёт менять
результаты. Это не гипотетика: именно так ломаются самописные «монады» с побочными эффектами внутри
bind.
Как это выглядит в реальном пайплайне
def parse_json(raw: bytes) -> Result[dict, str]:
try:
return Ok(json.loads(raw))
except ValueError as exc:
return Err(f"невалидный JSON: {exc}")
def extract_order(payload: dict) -> Result[Order, str]:
if "order_id" not in payload:
return Err("нет order_id")
return Ok(Order(id=payload["order_id"], items=payload.get("items", [])))
def check_inventory(order: Order) -> Result[Order, str]:
missing = [i for i in order.items if not stock.has(i)]
return Err(f"нет на складе: {missing}") if missing else Ok(order)
def reserve(order: Order) -> Result[Reservation, str]: ...
# Цепочка читается как список шагов. Ни одной проверки на ошибку.
def handle(raw: bytes) -> Result[Reservation, str]:
return (parse_json(raw)
.bind(extract_order)
.bind(check_inventory)
.bind(reserve)
.map_err(lambda e: f"[order-intake] {e}"))
Сравните с императивным вариантом: четыре if err is not None: return err, каждый из которых можно
забыть, и каждый из которых надо покрыть тестом. Здесь «прокидывание ошибки» — свойство типа, а не
дисциплина разработчика. Тестировать нужно шаги, а не связки.
ни один шаг не выполняется]] C -- Ok Order --> D{check_inventory} C -- Err --> Z D -- Ok Order --> E{reserve} D -- Err --> Z E -- Ok Reservation --> F[Успех - бронь создана] E -- Err --> Z Z --> G[map_err добавляет контекст
и отдаёт единый ответ] F --> H[HTTP 201] G --> I[HTTP 4xx или 5xx] classDef good fill:#3fa66b22,stroke:#3fa66b,stroke-width:2px classDef bad fill:#c9793a22,stroke:#c9793a,stroke-width:2px class F,H good class Z,G,I bad
Do-нотация: синтаксис против вложенности
У цепочек bind есть болезнь: если шагу нужен результат не предыдущего, а позапрошлого шага,
лямбды начинают вкладываться друг в друга «лесенкой». Поэтому почти каждый язык, всерьёз работающий
с монадами, вводит синтаксический сахар — и это, возможно, самая недооценённая часть темы, потому что
вы этим сахаром уже пользуетесь:
-- Haskell: do-нотация — это чистый сахар над >>=
handle raw = do
payload <- parseJson raw
order <- extractOrder payload
ok <- checkInventory order
reserve ok
// Rust: оператор ? — это bind для Result и Option, встроенный в язык
fn handle(raw: &[u8]) -> Result<Reservation, Error> {
let payload = parse_json(raw)?; // Err -> ранний возврат, Ok -> распаковка
let order = extract_order(payload)?;
let ok = check_inventory(order)?;
reserve(ok)
}
# Elixir: with — do-нотация для кортежей {:ok, _} / {:error, _}
def handle(raw) do
with {:ok, payload} <- parse_json(raw),
{:ok, order} <- extract_order(payload),
{:ok, checked} <- check_inventory(order) do
reserve(checked)
else
{:error, reason} -> {:error, "[order-intake] #{reason}"}
end
end
// C#: LINQ query syntax — это монадическая комбинация (SelectMany == bind)
var pairs = from customer in customers
from order in customer.Orders // второй from = bind
where order.Total > 1000
select new { customer.Name, order.Id };
// TypeScript: async/await — do-нотация для монады Promise
async function handle(raw: Uint8Array): Promise<Reservation> {
const payload = await parseJson(raw);
const order = await extractOrder(payload);
return reserve(await checkInventory(order));
}
Пять языков, пять синтаксисов, одна структура. await, ?, with, from...from, do — это всё
bind с человеческим лицом. Понимание этого стоит больше, чем умение написать Monad инстанс:
вы начинаете видеть, что Promise и Result — один и тот же паттерн, и переносить приёмы между ними.
Детали по языкам — в треках Elixir, TypeScript и C#.
Зоопарк монад: что чем является
| Монада | Что моделирует | Что делает bind |
|---|---|---|
Maybe/Option |
возможное отсутствие | обрывает цепочку на None |
Either/Result |
ошибка с причиной | обрывает цепочку, неся ошибку |
List |
недетерминизм, перебор вариантов | декартово произведение — это list comprehension |
Reader[Env, A] |
зависимость от конфигурации | протаскивает окружение через все шаги |
Writer[Log, A] |
накопление лога/метрик | склеивает выходные логи моноидом |
State[S, A] |
изменяемое состояние без мутаций | передаёт состояние из шага в шаг |
Future/Promise |
значение позже | связывает продолжение с завершением |
Parser |
потребление ввода | передаёт «остаток строки» дальше |
IO/Task/Effect |
описание эффекта как значения | строит план вычисления, не выполняя его |
State заслуживает отдельной иллюстрации, потому что она показывает главный трюк: изменяемое
состояние — это функция S -> (A, S), и ничего больше.
from typing import Callable, TypeVar
S = TypeVar("S"); A = TypeVar("A"); B = TypeVar("B")
State = Callable[[S], tuple[A, S]] # состояние на входе -> (результат, состояние на выходе)
def pure(a: A) -> State: return lambda s: (a, s)
def get() -> State: return lambda s: (s, s)
def put(new: S) -> State: return lambda s: (None, new)
def bind(m: State, f: Callable[[A], State]) -> State:
def run(s: S):
a, s1 = m(s) # выполнили первый шаг, получили новое состояние
return f(a)(s1) # и протащили его во второй — вот и весь «изменяемый» счётчик
return run
def fresh_id() -> State:
"""Выдать следующий id и увеличить счётчик. Мутаций нет ни одной."""
return bind(get(), lambda n: bind(put(n + 1), lambda _: pure(n)))
print(bind(fresh_id(), lambda a: bind(fresh_id(), lambda b: pure((a, b))))(10))
# ((10, 11), 12)
Читаемость на Python страдает (нет do-нотации), но конструкция видна насквозь: «состояние» — это
аккуратно передаваемый из рук в руки аргумент, а bind — механизм передачи.
Честное предупреждение: монады плохо композируются
Функторы композируются свободно: Future[List[Maybe[T]]] — тоже функтор. Монады — нет. Из
монады Result и монады Future не получается автоматически монада Future[Result[T]], и это не
недоработка библиотек, а математический факт.
Исторические ответы на эту проблему:
- Трансформеры монад (
EitherT,StateT) — механические обёртки, дающие композицию ценой тяжёлых типов и заметной потери производительности. В Scala/Haskell работают, в остальных языках выглядят пугающе. - Одна большая монада эффектов —
ZIO[R, E, A],Effectв TypeScript,IOв cats-effect. Вместо композиции монад — единый тип, где окружение, ошибка и результат зашиты в три параметра. - Алгебраические эффекты — направление, где эффект объявляется, а обработчик подставляется снаружи (OCaml 5 с его эффектами, исследовательские языки Koka и Eff).
- Прагматичный ответ, которым живёт 95 % прода: не строить башню. Одна монада на слой —
Resultвнутри домена,asyncна границе — и явная конвертация между ними в одном известном месте.
Это и есть главный практический вывод раздела. Монада — отличный инструмент, пока их одна-две. Три и больше — вы начинаете писать библиотеку вместо продукта.
Парсер-комбинаторы: паттерн, который стоит написать руками
Парсер-комбинаторы — лучшая демонстрация всего словаря сразу: парсер это функтор (map меняет
результат разбора), аппликатив (seq собирает независимые части), монада (bind позволяет
следующему парсеру зависеть от уже разобранного), а alt — это Chain of Responsibility в одну строку.
Идея: парсер — это функция str -> (значение, остаток) | неудача. Большие парсеры собираются
из маленьких комбинаторами. Никакого кодогенератора, никакого отдельного языка грамматик — только
обычные функции вашего языка.
import re
import operator
from dataclasses import dataclass
from typing import Any, Callable, Optional
@dataclass(frozen=True)
class Success:
value: Any
rest: str
Parser = Callable[[str], Optional[Success]]
def token(pattern: str) -> Parser:
"""Атом: регулярка с пропуском ведущих пробелов."""
rx = re.compile(r"\s*(" + pattern + r")")
def p(s: str) -> Optional[Success]:
m = rx.match(s)
return None if m is None else Success(m.group(1), s[m.end():])
return p
def map_p(p: Parser, f: Callable[[Any], Any]) -> Parser: # функтор
def q(s):
r = p(s)
return None if r is None else Success(f(r.value), r.rest)
return q
def seq(*ps: Parser) -> Parser: # аппликатив
def q(s):
out, rest = [], s
for p in ps:
r = p(rest)
if r is None:
return None # откат бесплатный: строку мы не трогали
out.append(r.value)
rest = r.rest
return Success(out, rest)
return q
def alt(*ps: Parser) -> Parser: # выбор с backtracking
def q(s):
for p in ps:
r = p(s)
if r is not None:
return r
return None
return q
def lazy(thunk: Callable[[], Parser]) -> Parser:
"""Нужен для рекурсивных грамматик: parens ссылается на expr, которого ещё нет."""
return lambda s: thunk()(s)
OPS = {"+": operator.add, "-": operator.sub,
"*": operator.mul, "/": operator.truediv}
def chain_left(operand: Parser, op: Parser) -> Parser:
"""Левоассоциативная цепочка operand (op operand)* — без левой рекурсии."""
def q(s):
r = operand(s)
if r is None:
return None
value, rest = r.value, r.rest
while True:
r_op = op(rest)
if r_op is None:
return Success(value, rest)
r_rhs = operand(r_op.rest)
if r_rhs is None:
return Success(value, rest) # «2 +» — оператор без правой части, откат
value = OPS[r_op.value](value, r_rhs.value)
rest = r_rhs.rest
return q
# --- сама грамматика: четыре строки, читаются как БНФ ---
number = map_p(token(r"\d+(?:\.\d+)?"), float)
parens = lambda: map_p(seq(token(r"\("), lazy(lambda: expr), token(r"\)")),
lambda parts: parts[1])
atom = alt(number, lazy(parens))
term = chain_left(atom, token(r"[*/]"))
expr = chain_left(term, token(r"[+-]"))
print(expr("2 + 3 * (4 - 1)")) # Success(value=11.0, rest='')
print(expr("10 / 4 - 1")) # Success(value=1.5, rest='')
Сложность. Для грамматик, где альтернативы различимы по первому символу (LL(1)-подобных), разбор
идёт за O(n) по времени и O(глубина вложенности) по стеку. Но комбинатор alt с общим префиксом даёт
повторный разбор: если alt(p, q) и p пробегает половину входа перед провалом, работа выброшена.
В худшем случае с вложенными альтернативами время экспоненциально — классическая ловушка
рекурсивного спуска с бэктрекингом.
Стандартное лекарство — packrat-парсинг: мемоизация результата (парсер, позиция). Это даёт
гарантированные O(n) по времени и O(n × число правил) по памяти. Именно так работают PEG-парсеры;
теорию описал Брайан Форд («Packrat Parsing: Simple, Powerful, Lazy, Linear Time»).
Второе лекарство — не откатываться слишком далеко: в Parsec для этого есть явный try, и
«коммит» после успешного разбора первого токена альтернативы.
Где применяют в проде. Библиотеки: Parsec и
megaparsec в Haskell, nom в Rust (на нём написаны десятки
бинарных парсеров), FParsec в .NET, parsy/pyparsing в Python, fast-check-подобные DSL в
TypeScript. Типичные задачи: конфиги и DSL, языки запросов и фильтров в API, разбор логов, бинарные
протоколы. Когда грамматика фиксирована и производительность критична (компилятор, SQL-движок) —
выбирают генератор парсеров или ручной рекурсивный спуск; комбинаторы выигрывают там, где грамматика
меняется вместе с продуктом и должна жить в обычном коде.
Линзы и оптика: обновление вглубь без мутаций
Неизменяемость платит за себя всюду, кроме одного места — глубокого обновления. Поменять city
внутри order.customer.address в неизменяемом мире означает пересобрать всю цепочку:
# Больно, шумно и легко ошибиться: суть «поменять city» тонет в трёх replace.
new_order = replace(order,
customer=replace(order.customer,
address=replace(order.customer.address, city="Казань")))
Ещё хуже, что этот код невозможно переиспользовать: «прочитать city» и «записать city» — два разных
куска, которые надо держать синхронными. Линза — это пара view/set, ставшая значением первого
класса. Одно значение отвечает и за чтение, и за запись, и его можно передать в функцию, положить
в список, скомпоновать с другой линзой.
from dataclasses import dataclass, replace
from typing import Any, Callable
@dataclass(frozen=True)
class Lens:
view: Callable[[Any], Any] # S -> A
put: Callable[[Any, Any], Any] # (S, A) -> S
def __rshift__(self, other: "Lens") -> "Lens":
"""Композиция: outer >> inner. Ассоциативна, единица — identity-линза."""
return Lens(
view=lambda s: other.view(self.view(s)),
put=lambda s, a: self.put(s, other.put(self.view(s), a)),
)
def over(self, s: Any, f: Callable[[Any], Any]) -> Any:
"""Модификация функцией — самая частая операция на практике."""
return self.put(s, f(self.view(s)))
def field(name: str) -> Lens:
return Lens(view=lambda s: getattr(s, name),
put=lambda s, a: replace(s, **{name: a}))
@dataclass(frozen=True)
class Address: city: str; zip: str
@dataclass(frozen=True)
class Customer: name: str; address: Address
@dataclass(frozen=True)
class Order: id: int; customer: Customer
city_of_order = field("customer") >> field("address") >> field("city")
order = Order(42, Customer("Аня", Address("Москва", "101000")))
print(city_of_order.view(order)) # 'Москва'
print(city_of_order.put(order, "Казань").customer.address.city) # 'Казань'
print(city_of_order.over(order, str.upper).customer.address.city) # 'МОСКВА'
print(order.customer.address.city) # 'Москва' — исходник цел
Законы линзы
1. view(put(s, a)) == a # что положили, то и прочли
2. put(s, view(s)) == s # запись прочитанного ничего не меняет
3. put(put(s, a), b) == put(s, b) # последняя запись побеждает
Эти три закона превращают линзу из «пары функций» в контракт. Нарушение первого — типичная ошибка
самописных линз, где put дополнительно нормализует значение (обрезает пробелы, приводит регистр):
тогда view(put(s, " Москва ")) вернёт "Москва", закон сломан, и композиция линз начнёт вести себя
непредсказуемо. Правило: линза только фокусирует, бизнес-логику кладите в функцию, передаваемую
в over.
Сложность и структурное разделение
over копирует ровно путь от корня до фокуса: O(d) аллокаций, где d — глубина, а не O(размера
структуры). Все ветки, лежащие в стороне, переиспользуются как есть — это structural sharing,
фундамент persistent-структур данных. Для словарей и векторов вместо «глубины пути» работает
HAMT/RRB-tree с O(log₃₂ n) — практически константа при реальных размерах (см. трек
«Структуры данных» и работу Фила Багвелла
«Ideal Hash Trees»).
Ключевое следствие для UI и стейт-менеджмента: сравнение по ссылке становится корректным способом
понять «изменилось ли поддерево». Именно на этом стоят React.memo, реселекторы в Redux и весь
подход immutable-стора.
Семейство оптик
Линза — не единственный представитель. Оптики — это иерархия, различающаяся тем, сколько целей в фокусе:
| Оптика | Фокус | Пример |
|---|---|---|
Lens |
ровно одна цель, всегда есть | поле структуры |
Prism |
ноль или одна цель | ветка суммы-типа, Ok внутри Result, элемент по ключу |
Traversal |
ноль или много целей | все элементы списка, все значения словаря |
Iso |
взаимно-обратное преобразование | метры ↔ футы, str ↔ bytes |
Fold |
много целей, только чтение | извлечь все email из дерева заказа |
Все они композируются друг с другом одним и тем же оператором — и именно поэтому оптика заслуживает
названия паттерна, а не утилиты. orders >> traversed >> field("customer") >> field("email") даёт
одно значение, которое умеет и прочитать все адреса, и обновить их все.
В проде: monocle (Scala), lens (Haskell),
optics-ts и Ramda
(TypeScript/JS), put_in/update_in/Access в Elixir — это линзы, встроенные в стандартную
библиотеку. И самое массовое применение: Immer в React-мире решает
ту же задачу иначе — даёт писать «мутирующий» код поверх Proxy, а на выходе получать неизменяемое
обновление со structural sharing. Полезно понимать, что Immer и линзы — конкурирующие ответы на один
и тот же вопрос: линзы дают композируемые значения-пути, Immer даёт знакомый синтаксис.
Катаморфизм: Visitor, которого нет
Последний паттерн для полноты картины. Дано рекурсивное дерево (AST выражения, JSON, дерево комментариев). Нужно много разных обходов: вычислить, отрендерить, посчитать глубину, собрать переменные. В ООП это Visitor — интерфейс с методом на каждый узел. В ФП обход схлопывается до fold: подставить функцию на каждый конструктор типа.
from dataclasses import dataclass
from typing import Callable, Union
@dataclass(frozen=True)
class Num: value: float
@dataclass(frozen=True)
class Add: left: "Expr"; right: "Expr"
@dataclass(frozen=True)
class Mul: left: "Expr"; right: "Expr"
@dataclass(frozen=True)
class Neg: inner: "Expr"
Expr = Union[Num, Add, Mul, Neg]
def fold(e: Expr, on_num, on_add, on_mul, on_neg):
"""Один обход. Всё поведение — в четырёх переданных функциях (алгебре)."""
rec = lambda x: fold(x, on_num, on_add, on_mul, on_neg)
match e:
case Num(v): return on_num(v)
case Add(l, r): return on_add(rec(l), rec(r))
case Mul(l, r): return on_mul(rec(l), rec(r))
case Neg(i): return on_neg(rec(i))
tree = Add(Num(2), Mul(Num(3), Neg(Num(4))))
evaluate = lambda e: fold(e, lambda v: v, lambda a, b: a + b,
lambda a, b: a * b, lambda a: -a)
render = lambda e: fold(e, str, lambda a, b: f"({a} + {b})",
lambda a, b: f"({a} * {b})", lambda a: f"(-{a})")
depth = lambda e: fold(e, lambda _: 1, lambda a, b: 1 + max(a, b),
lambda a, b: 1 + max(a, b), lambda a: 1 + a)
print(evaluate(tree), render(tree), depth(tree))
# -10 (2 + (3 * (-4))) 4
Три интерпретации, ноль классов, обход написан один раз. Сложность — O(число узлов) по времени и O(глубина) по стеку (на глубоких деревьях в Python это реальная проблема: нет оптимизации хвостовых вызовов, нужен явный стек).
Trade-off здесь ровно тот же, что у Visitor, и он известен как expression problem: такой код легко
расширять новыми операциями (добавьте пятый fold) и трудно — новыми узлами (добавили Div —
правьте все fold). ООП-иерархия ведёт себя зеркально. Об этом подробнее — в разборе Visitor в
поведенческих паттернах.
Цена абстракции: что это стоит на самом деле
Раздел, которого обычно нет в статьях про монады, но именно он решает, попадёт ли подход в ваш прод.
Аллокации. Каждый Ok(...), каждый промежуточный список в цепочке map — объект в куче.
В JIT-языках (JVM, .NET) escape-анализ часто убирает короткоживущие обёртки, но полагаться на это
нельзя. В Rust Result/Option вообще бесплатны: это перечисления на стеке, а Option<&T> ещё и
занимает столько же, сколько указатель (niche optimization). В Python каждый шаг — реальный объект,
и цепочка из пяти bind в горячем цикле на миллион итераций будет заметно медленнее пяти if.
Лишние проходы. xs.map(f).filter(g).map(h) — три прохода и два промежуточных списка. Лечится
ленивостью (генераторы, Stream, IEnumerable) или трансдьюсерами — комбинаторами, которые
композируют сами преобразования, а не их результаты, давая один проход без промежуточных коллекций
(см. Clojure Transducers).
Ленивость и утечки. Ленивое вычисление откладывает работу, но накапливает thunk-и. Классика
Haskell — утечка на ленивом foldl, когда вместо числа в памяти растёт цепочка отложенных сложений.
Практический вывод для любого языка: ленивость требует явного контроля точек форсирования.
Отладка и наблюдаемость. Стектрейс сквозь десять комбинаторов бесполезен: он показывает механику,
а не домен. Компенсация — обогащение ошибки контекстом на каждом шаге (map_err(lambda e: f"[reserve] {e}")),
структурные логи с идентификатором операции, трассировка. Это не опция, а обязательная часть внедрения.
Когнитивная нагрузка. Самая недооценённая статья расходов. Код с EitherT[StateT[IO]] понятен
трём людям в компании и не понятен дежурному в три часа ночи. Реальная стоимость абстракции — это
время, за которое незнакомый человек чинит инцидент.
Типичные ошибки
- «Монада» с эффектами внутри
bind. Логирование, метрики, мутация счётчика прямо вbindломают ассоциативность: сгруппировали шаги иначе — получили другое число записей в логе. Если нужен лог, он должен быть частью результата (Writer), а не побочным действием. - Использование монады там, где нужен аппликатив. Последовательные
awaitнезависимых запросов вместоPromise.all/asyncio.gather; валидация, отдающая первую ошибку вместо всех. Стоит миллисекунд и пользовательской злости. mapвместоbind. ПолучилиResult[Result[T]]илиPromise<Promise<T>>и «починили» лишней распаковкой. Признак — переменная с именемinner_result.- Ошибка как строка.
Result[T, str]удобен ровно до момента, когда вызывающему нужно отличить «не найдено» от «нет прав». Ошибка должна быть типом с вариантами, а не сообщением. - Result и исключения одновременно. Половина кода возвращает
Err, половина бросает — получается два механизма и ни одного гарантированного. Решение: граница. Внутри домена —Result, на входе в домен — конвертация исключений библиотек, на выходе — превращение в HTTP-ответ. - Линза с бизнес-логикой. Нормализация внутри
putломает законview(put(s, a)) == a. - Point-free ради point-free.
compose(map(prop("id")), filter(propEq("active", true)))вместо пяти читаемых строк — это не элегантность, а налог на каждое будущее чтение. - Ручные
Optionalтам, где язык уже дал инструмент. В JavaOptional— для возвращаемых значений, не для полей и параметров (см. рекомендации Stuart Marks).
Как это живёт в проде
- Rust.
Option/Result+ оператор?— монады, встроенные в язык и в культуру. Никто не называет их монадами, все ими пользуются. Нулевая стоимость времени выполнения. - TypeScript/JS.
Promise— монада сthenв ролиbind(с оговоркой:thenсхлопывает вложенность автоматически, что технически нарушает строгую типизацию монады, зато удобно). Библиотеки fp-ts и Effect дают полный набор; Effect — сегодня самый живой пример «одной большой монады эффектов» вне Scala. - C#. LINQ — это монадическая нотация, а
SelectMany—bind.Nullable<T>с?.— Maybe. Подробности — в треке C#. - Java.
Optional,Stream,CompletableFuture— три функтора в стандартной библиотеке;flatMapу каждого из них — этоbind. - Elixir/Erlang. Кортежи
{:ok, val}/{:error, reason}плюсwithдают Either без единого типа-класса.Ecto.Changeset— аппликативная валидация с накоплением ошибок в чистом виде. См. трек Elixir. - Go. Сознательно отказался от обобщённых монад:
if err != nilвместо?. Ноerrors.Wrap/%wиerrors.Is/As— это то же «обогащение ошибки контекстом по пути наверх». Ср. с треком Go. - Python.
returns,pydantic(валидация как аппликатив по духу), генераторы как ленивые потоки. Полноценные монады в питоне остаются нишевыми — цена читаемости слишком высока. - Frontend-архитектура целиком. Redux — это
fold(reduce) над потоком событий: состояние как чистая функция от истории. React-хуки — комбинаторы над жизненным циклом. Immer/optics — линзы. Функциональный словарь во фронтенде победил, просто без терминов.
Мини-итог
- Функциональные паттерны решают ту же задачу, что GoF — управляемое изменение — но единственным инструментом: композицией функций вместо расстановки объектов.
- Функтор = «применить функцию внутрь контекста, не меняя форму контекста». Законы функтора
гарантируют, что
mapне делает ничего лишнего. - Аппликатив = «объединить несколько независимых контекстов». Отсюда параллельность и накопление всех ошибок сразу.
- Монада = «связать шаги, где следующий зависит от предыдущего».
bind = join ∘ map. Вы уже пишете монадический код:await,?,with,SelectMany,flatMap. - Комбинаторы превращают политики (retry, timeout, парсинг, валидация) в обычные значения, которые можно складывать. Парсер-комбинаторы — лучший учебный пример: там встречается весь словарь.
- Линзы и оптика решают глубокое обновление неизменяемых структур: O(глубины) копирования, structural sharing, композиция путей как значений.
- Цена реальна: аллокации, лишние проходы, разрушенные стектрейсы и, главное, когнитивная нагрузка. Одна-две монады на систему — победа. Башня трансформеров — почти всегда проигрыш.
Источники
- Simon Peyton Jones, «Tackling the Awkward Squad: monadic input/output, concurrency, exceptions and
foreign-language calls in Haskell», 2001 — лучший текст о том, зачем нужна монада
IO: microsoft.com/en-us/research. - Conor McBride, Ross Paterson, «Applicative programming with effects», JFP 2008 — статья, которая ввела аппликативные функторы: staff.city.ac.uk/~ross/papers/Applicative.html.
- Philip Wadler, «Monads for functional programming», 1992 — homepages.inf.ed.ac.uk/wadler.
- Erik Meijer, Maarten Fokkinga, Ross Paterson, «Functional Programming with Bananas, Lenses, Envelopes and Barbed Wire», 1991 — истоки катаморфизмов: research.utwente.nl.
- Bryan Ford, «Packrat Parsing: Simple, Powerful, Lazy, Linear Time», ICFP 2002 — bford.info.
- Graham Hutton, Erik Meijer, «Monadic Parsing in Haskell», JFP 1998 — people.cs.nott.ac.uk/pszgmh.
- Phil Bagwell, «Ideal Hash Trees», 2001 — основа persistent-структур со structural sharing: lampwww.epfl.ch.
- Scott Wlaschin, «Railway Oriented Programming» — самое доступное объяснение
Result-пайплайна: fsharpforfunandprofit.com/rop. - Rich Hickey, «The Value of Values» и документация Clojure по трансдьюсерам — clojure.org/reference/transducers.
- Документация: Rust
Result, fp-ts, Effect, Monocle, Immer, nom, Ecto.Changeset.
Что дальше
Мы разобрали два полных словаря паттернов — объектный и функциональный — и в обоих случаях подчёркивали цену абстракции. Логичный следующий шаг: посмотреть, что происходит, когда решение применяют не там, не тогда или слишком усердно. У неудачных структур тоже есть имена, и знать их не менее полезно, чем знать удачные.
Антипаттерны: God Object, Big Ball of Mud, Golden Hammer и другие