Паттерны проектирования Функциональные паттерны: функторы, монады, линзы, комбинаторы
0%

Функциональные паттерны: функторы, монады, линзы, комбинаторы

Функциональные паттерны: функторы, монады, линзы, комбинаторы

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

Функциональные паттерны выросли из другой традиции и отвечают на другой вопрос. Там, где GoF спрашивает «кто кем владеет и кто кого вызывает», функциональный подход спрашивает: «какое здесь значение и какая функция его преобразует». Объектов нет, наследования нет, изменяемого состояния в идеале тоже нет — значит, все привычные швы (подкласс, инъекция зависимости, полиморфный вызов) недоступны, и на их место приходит ровно один механизм: композиция функций.

Это не «другой стиль оформления кода». Это другая точка приложения силы. И если вы её не видите, то монады выглядят академической эзотерикой, а если видите — оказывается, что вы уже лет десять пишете монадический код в Promise.then, Optional.map, LINQ и Ecto.Changeset, просто без словаря.

Здесь мы разберём словарь. Не «монада — это моноид в категории эндофункторов», а: какая сила порождает паттерн, как он выглядит в коде на Python/TypeScript, что он стоит по времени и памяти, где ломается и как выглядит в проде.

Если хочется предварительно освежить саму парадигму — чистота, неизменяемость, рекурсия, ленивость — это «Функциональная парадигма» в треке парадигм. Здесь мы считаем базу известной и сразу идём в паттерны.

Смена системы координат

Функциональные паттерны опираются на три обязательства. Не «правила хорошего тона», а именно обязательства: если вы их нарушите, паттерны перестанут работать — не эстетически, а технически.

  1. Чистота. Функция от одних и тех же аргументов возвращает один и тот же результат и не делает ничего наблюдаемого снаружи. Следствие: функцию можно вызвать позже, дважды, в другом потоке, не вызвать вовсе — результат программы не изменится. Именно это делает композицию безопасной.
  2. Неизменяемость. Значение, однажды созданное, не меняется. Следствие: не нужны блокировки (см. проблемы разделяемого состояния в паттернах конкурентности), а «изменение» становится функцией old -> new.
  3. Тотальность и явность эффектов. Функция описывает все свои исходы в возвращаемом типе: не бросает исключение, а возвращает 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

Подъём функции в контекст: map и bind

Без функтора код обрастает распаковкой: 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, каждый из которых можно забыть, и каждый из которых надо покрыть тестом. Здесь «прокидывание ошибки» — свойство типа, а не дисциплина разработчика. Тестировать нужно шаги, а не связки.

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 взаимно-обратное преобразование метры ↔ футы, strbytes
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]] понятен трём людям в компании и не понятен дежурному в три часа ночи. Реальная стоимость абстракции — это время, за которое незнакомый человек чинит инцидент.

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

  1. «Монада» с эффектами внутри bind. Логирование, метрики, мутация счётчика прямо в bind ломают ассоциативность: сгруппировали шаги иначе — получили другое число записей в логе. Если нужен лог, он должен быть частью результата (Writer), а не побочным действием.
  2. Использование монады там, где нужен аппликатив. Последовательные await независимых запросов вместо Promise.all/asyncio.gather; валидация, отдающая первую ошибку вместо всех. Стоит миллисекунд и пользовательской злости.
  3. map вместо bind. Получили Result[Result[T]] или Promise<Promise<T>> и «починили» лишней распаковкой. Признак — переменная с именем inner_result.
  4. Ошибка как строка. Result[T, str] удобен ровно до момента, когда вызывающему нужно отличить «не найдено» от «нет прав». Ошибка должна быть типом с вариантами, а не сообщением.
  5. Result и исключения одновременно. Половина кода возвращает Err, половина бросает — получается два механизма и ни одного гарантированного. Решение: граница. Внутри домена — Result, на входе в домен — конвертация исключений библиотек, на выходе — превращение в HTTP-ответ.
  6. Линза с бизнес-логикой. Нормализация внутри put ломает закон view(put(s, a)) == a.
  7. Point-free ради point-free. compose(map(prop("id")), filter(propEq("active", true))) вместо пяти читаемых строк — это не элегантность, а налог на каждое будущее чтение.
  8. Ручные Optional там, где язык уже дал инструмент. В Java Optional — для возвращаемых значений, не для полей и параметров (см. рекомендации Stuart Marks).

Как это живёт в проде

  • Rust. Option/Result + оператор ? — монады, встроенные в язык и в культуру. Никто не называет их монадами, все ими пользуются. Нулевая стоимость времени выполнения.
  • TypeScript/JS. Promise — монада с then в роли bind (с оговоркой: then схлопывает вложенность автоматически, что технически нарушает строгую типизацию монады, зато удобно). Библиотеки fp-ts и Effect дают полный набор; Effect — сегодня самый живой пример «одной большой монады эффектов» вне Scala.
  • C#. LINQ — это монадическая нотация, а SelectManybind. 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 и другие

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

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

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

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