Паттерны проектирования Поведенческие паттерны: Strategy, Observer, Command, State и другие
0%

Поведенческие паттерны: Strategy, Observer, Command, State и другие

Поведенческие паттерны: Strategy, Observer, Command, State и другие

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

Это самая большая группа в каталоге GoF — 11 из 23 паттернов — и самая практичная. Причина в том, что структура кода стабильна, а поведение меняется каждый спринт: появляется новый способ считать скидку, новый статус заказа, новая подписка на событие, новая проверка в пайплайне запроса. Все поведенческие паттерны — это разные ответы на один и тот же запах: растущее ветвление (if/switch) там, где должна быть точка расширения.

Предыдущие статьи трека: обзор каталога, порождающие, структурные.


Карта группы: по какому вопросу выбирать

Не пытайтесь запомнить 11 карточек. Запомните 5 вопросов — каждый ведёт к своему паттерну.

Дальше — по одному паттерну, от самого частого к самому редкому. Для каждого: интуиция, код, цена, где ломается.


Strategy: вынести «как именно» в отдельный объект

Проблема. Метод считает стоимость доставки, и внутри него растёт if по типу доставки. Каждый новый перевозчик — правка работающего кода, риск регрессии, конфликт в git, невозможность протестировать один способ отдельно.

# Было: точка вариативности размазана по телу метода
def shipping_cost(order: Order, kind: str) -> Decimal:
    if kind == "pickup":
        return Decimal(0)
    elif kind == "post":
        return Decimal(300) + order.weight_kg * Decimal(20)
    elif kind == "courier":
        base = Decimal(500) if order.total < 3000 else Decimal(0)
        return base + _zone_surcharge(order.address)   # ещё один if внутри
    raise ValueError(kind)

Решение. Каждая ветка — объект с одинаковым интерфейсом; контекст держит ссылку на один из них.

from decimal import Decimal
from typing import Protocol

class ShippingPolicy(Protocol):
    """Стабильный контракт. Всё, что знает клиент."""
    def cost(self, order: "Order") -> Decimal: ...

class Pickup:
    def cost(self, order: "Order") -> Decimal:
        return Decimal(0)

class Post:
    def __init__(self, base: Decimal, per_kg: Decimal) -> None:
        self._base, self._per_kg = base, per_kg      # тариф — состояние стратегии

    def cost(self, order: "Order") -> Decimal:
        return self._base + order.weight_kg * self._per_kg

class Courier:
    def __init__(self, zones: "ZoneTable", free_from: Decimal) -> None:
        self._zones, self._free_from = zones, free_from

    def cost(self, order: "Order") -> Decimal:
        base = Decimal(0) if order.total >= self._free_from else Decimal(500)
        return base + self._zones.surcharge(order.address)

class Checkout:
    def __init__(self, policy: ShippingPolicy) -> None:
        self._policy = policy                        # композиция, не наследование

    def total(self, order: "Order") -> Decimal:
        return order.total + self._policy.cost(order)

Что мы купили. Новый перевозчик — новый класс, Checkout не меняется (это буквально Open-Closed Principle, см. принципы проектирования). Каждая политика тестируется без заказа целиком. Тарифы — параметры объекта, а не константы в коде.

Что мы заплатили. Классов стало 4 вместо 1. Появился вопрос «кто выбирает стратегию» — он никуда не делся, а переехал в фабрику или DI-контейнер. Стек вызовов длиннее на один кадр.

Сложность. Диспетчеризация — один виртуальный вызов, O(1). Против цепочки if из n веток (O(n) сравнений в худшем случае) Strategy на горячем пути даже быстрее — при условии, что call-site мономорфный. Если через одну точку проходят 5+ разных реализаций, JIT/CPU теряет предсказание ветвлений и косвенный вызов стоит десятки тактов на промахе. На по-настоящему горячем цикле это измеримо; в бизнес-логике — шум.

Честно про языки. Интерфейс с одним методом — это функция. В Python, Go, TypeScript, C# Strategy почти всегда сворачивается до типа-функции:

ShippingPolicy = Callable[[Order], Decimal]          # весь «интерфейс»

def post(base: Decimal, per_kg: Decimal) -> ShippingPolicy:
    def cost(order: Order) -> Decimal:               # замыкание держит тариф
        return base + order.weight_kg * per_kg
    return cost

