Поведенческие паттерны: 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 ссылку меняет само состояние; реализации знают о соседях и образуют граф переходов.
Цена и границы. 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 вызывает всех четверых напрямую,
модель домена начинает зависеть от кэша, пушей и аналитики.
Решение. Субъект хранит список наблюдателей и оповещает их, ничего о них не зная.
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.
Это двойная диспетчеризация: в языках с одиночным виртуальным вызовом (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. Знать формулировку полезно, применять
буквально — редко.
Как выбирать: короткий алгоритм
Сводная таблица цены
| Паттерн | Что даёт | Стоимость | Когда НЕ надо |
|---|---|---|---|
| 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