Поведенческие паттерны: Strategy, Observer, Command, State и другие
Порождающие паттерны отвечали на вопрос «кто создаёт объект», структурные — «как объекты складываются в целое». Поведенческие отвечают на третий и самый скользкий вопрос: кто кого вызывает, кто за что отвечает и как решение принимается в рантайме.
Это самая большая группа в каталоге GoF — 11 из 23 паттернов — и самая практичная. Причина в
том, что структура кода стабильна, а поведение меняется каждый спринт: появляется новый способ
считать скидку, новый статус заказа, новая подписка на событие, новая проверка в пайплайне
запроса. Все поведенческие паттерны — это разные ответы на один и тот же запах: растущее
ветвление (if/switch) там, где должна быть точка расширения.
Предыдущие статьи трека: обзор каталога, порождающие, структурные.
Карта группы: по какому вопросу выбирать
Не пытайтесь запомнить 11 карточек. Запомните 5 вопросов — каждый ведёт к своему паттерну.
паттерны)) Варьируется алгоритм Strategy подмена целиком, композиция Template Method варьируются только шаги, наследование Варьируется поведение по состоянию State объект меняет класс поведения на лету Memento снимок состояния для отката Запрос как объект Command undo, очередь, лог, повтор Chain of Responsibility цепочка обработчиков, каждый может съесть Interpreter грамматика как дерево объектов Кто с кем говорит Observer один-ко-многим, издатель не знает подписчиков Mediator много-ко-многим сводится к звезде Обход структуры Iterator перебор без знания внутренностей Visitor новая операция без правки типов
Дальше — по одному паттерну, от самого частого к самому редкому. Для каждого: интуиция, код, цена, где ломается.
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 ссылку меняет само состояние; реализации знают о соседях и образуют граф переходов.
Цена и границы. 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/redo — O(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: команда (меняет состояние, ничего не возвращает) отделена от запроса (читает, ничего не меняет). Подробнее — в треке архитектурных паттернов.
Типичные ошибки.
- Команда лезет в UI за аргументами.
class Save: def execute(self): path = dialog.ask()— такую команду нельзя ни повторить, ни выполнить в очереди. Аргументы фиксируются при создании. undo()не идемпотентен к повторномуredo. Тест-минимум:execute(); undo(); execute()должно давать то же, что простоexecute(), аundo()после этого — исходное состояние. Property-based тест на случайной последовательности команд ловит 90% таких багов.- Команда держит ссылки на живые объекты и течёт. История на 500 шагов удерживает 500 поддеревьев документа. Храните идентификаторы, а не объекты.
Observer: издатель не знает подписчиков
Проблема. Когда меняется цена товара, надо: пересчитать корзины, инвалидировать кэш, отправить
пуш подписчикам, записать в аналитику. Если Product.set_price вызывает всех четверых напрямую,
модель домена начинает зависеть от кэша, пушей и аналитики.
Решение. Субъект хранит список наблюдателей и оповещает их, ничего о них не зная.
иначе подписчики увидят
несуществующие данные S->>O1: on_price_changed(event) O1-->>S: ok S->>O2: on_price_changed(event) O2--xS: TimeoutError Note over S,O2: Кто виноват? Если исключение
всплывёт — O3 не получит событие,
а O1 уже отработал. Частичное
оповещение — главный баг Observer. S->>O3: on_price_changed(event) O3-->>S: ok
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, по убыванию частоты.
- Мутация списка во время обхода. Подписчик отписывается в обработчике →
RuntimeErrorили пропущенный сосед. Лечение: итерировать по копии (как выше). - Утечка памяти («lapsed listener»). Субъект живёт долго, подписчик — короткоживущий
объект, но ссылка из списка не даёт его собрать. Классика в GUI и в Android. Лечения два:
возвращать функцию отписки и требовать её вызова (как выше), либо хранить
weakref— но осторожно:weakrefна bound-метод умирает мгновенно, нуженWeakMethod. - Исключение подписчика ломает бизнес-операцию. Аналитика упала — заказ не сохранился. Решение: изолировать каждый вызов, а для критичных — не Observer, а явный вызов.
- Порядок оповещения — неявная зависимость. Если инвалидация кэша обязана произойти раньше пуша, вы уже не в Observer, а в скрытом пайплайне. Не подкручивайте порядок подписки — сделайте зависимость явной.
- Каскады и реентерабельность. Подписчик меняет субъект → новое оповещение → цикл. Лечение: флаг «идёт оповещение» + очередь отложенных событий, либо запрет мутаций в обработчиках.
- Оповещение до коммита транзакции. Подписчик читает БД и не видит данных. Правило:
собирать события в транзакции, публиковать после её коммита (в 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² связей), вводится
посредник, и связей становится 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.
выбран Circle.accept
(по типу элемента) N->>V: visit_circle(self) Note over V: 2-я диспетчеризация:
выбран AreaVisitor.visit_circle
(по типу визитёра) V->>N: radius N-->>V: 5.0 V-->>N: 78.5 N-->>C: 78.5
Это двойная диспетчеризация: в языках с одиночным виртуальным вызовом (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 нельзя применять «на всякий случай»:
Это проблема выражения (expression problem), сформулированная Филипом Вадлером в заметке 1998 года: нельзя одновременно дёшево добавлять и новые типы, и новые операции, не меняя существующий код. ООП без Visitor делает дешёвым добавление типов; Visitor делает дешёвым добавление операций. Отсюда правило: Visitor оправдан там, где иерархия типов стабильна, а операций много и они растут. Ровно один случай встречается постоянно — компиляторы, интерпретаторы, линтеры, трансформации AST. В бизнес-коде, где типы меняются каждый спринт, Visitor — чистый вред.
В Python двойную диспетчеризацию часто пишут короче — через singledispatchmethod, match по
типам или словарь type -> handler; это тот же Visitor без accept и с меньшей церемонией.
Родственник Visitor — Interpreter: грамматика выражается деревом объектов, каждый узел умеет
interpret(context). На практике его почти всегда вытесняет генератор парсеров или ручной
recursive descent, а вычисление дерева пишется как Visitor. Знать формулировку полезно, применять
буквально — редко.
Как выбирать: короткий алгоритм
меняется?} --> A[Алгоритм целиком] S --> B[Поведение по статусу] S --> C[Момент/место вызова] S --> D[Список реагирующих] S --> E[Набор операций над
стабильной иерархией] A --> A1{Скелет фиксирован,
варьируются шаги?} A1 -->|да| TM[Template Method] A1 -->|нет| ST[Strategy
в динамике — функция] B --> B1{Переходов много,
есть запрещённые?} B1 -->|да, и у состояний
свои данные| STT[State] B1 -->|нет| TBL[Таблица переходов
проще и проверяема] C --> C1{Нужны undo/
очередь/лог?} C1 -->|да| CMD[Command
+ Memento для отката] C1 -->|нет, нужна
цепочка фильтров| COR[Chain of Responsibility
middleware] D --> D1{Реагирующие
в одном процессе?} D1 -->|да| OBS[Observer
после коммита] D1 -->|нет| Q[Очередь событий
идемпотентные обработчики] E --> VIS[Visitor
только если типы стабильны] style TBL fill:#3fa66b,fill-opacity:0.2,stroke:#3fa66b style ST fill:#3fa66b,fill-opacity:0.2,stroke:#3fa66b style VIS fill:#c9793a,fill-opacity:0.2,stroke:#c9793a
Сводная таблица цены
| Паттерн | Что даёт | Стоимость | Когда НЕ надо |
|---|---|---|---|
| Strategy | замена алгоритма в рантайме, тестируемость | +класс на вариант, косвенность | вариант один и не меняется |
| Template Method | переиспользование скелета | жёсткая иерархия, хрупкий базовый класс | шаги хочется комбинировать |
| State | ветвление по статусу исчезает | n·m методов, граф размазан |
≤3 состояния, линейные переходы |
| Command | undo, очередь, лог, повтор | память истории, сериализуемость | вызов и так одноразовый |
| Observer | развязка издателя и подписчика | утечки, порядок, частичный сбой | подписчик ровно один и известен |
| Chain of Resp. | конфигурируемый пайплайн | глубина стека, «съеденный» запрос | шагов 2–3 и они фиксированы |
| Iterator | ленивый обход, инкапсуляция | инвалидация при мутации | язык уже даёт из коробки |
| Mediator | n² связей → n |
риск God Object | компонентов мало |
| Memento | откат без нарушения инкапсуляции | память под снимки | состояние огромное |
| Visitor | новая операция без правки типов | новый тип ломает все визитёры | иерархия типов растёт |
| Interpreter | грамматика как объекты | медленно, многословно | есть парсер-генератор |
Что важно унести
- Все 11 паттернов — ответ на растущее ветвление. Прежде чем брать паттерн, посмотрите, что именно ветвится: алгоритм (Strategy), статус (State), тип узла (Visitor), обработчик (CoR).
- UML не различает Strategy, State и Command. Различает намерение: кто меняет ссылку и зачем реализация существует.
- Функции первого класса схлопывают половину группы. Strategy, Command, Observer, Template Method с одним хуком — всё это функции и замыкания. Классы берите, когда нужны состояние, жизненный цикл или несколько методов.
- Самые дорогие грабли группы — у Observer: утечки подписчиков, порядок оповещения, частичный сбой, оповещение до коммита. Это не теория, это то, что чинят в проде.
- State против таблицы переходов — реальный выбор, и таблица чаще выигрывает. Граф, который можно проверить целиком, ценнее объектной элегантности.
- 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.
Справочники и реализации
- refactoring.guru/ru/design-patterns/behavioral-patterns — примеры на 8 языках.
- nodejs.org/api/events.html — Observer в стандартной библиотеке Node.js, включая предупреждение о лимите подписчиков (защита от утечек).
- erlang.org/doc/man/gen_statem — машина состояний как OTP-поведение.
- pkg.go.dev/net/http#Handler — Chain of Responsibility как основа всей веб-экосистемы Go.
Что дальше
Мы разобрали поведение в одном потоке. Как только потоков становится больше одного, знакомые паттерны ломаются: Observer получает гонки в списке подписчиков, Command — необходимость идемпотентности, State — атомарность перехода. Для конкурентного мира выработан отдельный словарь: пул воркеров, producer-consumer, future/promise, pipeline — со своими законами и своими способами всё испортить.
Следующая статья: Паттерны конкурентности: пул, producer-consumer, future, pipeline