checkout = Checkout(post(Decimal(300), Decimal(20)))

Паттерн тот же — структура «клиент зависит от контракта, реализация подставляется снаружи» сохранена. Классы нужны, когда у стратегии больше одного метода, есть жизненный цикл или её надо конфигурировать декларативно. Об этом же — статья Питера Норвига «Design Patterns in Dynamic Languages»: 16 из 23 паттернов GoF в языках с функциями первого класса «невидимы» или тривиальны.

Template Method: близнец Strategy на наследовании

Тот же вопрос «варьируется алгоритм», но варьируются отдельные шаги, а скелет фиксирован:

class ReportJob:
    def run(self) -> None:            # скелет — final по смыслу, не переопределяют
        rows = self.fetch()
        rows = self.transform(rows)
        self.publish(rows)

    def fetch(self) -> list: raise NotImplementedError    # обязательный хук
    def transform(self, rows: list) -> list: return rows  # необязательный хук
    def publish(self, rows: list) -> None: raise NotImplementedError

Разница практическая, а не эстетическая:

Template Method Strategy
Механизм наследование композиция
Когда фиксируется на компиляции в рантайме
Сколько точек вариации много мелких (хуки) одна крупная
Переиспользование шага нельзя без иерархии тривиально
Тестирование нужен подкласс подставить фейк
Риск хрупкий базовый класс, глубокие иерархии лишние объекты

Правило: начинайте с Template Method, если вариантов 2–3 и они не пересекаются; переходите на Strategy, когда шаги хочется комбинировать. Комбинаторный взрыв подклассов (FastJsonReportJob, SlowCsvReportJob, …) — сигнал, что пора.


State: поведение зависит от того, «кто я сейчас»

Проблема. Заказ имеет статус, и почти каждый метод начинается со switch (status). Логика одного статуса размазана по десяти методам; логика одного метода перемешана со всеми статусами. Добавили статус — обошли все switch, один забыли, получили баг в проде.

Решение. Каждое состояние — класс, реализующий все операции; контекст делегирует и позволяет состоянию заменить себя следующим.

from typing import Protocol

class OrderState(Protocol):
    name: str
    def pay(self, order: "Order") -> None: ...
    def cancel(self, order: "Order") -> None: ...
    def ship(self, order: "Order") -> None: ...

class _Base:
    """Дефолт: любой не разрешённый переход — явная ошибка, а не тихий no-op."""
    name = "base"
    def pay(self, order): raise IllegalTransition(f"pay() недопустим в {self.name}")
    def cancel(self, order): raise IllegalTransition(f"cancel() недопустим в {self.name}")
    def ship(self, order): raise IllegalTransition(f"ship() недопустим в {self.name}")

class Pending(_Base):
    name = "pending"
    def pay(self, order: "Order") -> None:
        order.payment.charge(order.total)       # side-effect ДО смены состояния
        order.set_state(Paid())                 # состояние знает про следующее
    def cancel(self, order: "Order") -> None:
        order.set_state(Cancelled())

class Paid(_Base):
    name = "paid"
    def ship(self, order: "Order") -> None:
        order.warehouse.reserve(order.items)
        order.set_state(Shipped())
    def cancel(self, order: "Order") -> None:
        order.payment.refund(order.total)       # у отмены разный смысл в разных состояниях
        order.set_state(Refunded())

class Order:
    def __init__(self) -> None:
        self._state: OrderState = Pending()

    def set_state(self, s: OrderState) -> None:
        self._state = s

    # Контекст — тонкий фасад: ни одного if по статусу
    def pay(self) -> None: self._state.pay(self)
    def cancel(self) -> None: self._state.cancel(self)
    def ship(self) -> None: self._state.ship(self)

Ключевое отличие от Strategy — не в UML (диаграммы классов идентичны), а в том, кто меняет ссылку. У Strategy её ставит клиент снаружи и обычно один раз; реализации не знают друг о друге. У State ссылку меняет само состояние; реализации знают о соседях и образуют граф переходов.

Strategy, State и Command: одна структура, три намерения

