Стратегия, наблюдатель, фабрика: как они выглядят в Python
Классические паттерны GoF в Python часто схлопываются до функции, декоратора или словаря. Разбираем, где отдельный класс лишний, а где всё же нужен, и чем платишь за каждый вариант.

Вы открываете чужой код на Python и видите класс StrategyFactory, который создаёт объект ConcreteStrategy с единственным методом execute(). Внутри метода — одна строчка. Это прямой перенос примеров из книги «банды четырёх», написанных под C++ и Java, где функция не может жить сама по себе. В Python функция — объект первого класса, и три классических паттерна из GoF схлопываются до заметно более простых конструкций. Разберём, во что превращаются стратегия, наблюдатель и фабрика, где отдельный класс всё-таки оправдан и чем вы платите за каждый вариант.
Всё, что ниже — про CPython 3.11+, стандартную библиотеку без сторонних зависимостей. Синтаксис match требует 3.10+, там где он встречается.
Стратегия: чаще всего это просто функция
Задача паттерна «стратегия» — подменять алгоритм во время выполнения. Каноничная реализация: интерфейс с методом, несколько классов-наследников, объект-контекст, который держит ссылку на текущую стратегию. В языках без функций-объектов иначе алгоритм не передашь.
В Python алгоритм — это функция, и её можно передать напрямую.
from typing import Callable
# Каждая стратегия — обычная функция с одинаковой сигнатурой
def sort_by_price(items: list[dict]) -> list[dict]:
return sorted(items, key=lambda x: x["price"])
def sort_by_rating(items: list[dict]) -> list[dict]:
return sorted(items, key=lambda x: -x["rating"])
# Контекст принимает функцию, а не объект-стратегию
def render_catalog(items: list[dict],
strategy: Callable[[list[dict]], list[dict]]) -> list[dict]:
return strategy(items)
goods = [{"price": 300, "rating": 4.5}, {"price": 100, "rating": 4.9}]
render_catalog(goods, sort_by_price) # передаём саму функцию
render_catalog(goods, sort_by_rating)
Никаких классов, никакого self, который ни к чему не обращается. Тип Callable[[list[dict]], list[dict]] из typing заменяет абстрактный интерфейс: он и есть контракт стратегии.
Когда класс всё же нужен
Функция перестаёт хватать, когда у стратегии есть состояние или несколько связанных методов. Пример: стратегия скидок, которая помнит накопленную сумму по клиенту между вызовами. Тогда либо замыкание, либо класс с __call__.
class ThresholdDiscount:
"""Стратегия со внутренним порогом — состояние оправдывает класс."""
def __init__(self, threshold: int, percent: int):
self.threshold = threshold
self.percent = percent
def __call__(self, total: int) -> int:
# объект ведёт себя как функция благодаря __call__
if total >= self.threshold:
return total - total * self.percent // 100
return total
discount = ThresholdDiscount(threshold=1000, percent=10)
discount(1500) # 1350 — вызываем экземпляр как функцию
Такой объект по-прежнему подходит под Callable, поэтому render_catalog и любая функция-контекст не заметят разницы между ним и обычной функцией. В этом и суть: интерфейс стратегии в Python — вызываемость, а не имя базового класса.
Если у стратегии нет ни состояния, ни второго метода — класс не добавляет ничего, кроме строчкиclassи словаself. Передавайте функцию.
Наблюдатель: список колбэков вместо иерархии
Паттерн «наблюдатель» уведомляет подписчиков об изменении субъекта. В классической форме — абстрактный Observer с методом update() и субъект, который держит список объектов-наблюдателей и обходит их.
В Python подписчик снова может быть просто функцией, а «интерфейс» — сигнатурой колбэка.
from typing import Callable
EventCallback = Callable[[str], None]
class OrderService:
def __init__(self) -> None:
self._subscribers: list[EventCallback] = []
def subscribe(self, callback: EventCallback) -> None:
self._subscribers.append(callback)
def _notify(self, order_id: str) -> None:
for callback in self._subscribers:
callback(order_id)
def create_order(self, order_id: str) -> None:
# ...бизнес-логика создания заказа...
self._notify(order_id)
# Подписчики — обычные функции
def send_email(order_id: str) -> None:
print(f"Письмо по заказу {order_id}")
def write_log(order_id: str) -> None:
print(f"Лог: заказ {order_id} создан")
service = OrderService()
service.subscribe(send_email)
service.subscribe(write_log)
service.create_order("A-1001")
Разница с Java-версией — не только в отсутствии базового класса. В Python естественно хранить список функций, а не объектов с методом update. Это ближе к тому, как в языке уже работают события: тот же принцип лежит в основе сигналов Django или обработчиков в asyncio.
Две ловушки списка колбэков
- Отписка. Чтобы удалить подписчика через
self._subscribers.remove(callback), нужно передать ту же самую ссылку. Лямбда, созданная на лету при подписке, отписаться уже не даст — её объект не сохранён. Храните ссылку, если планируете отписку. - Исключение в одном колбэке рвёт цикл. Если
send_emailбросит исключение внутри_notify, остальные подписчики не получат событие. Оберните вызов вtry/except, если подписчики независимы и падение одного не должно ронять остальных.
Слабые ссылки на подписчиков через weakref, чтобы не держать объекты живыми, — отдельная тема, здесь её не разворачиваем.
Фабрика: словарь вместо ветвления по типам
Фабрика создаёт объект по параметру, скрывая конкретный класс. В классической реализации это метод с цепочкой if/elif по строке-типу. В Python на входе — та же строка, но выбор удобнее делать через словарь «ключ → класс или функция-конструктор».
class JSONExporter:
def export(self, data: dict) -> str: ...
class CSVExporter:
def export(self, data: dict) -> str: ...
class XMLExporter:
def export(self, data: dict) -> str: ...
# Реестр вместо if/elif — расширяется без правки функции
_EXPORTERS = {
"json": JSONExporter,
"csv": CSVExporter,
"xml": XMLExporter,
}
def make_exporter(fmt: str):
try:
return _EXPORTERS[fmt]() # значение словаря — класс, вызываем его
except KeyError:
raise ValueError(f"Неизвестный формат: {fmt}")
exporter = make_exporter("csv")
Плюс словаря перед цепочкой if/elif: добавить формат — это одна строка в _EXPORTERS, а не новая ветка внутри функции. Значением может быть не только класс, но и любая вызываемая фабрика — функция, partial, лямбда с преднастроенными аргументами.
Если ветвление сложное — например, выбор зависит от нескольких полей, а не от одной строки, — читаемее оказывается match (Python 3.10+):
def make_exporter_v2(fmt: str, compressed: bool):
match (fmt, compressed):
case ("json", False):
return JSONExporter()
case ("json", True):
return GzipJSONExporter()
case ("csv", _):
return CSVExporter()
case _:
raise ValueError(f"Не поддерживается: {fmt}")
Здесь словарь пришлось бы городить с кортежами-ключами и терять читаемость, а match раскладывает комбинации явно.
Когда что выбирать
Критерий один: есть ли у сущности состояние или несколько связанных методов. Если нет — берите функцию или словарь и не плодите классы.
| Паттерн | Питоничная форма | Класс нужен, когда | Чем платите за упрощение |
|---|---|---|---|
| Стратегия | Функция, переданная как аргумент | У стратегии есть состояние или 2+ метода | Меньше явной структуры — контракт держится на типе Callable |
| Наблюдатель | Список функций-колбэков | Подписчик — объект с несколькими реакциями | Отписка и обработка ошибок ложатся на вас вручную |
| Фабрика | Словарь «ключ → класс/функция» | Логика выбора зависит от нескольких условий → match | Нет проверки на этапе импорта: опечатка в ключе всплывёт в рантайме |
Что ломается на практике
- «Класс ради интерфейса». В статически типизированных языках базовый класс — единственный способ задать контракт. В Python его роль выполняют аннотации
Callableиtyping.Protocol. Если пишетеABCс одним абстрактным методом, чтобы «был интерфейс», спросите, что он даёт кроме лишнего файла. - Реестр фабрики и опечатки. Строковый ключ не проверяется до вызова. Литеральные типы
Literal["json", "csv", "xml"]в аннотации плюс проверка mypy ловят опечатку до запуска — иначе получитеValueErrorтолько в проде. - Замыкания в цикле. Классическая ловушка при создании колбэков в цикле: лямбда захватывает переменную, а не её значение на момент создания. Все колбэки в итоге видят последнее значение. Фиксируйте значение через аргумент по умолчанию:
lambda x, v=current: ....
Что делать дальше
Пройдитесь по своему коду и найдите классы с единственным методом и без полей в __init__, кроме сохранённых аргументов, — это первые кандидаты на замену функцией. Для контрактов посмотрите typing.Protocol: он даёт структурную типизацию без наследования и заменяет добрую половину абстрактных базовых классов. Если фабрик много и они разбросаны — оцените реестр с регистрацией через декоратор вместо словаря-константы. И держите mypy в CI: без него питоничные упрощения превращают ошибки типов из ловимых на проверке в ловимые пользователем.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: typing — Support for type hints (Python documentation), PEP 634 — Structural Pattern Matching: Specification
Частые вопросы
А если у меня Python 3.9, где нет match?
Разве отказ от классов не делает код менее читаемым для команды с Java-опытом?
Чем typing.Protocol лучше абстрактного базового класса для стратегии?
Не сработало: список подписчиков remove() бросает ValueError.
Реестр фабрики через словарь-константу — а если формат неизвестен только в рантайме?
Стоит ли переписывать существующий код с классов на функции ради питоничности?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.