Цена и границы. State честно платит: n состояний × m операций = до n·m методов, ровно столько же ветвей, сколько было в switch — они просто перегруппированы «по состояниям», а не «по операциям». Выигрыш появляется, когда:

  • переходов много и они нетривиальны (есть запрещённые);
  • у состояний своё состояние (Pending хранит дедлайн, Shipped — трек-номер);
  • добавление состояния — частая операция.

Если состояний три и переходы линейные — таблица переходов (dict[(state, event)] -> state) короче и нагляднее, чем 3 класса. Это классическая альтернатива, и в проде она встречается чаще:

TRANSITIONS = {
    ("pending", "pay"):    ("paid",      charge),
    ("pending", "cancel"): ("cancelled", None),
    ("paid",    "ship"):   ("shipped",   reserve),
    ("paid",    "cancel"): ("refunded",  refund),
}

def apply(order: Order, event: str) -> None:
    key = (order.status, event)
    if key not in TRANSITIONS:
        raise IllegalTransition(key)          # одно место, где ловятся все дыры
    new_status, effect = TRANSITIONS[key]
    if effect: effect(order)
    order.status = new_status

Таблица даёт то, чего не даёт State: её можно проверить целиком (обойти граф, найти недостижимые состояния и тупики), сериализовать, показать аналитику. Библиотеки вроде transitions (Python), Stateless (C#), XState (TS) — это именно таблица, а не GoF-State. В Elixir роль контекста играет процесс: gen_statem — машина состояний как OTP-поведение, где состояние — это атом плюс данные (см. трек Elixir).

Типичная ошибка. Хранить статус в БД как строку, а переходы — в коде State, и не иметь ни одного места, где виден весь граф. Через год никто не может ответить, возможен ли переход shipped → cancelled. Лечение: держать граф данными (таблица/enum), даже если исполнение — объектное.


Command: запрос как объект

Интуиция. Обычный вызов editor.delete(row) — событие без следа: он произошёл и исчез. Command замораживает вызов вместе с аргументами в объект. Как только вызов стал объектом, с ним можно делать всё, что делают с данными: класть в очередь, писать в лог, отправлять по сети, повторять, откатывать, показывать в UI.

from dataclasses import dataclass
from typing import Protocol

class Command(Protocol):
    def execute(self) -> None: ...
    def undo(self) -> None: ...

@dataclass
class InsertText:
    doc: "Document"
    pos: int
    text: str

    def execute(self) -> None:
        self.doc.insert(self.pos, self.text)

    def undo(self) -> None:
        self.doc.delete(self.pos, len(self.text))   # обратная операция

@dataclass
class DeleteRange:
    doc: "Document"
    pos: int
    length: int
    _removed: str = ""          # для отката нужен снимок — это уже Memento внутри

    def execute(self) -> None:
        self._removed = self.doc.read(self.pos, self.length)
        self.doc.delete(self.pos, self.length)

    def undo(self) -> None:
        self.doc.insert(self.pos, self._removed)

class History:
    """Invoker: единственное место, где живут undo/redo."""
    def __init__(self, limit: int = 200) -> None:
        self._done: list[Command] = []
        self._undone: list[Command] = []
        self._limit = limit

    def run(self, cmd: Command) -> None:
        cmd.execute()
        self._done.append(cmd)
        self._undone.clear()                 # новая команда обрывает ветку redo
        if len(self._done) > self._limit:
            self._done.pop(0)                # O(n); в проде — deque(maxlen=...)

    def undo(self) -> None:
        if not self._done: return
        cmd = self._done.pop()
        cmd.undo()
        self._undone.append(cmd)

    def redo(self) -> None:
        if not self._undone: return
        cmd = self._undone.pop()
        cmd.execute()
        self._done.append(cmd)

Сложность. run/undo/redoO(1) амортизированно на deque. Память — O(k) от глубины истории, и вот здесь прячется главный практический вопрос: сколько весит один шаг отката.

Две стратегии отката, выбор между ними — реальный архитектурный трейд-офф:

Обратная операция (undo()) Снимок состояния (Memento)
Память на шаг O(размер дельты) O(размер документа)
Время отката O(дельты) O(восстановления)
Требование операция должна быть обратимой никаких
Ломается на нормализовать_пробелы(), x = 0 больших состояниях

Гибрид, который применяют в редакторах и БД: снимок каждые k шагов + дельты между ними — откат стоит O(k) применений, память O(n/k · размер). Ровно эта же идея — WAL плюс checkpoint в PostgreSQL и event sourcing со снапшотами.

Второе применение Command — не undo, а асинхронность. Раз команда сериализуема, её можно положить в очередь и выполнить в другом процессе. Celery-таска, Sidekiq-job, сообщение в Kafka — это Command, доехавший до продакшена. Отсюда же CQRS: команда (меняет состояние, ничего не возвращает) отделена от запроса (читает, ничего не меняет). Подробнее — в треке архитектурных паттернов.

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

  1. Команда лезет в UI за аргументами. class Save: def execute(self): path = dialog.ask() — такую команду нельзя ни повторить, ни выполнить в очереди. Аргументы фиксируются при создании.
  2. undo() не идемпотентен к повторному redo. Тест-минимум: execute(); undo(); execute() должно давать то же, что просто execute(), а undo() после этого — исходное состояние. Property-based тест на случайной последовательности команд ловит 90% таких багов.
  3. Команда держит ссылки на живые объекты и течёт. История на 500 шагов удерживает 500 поддеревьев документа. Храните идентификаторы, а не объекты.

Observer: издатель не знает подписчиков

Проблема. Когда меняется цена товара, надо: пересчитать корзины, инвалидировать кэш, отправить пуш подписчикам, записать в аналитику. Если Product.set_price вызывает всех четверых напрямую, модель домена начинает зависеть от кэша, пушей и аналитики.

Решение. Субъект хранит список наблюдателей и оповещает их, ничего о них не зная.

from collections import defaultdict
from typing import Callable
import logging, weakref

log = logging.getLogger(__name__)
Handler = Callable[["Event"], None]

class EventBus:
    def __init__(self) -> None:
        self._subs: dict[str, list[Handler]] = defaultdict(list)

    def subscribe(self, topic: str, handler: Handler) -> Callable[[], None]:
        self._subs[topic].append(handler)
        return lambda: self._subs[topic].remove(handler)   # отписка возвращается сразу

    def publish(self, topic: str, event: "Event") -> None:
        # копия списка: подписчик может отписаться/подписаться внутри обработчика
        for handler in list(self._subs[topic]):
            try:
                handler(event)
            except Exception:                # один упавший не рвёт цепочку
                log.exception("подписчик %r упал на %s", handler, topic)

Четыре строчки защиты в publish — это дистиллят граблей, на которые наступают все.

Грабли Observer, по убыванию частоты.

  1. Мутация списка во время обхода. Подписчик отписывается в обработчике → RuntimeError или пропущенный сосед. Лечение: итерировать по копии (как выше).
  2. Утечка памяти («lapsed listener»). Субъект живёт долго, подписчик — короткоживущий объект, но ссылка из списка не даёт его собрать. Классика в GUI и в Android. Лечения два: возвращать функцию отписки и требовать её вызова (как выше), либо хранить weakref — но осторожно: weakref на bound-метод умирает мгновенно, нужен WeakMethod.
  3. Исключение подписчика ломает бизнес-операцию. Аналитика упала — заказ не сохранился. Решение: изолировать каждый вызов, а для критичных — не Observer, а явный вызов.
  4. Порядок оповещения — неявная зависимость. Если инвалидация кэша обязана произойти раньше пуша, вы уже не в Observer, а в скрытом пайплайне. Не подкручивайте порядок подписки — сделайте зависимость явной.
  5. Каскады и реентерабельность. Подписчик меняет субъект → новое оповещение → цикл. Лечение: флаг «идёт оповещение» + очередь отложенных событий, либо запрет мутаций в обработчиках.
  6. Оповещение до коммита транзакции. Подписчик читает БД и не видит данных. Правило: собирать события в транзакции, публиковать после её коммита (в Django — transaction.on_commit).

Синхронный или асинхронный. Синхронный Observer прост и отлаживаем: стек-трейс показывает, кто кого вызвал. Но он делает бизнес-операцию медленнее на сумму всех подписчиков и связывает её надёжность с их надёжностью. Асинхронный (очередь) развязывает, но приносит at-least-once доставку, необходимость идемпотентности обработчиков и потерю причинно-следственного стека. Практическое правило: внутри одного процесса и одной транзакции — синхронно; всё, что ходит по сети или может быть медленным — через очередь.

В проде это выглядит так: Django signals и transaction.on_commit, EventEmitter в Node.js, INotifyPropertyChanged и события C#, PropertyChangeListener в Java, PhoenixPubSub в Elixir, реактивные библиотеки (RxJS, ReactiveX) — это Observer плюс операторы над потоком событий плюс явная семантика завершения и ошибки. Именно эти три добавки и делают Rx полезнее голого Observer.


Chain of Responsibility: конвейер обработчиков

Запрос идёт по цепочке; каждый обработчик либо обрабатывает его, либо передаёт дальше. Каноническая GoF-формулировка («обрабатывает ровно один») в проде почти вытеснена вариантом middleware: каждый видит запрос по пути туда и ответ по пути обратно, и любой может оборвать цепочку.

from typing import Callable
Handler = Callable[["Request"], "Response"]
Middleware = Callable[[Handler], Handler]

def logging_mw(nxt: Handler) -> Handler:
    def mw(req: "Request") -> "Response":
        started = time.monotonic()
        resp = nxt(req)                                    # путь «туда»
        log.info("%s %s -> %s за %.1f мс",                 # путь «обратно»
                 req.method, req.path, resp.status,
                 (time.monotonic() - started) * 1000)
        return resp
    return mw

def auth_mw(nxt: Handler) -> Handler:
    def mw(req: "Request") -> "Response":
        user = authenticate(req.headers.get("Authorization"))
        if user is None:
            return Response(401)                           # обрыв: nxt не вызываем
        req.user = user
        return nxt(req)
    return mw

def build(handler: Handler, *middlewares: Middleware) -> Handler:
    """Оборачиваем в обратном порядке: первый в списке — самый внешний."""
    for mw in reversed(middlewares):
        handler = mw(handler)
    return handler

app = build(my_view, logging_mw, auth_mw, rate_limit_mw)

Это в точности net/http в Go (func(http.Handler) http.Handler, см. трек Go), ASP.NET Core middleware, Express, Plug в Phoenix, WSGI/ASGI. Один и тот же паттерн, независимо переизобретённый в каждой экосистеме.

Сложность. O(k) по числу звеньев на запрос, плюс O(k) кадров стека — при 30 middleware трейс становится нечитаемым, а глубина рекурсии заметной. Trade-off честный: гибкость сборки пайплайна против наблюдаемости. Явный список из пяти вызовов подряд отлаживается лучше, чем цепочка из пяти; цепочка выигрывает, когда состав звеньев меняется по конфигурации.

Ошибки. Звено, забывшее вызвать nxt, тихо съедает запрос — самый частый баг; лечится тестом «цепочка из N звеньев доходит до конца». Порядок звеньев критичен и неочевиден: rate-limit до auth считает анонимов, после auth — не защищает сам auth от перебора.


Iterator и Mediator: два коротких, но важных

Iterator — перебор элементов без раскрытия внутреннего устройства коллекции. В современных языках это не «паттерн», а часть языка: __iter__/генераторы в Python, IEnumerable/yield в C#, range над каналом в Go, Enumerable/Stream в Elixir. Ценность формулировки GoF сохраняется в двух местах: ленивость (генератор не материализует коллекцию — O(1) памяти вместо O(n)) и инкапсуляция обхода (дерево можно обойти в глубину, в ширину, в порядке возрастания — тремя итераторами над одной структурой; см. структуры данных).

def walk_inorder(node):
    """Симметричный обход. O(n) времени, O(h) памяти — клиент не знает про узлы."""
    if node is None: return
    yield from walk_inorder(node.left)
    yield node.value
    yield from walk_inorder(node.right)

Главная ловушка — инвалидация: изменение коллекции во время обхода. Java бросает ConcurrentModificationException, Python на dict — RuntimeError, C++ молча даёт UB. Явно решите, что делает ваш итератор при мутации: падает, снапшотит или видит изменения.

Mediator — когда n компонентов знают друг о друге напрямую (до связей), вводится посредник, и связей становится n. Классика — форма с полями, где включение чекбокса гасит три поля и меняет валидацию четвёртого: вместо перекрёстных ссылок все поля говорят с FormMediator.

Опасность Mediator реальна и известна: он собирает в себя всю логику взаимодействия и вырастает в God Object — прямой путь к антипаттернам. Симптом: медиатор знает бизнес-правила компонентов, а не только маршрутизацию событий. Держите его тонким; когда разрастается — режьте на несколько по смысловым группам.


Memento: снимок состояния без нарушения инкапсуляции

Memento — это «сохранёнка». Объект отдаёт наружу непрозрачный снимок своего состояния, который никто, кроме него самого, интерпретировать не умеет; хранитель (Caretaker) только держит его и возвращает обратно.

@dataclass(frozen=True)
class EditorMemento:
    """Непрозрачно для Caretaker: он видит только объект целиком."""
    _text: str
    _cursor: int

class Editor:
    def save(self) -> EditorMemento:
        return EditorMemento(self._text, self._cursor)

    def restore(self, m: EditorMemento) -> None:
        self._text, self._cursor = m._text, m._cursor    # знает про приватные поля

На практике Memento почти всегда живёт рядом с Command (см. DeleteRange._removed выше) и почти всегда упирается в память. Два приёма из реального кода:

  • Persistent-структуры. Если состояние — иммутабельная структура с разделением (immutable.js, pyrsistent, любая структура в Clojure/Elixir), снимок стоит O(1) по времени и O(1) по добавочной памяти: снимок — это просто ссылка на текущий корень. Это делает undo почти бесплатным и объясняет, почему в редакторах на иммутабельных моделях история глубиной в тысячи шагов — норма.
  • Дельты вместо снимков — когда состояние большое, а изменения точечные.

Visitor: новая операция без правки типов

Самый сложный паттерн группы и самый узкоспециальный. Задача: есть устойчивая иерархия типов (узлы AST, фигуры, элементы документа) и растущий список операций над ней (посчитать, отрисовать, сериализовать, оптимизировать, проверить типы). Складывать все операции в сами типы — значит раздувать их и мешать несвязанные заботы.

Visitor выворачивает зависимость: операция становится классом с методом на каждый тип, а типы получают один-единственный метод accept.

Это двойная диспетчеризация: в языках с одиночным виртуальным вызовом (Java, C#, C++, Python) нельзя выбрать метод по двум типам сразу, поэтому выбор разбивается на два шага. В языках с мультиметодами (Clojure, Julia, functools.singledispatch частично) Visitor не нужен — компилятор делает это сам.

class Shape(Protocol):
    def accept(self, v: "Visitor"): ...

@dataclass
class Circle:
    radius: float
    def accept(self, v: "Visitor"): return v.visit_circle(self)

@dataclass
class Rect:
    w: float; h: float
    def accept(self, v: "Visitor"): return v.visit_rect(self)

class Visitor(Protocol):
    def visit_circle(self, c: Circle): ...
    def visit_rect(self, r: Rect): ...

class Area:
    def visit_circle(self, c: Circle) -> float: return math.pi * c.radius ** 2
    def visit_rect(self, r: Rect) -> float: return r.w * r.h

class Svg:
    def visit_circle(self, c: Circle) -> str: return f'<circle r="{c.radius}"/>'
    def visit_rect(self, r: Rect) -> str: return f'<rect width="{r.w}" height="{r.h}"/>'

Теперь — главная причина, по которой Visitor нельзя применять «на всякий случай»:

Visitor и проблема выражения: матрица «тип × операция»

Это проблема выражения (expression problem), сформулированная Филипом Вадлером в заметке 1998 года: нельзя одновременно дёшево добавлять и новые типы, и новые операции, не меняя существующий код. ООП без Visitor делает дешёвым добавление типов; Visitor делает дешёвым добавление операций. Отсюда правило: Visitor оправдан там, где иерархия типов стабильна, а операций много и они растут. Ровно один случай встречается постоянно — компиляторы, интерпретаторы, линтеры, трансформации AST. В бизнес-коде, где типы меняются каждый спринт, Visitor — чистый вред.

В Python двойную диспетчеризацию часто пишут короче — через singledispatchmethod, match по типам или словарь type -> handler; это тот же Visitor без accept и с меньшей церемонией.

Родственник Visitor — Interpreter: грамматика выражается деревом объектов, каждый узел умеет interpret(context). На практике его почти всегда вытесняет генератор парсеров или ручной recursive descent, а вычисление дерева пишется как Visitor. Знать формулировку полезно, применять буквально — редко.


Как выбирать: короткий алгоритм


Сводная таблица цены

Паттерн Что даёт Стоимость Когда НЕ надо
Strategy замена алгоритма в рантайме, тестируемость +класс на вариант, косвенность вариант один и не меняется
Template Method переиспользование скелета жёсткая иерархия, хрупкий базовый класс шаги хочется комбинировать
State ветвление по статусу исчезает n·m методов, граф размазан ≤3 состояния, линейные переходы
Command undo, очередь, лог, повтор память истории, сериализуемость вызов и так одноразовый
Observer развязка издателя и подписчика утечки, порядок, частичный сбой подписчик ровно один и известен
Chain of Resp. конфигурируемый пайплайн глубина стека, «съеденный» запрос шагов 2–3 и они фиксированы
Iterator ленивый обход, инкапсуляция инвалидация при мутации язык уже даёт из коробки
Mediator связей → n риск God Object компонентов мало
Memento откат без нарушения инкапсуляции память под снимки состояние огромное
Visitor новая операция без правки типов новый тип ломает все визитёры иерархия типов растёт
Interpreter грамматика как объекты медленно, многословно есть парсер-генератор

Что важно унести

  1. Все 11 паттернов — ответ на растущее ветвление. Прежде чем брать паттерн, посмотрите, что именно ветвится: алгоритм (Strategy), статус (State), тип узла (Visitor), обработчик (CoR).
  2. UML не различает Strategy, State и Command. Различает намерение: кто меняет ссылку и зачем реализация существует.
  3. Функции первого класса схлопывают половину группы. Strategy, Command, Observer, Template Method с одним хуком — всё это функции и замыкания. Классы берите, когда нужны состояние, жизненный цикл или несколько методов.
  4. Самые дорогие грабли группы — у Observer: утечки подписчиков, порядок оповещения, частичный сбой, оповещение до коммита. Это не теория, это то, что чинят в проде.
  5. State против таблицы переходов — реальный выбор, и таблица чаще выигрывает. Граф, который можно проверить целиком, ценнее объектной элегантности.
  6. Visitor — про проблему выражения. Он оправдан ровно там, где типы стабильны: компиляторы, AST, линтеры.

Источники

Первоисточники

  • Gamma E., Helm R., Johnson R., Vlissides J. «Design Patterns: Elements of Reusable Object-Oriented Software» (1994), глава 5 «Behavioral Patterns» — канон, включая явное сравнение Strategy/State и Strategy/Template Method.
  • Wadler P. «The Expression Problem» (1998) — homepages.inf.ed.ac.uk/wadler/papers/expression.
  • Norvig P. «Design Patterns in Dynamic Languages» (1996) — norvig.com/design-patterns.

Практика и критика

  • Fowler M. «Refactoring», 2-е изд. — рефакторинги Replace Conditional with Polymorphism, Replace Type Code with Subclasses: механика перехода к Strategy/State по шагам. refactoring.com/catalog.
  • Fowler M. «Event-Driven Architecture» и «What do you mean by Event-Driven?»martinfowler.com/articles/201701-event-driven.html.
  • Fowler M. «Domain Event» и «Event Sourcing» — Command и Observer на уровне архитектуры: martinfowler.com/eaaDev/EventSourcing.html.
  • Nystrom R. «Game Programming Patterns» — главы State, Command, Observer, Event Queue: лучший разбор их производительности, доступен бесплатно — gameprogrammingpatterns.com.
  • Harel D. «Statecharts: A Visual Formalism for Complex Systems» (1987) — иерархические и параллельные состояния, теоретическая база XState и gen_statem.

Справочники и реализации


Что дальше

Мы разобрали поведение в одном потоке. Как только потоков становится больше одного, знакомые паттерны ломаются: Observer получает гонки в списке подписчиков, Command — необходимость идемпотентности, State — атомарность перехода. Для конкурентного мира выработан отдельный словарь: пул воркеров, producer-consumer, future/promise, pipeline — со своими законами и своими способами всё испортить.

Следующая статья: Паттерны конкурентности: пул, producer-consumer, future, pipeline

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

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

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

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