Паттерн проектирования — типовое решение повторяющейся задачи в устройстве кода. Не библиотека и не готовый класс: схема, которую ты каждый раз пишешь заново под свою задачу. Каталог из 23 паттернов «банды четырёх» вышел в 1994 году под C++ и Smalltalk, и в Python половина из них короче на порядок: то, ради чего там заводят три класса, здесь делает функция, словарь или декоратор.
Ниже — восемь самых ходовых, все на одном сквозном примере: интернет-магазин с корзиной, доставкой, оплатой и уведомлениями. Для каждого показано, какую боль он лечит, как выглядит код без него и с ним, и когда он лишний. Весь код выполнен на CPython 3.14.5.
Три группы паттернов
Паттерны делят на три группы по вопросу, на который они отвечают: кто создаёт объекты, как объекты собираются в целое, как они договариваются между собой. Деление придумано не ради классификации — оно подсказывает, где искать: если больно в момент Класс(...), смотри порождающие; если больно от того, что объектов много и они переплетены, смотри структурные; если больно от того, кто кого зовёт, смотри поведенческие.
Паттерны группируются по задаче
Стратегия: подменить алгоритм, не трогая клиента
Стратегия выносит взаимозаменяемые способы что-то посчитать в отдельные объекты или функции, а клиент получает нужный способ снаружи. Признак, что она нужна: цепочка if по одному и тому же признаку, которая растёт с каждым новым случаем.
Вот как это выглядит до паттерна — доставка в магазине:
def delivery_cost(kind, order):
if kind == "самовывоз":
return 0
if kind == "курьер":
return 200 + 15 * order["distance"]
if kind == "почта":
return 150 + 40 * order["weight"]
raise ValueError(kind)
Функцию зовут из корзины, из письма, из админки и из расчёта возврата. Добавили постаматы — правишь функцию, которую держат четыре места, и тестируешь все четыре. Стратегия разрывает эту связь: каждый способ живёт сам по себе, а выбор делается словарём.
Оформление заказа не знает формулу доставки
В Python стратегия — это функция в словаре, без единого класса:
def pickup(order):
return 0
def courier(order):
return 200 + 15 * order["distance"]
def post(order):
return 150 + 40 * order["weight"]
DELIVERY = {"самовывоз": pickup, "курьер": courier, "почта": post}
order = {"distance": 12, "weight": 3}
for name, strategy in DELIVERY.items():
print(f"{name}: {strategy(order)} ₽")
самовывоз: 0 ₽
курьер: 380 ₽
почта: 270 ₽
Классический вид — с классами — нужен, когда у способа есть своё состояние: ключ к API, счётчик, настройки тарифа. Общий интерфейс при этом описывают через Protocol: он не требует наследования и проверяется типизатором, а не в рантайме.
Тот же приём на TypeScript — реестр функций, Record, satisfies и проверка имени из внешнего ввода — разобран в примере Strategy для маршрутизации ИИ‑провайдеров.
from typing import Protocol
class Delivery(Protocol):
def cost(self, order: dict) -> int: ...
class Courier:
def __init__(self, price_per_km: int):
self.price_per_km = price_per_km
def cost(self, order):
return 200 + self.price_per_km * order["distance"]
class Post:
def cost(self, order):
return 150 + 40 * order["weight"]
def checkout(order, delivery: Delivery):
return order["goods"] + delivery.cost(order)
order = {"goods": 1990, "distance": 12, "weight": 3}
print(checkout(order, Courier(price_per_km=15)))
print(checkout(order, Post()))
2370
2260
Когда лишняя. Если способ ровно один и второго не предвидится — стратегия только добавляет уровень косвенности. И если ветвление идёт по разным признакам (if user.is_premium and order.total > 3000), это не стратегия, а бизнес-правило: выносить его в «алгоритм» бессмысленно.
Фабрика: создать объект по имени
Фабрика — функция или метод, который по входным данным решает, объект какого класса создать, и возвращает готовый. Клиент называет строку "email", а не импортирует класс EmailSender.
Задача в магазине: уведомления уходят по каналу, который выбрал покупатель, а канал приходит строкой из базы.
Фабрика выбирает класс по ключу
class Email:
def send(self, text):
return f"письмо: {text}"
class Sms:
def send(self, text):
return f"смс: {text}"
class Telegram:
def send(self, text):
return f"телеграм: {text}"
SENDERS = {"email": Email, "sms": Sms, "telegram": Telegram}
def sender(channel):
try:
return SENDERS[channel]()
except KeyError:
raise ValueError(f"канал {channel!r} не подключён") from None
for channel in ["email", "telegram"]:
print(sender(channel).send("заказ №1042 собран"))
try:
sender("голубь")
except ValueError as exc:
print(exc)
письмо: заказ №1042 собран
телеграм: заказ №1042 собран
канал 'голубь' не подключён
Классы в словаре лежат как обычные значения: в Python класс — такой же объект, как число, и его можно положить в словарь, передать в функцию и вернуть. Отсюда следующий шаг — регистрация декоратором, когда класс сам вписывает себя в реестр и SENDERS не надо править вообще:
SENDERS = {}
def channel(name):
def register(cls):
SENDERS[name] = cls
return cls
return register
@channel("email")
class Email:
def send(self, text):
return f"письмо: {text}"
@channel("push")
class Push:
def send(self, text):
return f"пуш: {text}"
print(sorted(SENDERS))
print(SENDERS["push"]().send("курьер выехал"))
['email', 'push']
пуш: курьер выехал
Когда лишняя. Если класс один — пиши Email() прямо. Фабрика окупается с третьего варианта или когда выбор приходит извне: из базы, конфига, тела запроса.
Чего остерегаться. Реестр по словарю прячет ошибку в ключе до рантайма: опечатка "emial" компилируется и падает у покупателя. Поэтому в примере выше KeyError превращён в понятный ValueError с именем канала — raise ... from None убирает лишний внутренний KeyError из трассировки.
Одиночка: один объект на всё приложение
Одиночка гарантирует, что объект класса создан ровно один раз, и отдаёт всем один и тот же экземпляр. Типичный повод — конфигурация, пул соединений, клиент внешнего API: читать файл настроек на каждый вызов незачем.
Классическая реализация переопределяет __new__:
class Config:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.currency = "RUB"
return cls._instance
first = Config()
second = Config()
print(first is second)
second.currency = "USD"
print(first.currency)
True
USD
Вторая строка вывода показывает и главный минус: состояние стало общим и глобальным. Кто угодно из любого места меняет currency, а найти виновника потом нечем — обычная отладка показывает только результат.
В Python у одиночки есть два более коротких способа, и оба лучше __new__.
Один экземпляр можно обеспечить разными средствами
sys.modules.Модуль — уже одиночка, и это легко проверить:
import math
import sys
import math as alias
print(math is alias)
print(sys.modules["math"] is math)
True
True
Если нужен именно объект с ленивой инициализацией — годится functools.cache поверх функции-конструктора. Дорогая работа делается при первом обращении, дальше возвращается тот же объект:
from functools import cache
class Config:
def __init__(self):
print("читаю настройки с диска")
self.currency = "RUB"
@cache
def config():
return Config()
print(config() is config())
print(config().currency)
читаю настройки с диска
True
RUB
Когда лишняя. Почти всегда, если объект нужен ради удобства, а не по необходимости. Одиночка — это глобальная переменная с хорошими манерами: она склеивает модули между собой и мешает тестам, потому что состояние переезжает из теста в тест. Передать объект аргументом скучнее, но честнее.
Наблюдатель: сообщить всем, кто подписался
Наблюдатель позволяет объекту рассылать событие списку подписчиков, ничего не зная о том, кто они и сколько их. В магазине это момент оплаты: заказ оплачен — и дальше склад резервирует товар, бухгалтерия пробивает чек, покупателю уходит письмо.
Без паттерна метод pay() знает про склад, кассу и почту сразу, и каждая новая реакция дописывается внутрь него. С паттерном pay() знает только про список функций.
Одно событие запускает независимых подписчиков
pay().class Order:
def __init__(self, number):
self.number = number
self._subscribers = []
def subscribe(self, handler):
self._subscribers.append(handler)
def pay(self):
for handler in self._subscribers:
handler(self)
def reserve_stock(order):
print(f"склад: резервирую товары заказа №{order.number}")
def send_receipt(order):
print(f"чек: пробил чек по заказу №{order.number}")
def notify_buyer(order):
print(f"письмо: заказ №{order.number} оплачен")
order = Order(1042)
order.subscribe(reserve_stock)
order.subscribe(send_receipt)
order.subscribe(notify_buyer)
order.pay()
склад: резервирую товары заказа №1042
чек: пробил чек по заказу №1042
письмо: заказ №1042 оплачен
У наблюдателя есть неочевидная ловушка: подписчики выполняются в одном цикле, и первый же упавший обрывает остальных. Покупатель не получит письмо из-за того, что не ответил склад:
class Order:
def __init__(self, number):
self.number = number
self._subscribers = []
def subscribe(self, handler):
self._subscribers.append(handler)
def pay(self):
for handler in self._subscribers:
try:
handler(self)
except Exception as exc:
print(f"подписчик {handler.__name__} упал: {exc}")
def reserve_stock(order):
raise RuntimeError("склад не отвечает")
def notify_buyer(order):
print(f"письмо: заказ №{order.number} оплачен")
order = Order(1042)
order.subscribe(reserve_stock)
order.subscribe(notify_buyer)
order.pay()
подписчик reserve_stock упал: склад не отвечает
письмо: заказ №1042 оплачен
Перехват внутри цикла — минимум, без которого паттерн опасен. Разбор того, что ловить и что не глушить, — в статье про try/except.
Когда лишний. Если подписчик один и он всегда один, прямой вызов читается лучше. И помни цену: по коду больше не видно, что произойдёт после pay() — чтобы это узнать, придётся искать все места подписки.
Декоратор: добавить поведение обёрткой
Декоратор оборачивает объект или функцию другим объектом с тем же интерфейсом и добавляет поведение до и после вызова. Исходный код при этом не меняется — и в этом весь смысл: логирование, кеш, повтор при сбое, замер времени навешиваются снаружи.
В Python для этого есть синтаксис @, поэтому паттерн выглядит как часть языка:
Обёртки добавляют поведение вокруг одного вызова
@log над @cache печатает все вызовы, включая закешированные; поменяй их местами — и повторные вызовы исчезнут из лога.import functools
def log(func):
@functools.wraps(func)
def wrapper(*args):
shown = ", ".join(repr(arg) for arg in args)
print(f"→ {func.__name__}({shown})")
return func(*args)
return wrapper
@log
@functools.cache
def rate(currency):
print(f" иду в банк за курсом {currency}")
return 92
print(rate("USD"))
print(rate("USD"))
→ rate('USD')
иду в банк за курсом USD
92
→ rate('USD')
92
Второй вызов до банка не дошёл — его перехватил @functools.cache, но @log отработал оба раза, потому что стоит снаружи. Подробный разбор синтаксиса, functools.wraps и декораторов с аргументами — в отдельной статье про декораторы.
Объектная версия из книги нужна там, где оборачивать надо не функцию, а объект с несколькими методами: обёртка реализует тот же интерфейс, хранит внутреннюю ссылку и делегирует. Именно так устроены io.BufferedReader поверх сырого файла и gzip.GzipFile поверх любого потока — обёртка, а не наследник.
Когда лишний. Если поведение нужно ровно одной функции и навсегда — впиши его внутрь. Декоратор окупается, когда одну и ту же добавку вешают на десяток функций.
Адаптер: подружить чужой интерфейс со своим
Адаптер — прослойка, которая переводит вызовы твоего кода в вызовы чужого. Нужен, когда библиотеку менять нельзя, а её интерфейс не совпадает с тем, который ты уже используешь везде.
В магазине оплата принимает pay(рубли). Платёжный шлюз говорит на своём языке: копейки, коды валют, словарь в ответе. Адаптер прячет этот перевод в одном месте — иначе он расползётся по всему коду вместе с делением на 100.
Адаптер переводит контракт в формат чужого SDK
class YooKassa:
def create_payment(self, amount_cents, currency):
return {"id": "2f0a", "status": "pending", "sum": amount_cents / 100}
class Cashbox:
def take(self, rubles):
return f"принято наличными {rubles} ₽"
class YooKassaAdapter:
def __init__(self, api):
self._api = api
def pay(self, rubles):
payment = self._api.create_payment(rubles * 100, "RUB")
return f"платёж {payment['id']}: {payment['sum']:.0f} ₽"
class CashboxAdapter:
def __init__(self, cashbox):
self._cashbox = cashbox
def pay(self, rubles):
return self._cashbox.take(rubles)
def checkout(total, method):
return method.pay(total)
print(checkout(2260, YooKassaAdapter(YooKassa())))
print(checkout(2260, CashboxAdapter(Cashbox())))
платёж 2f0a: 2260 ₽
принято наличными 2260 ₽
Функция checkout не знает ни про копейки, ни про словарь ответа. Заменить шлюз — значит написать новый адаптер, а не искать по проекту умножения на 100.
Когда лишний. Если чужой интерфейс используется в одном месте — зови его напрямую. Адаптер нужен, когда вызовов много или когда одну и ту же операцию делают несколько разных поставщиков.
Фасад: одна дверь вместо пяти вызовов
Фасад — объект с простым методом, за которым спрятана последовательность вызовов к нескольким подсистемам. Клиент перестаёт знать порядок шагов, а порядок шагов перестаёт дублироваться в каждом месте, где нужен заказ.
Оформление заказа в магазине — четыре шага, и все четыре должны идти в правильном порядке: резерв, оплата, чек, письмо. Если это набирают руками в контроллере, в фоновом джобе и в админке, рано или поздно где-то забудут чек.
Фасад даёт одну точку входа в несколько подсистем
class Stock:
def reserve(self, items):
print(f"склад: зарезервировал позиций — {len(items)}")
class Payments:
def charge(self, total):
print(f"оплата: списал {total} ₽")
return "ok"
class Receipts:
def issue(self, total):
print(f"чек: пробил {total} ₽")
class Mail:
def order_paid(self, number):
print(f"письмо: заказ №{number} оплачен")
class Checkout:
def __init__(self):
self._stock = Stock()
self._payments = Payments()
self._receipts = Receipts()
self._mail = Mail()
def place(self, number, items, total):
self._stock.reserve(items)
if self._payments.charge(total) != "ok":
return False
self._receipts.issue(total)
self._mail.order_paid(number)
return True
print(Checkout().place(1042, ["чайник", "кружка"], 2260))
склад: зарезервировал позиций — 2
оплата: списал 2260 ₽
чек: пробил 2260 ₽
письмо: заказ №1042 оплачен
True
Когда лишний. Если подсистема одна — фасад над ней это просто лишний файл. И следи, чтобы он не начал расти: фасад, который вместо делегирования сам считает скидки и валидирует адрес, превращается в тот самый «божественный объект», от которого паттерн должен был спасти.
Команда: превратить действие в объект
Команда упаковывает вызов вместе с аргументами в объект, у которого есть метод «сделать» и, если нужно, «отменить». Как только действие стало объектом, его можно положить в список, отправить в очередь, повторить и откатить.
Отмена — главная причина, по которой этот паттерн живёт: чтобы отменить действие, нужно помнить, что именно оно изменило. Функция этого не помнит, объект помнит.
Команда хранит действие, чтобы его можно было отменить
class Cart:
def __init__(self):
self.items = []
class Add:
def __init__(self, item):
self.item = item
def do(self, cart):
cart.items.append(self.item)
def undo(self, cart):
cart.items.pop()
class Clear:
def __init__(self):
self.saved = []
def do(self, cart):
self.saved = cart.items[:]
cart.items.clear()
def undo(self, cart):
cart.items[:] = self.saved
class History:
def __init__(self, cart):
self.cart = cart
self.done = []
def run(self, command):
command.do(self.cart)
self.done.append(command)
def undo(self):
self.done.pop().undo(self.cart)
cart = Cart()
history = History(cart)
history.run(Add("чайник"))
history.run(Add("кружка"))
print(cart.items)
history.run(Clear())
print(cart.items)
history.undo()
print(cart.items)
history.undo()
print(cart.items)
['чайник', 'кружка']
[]
['чайник', 'кружка']
['чайник']
В этом примере корзину меняют только через историю, а команды отменяют в обратном порядке. Поэтому Add.undo() снимает последний элемент через pop(): remove(self.item) удалил бы первое совпадение и нарушил порядок при повторяющихся товарах.
Когда лишняя. Если отмена, очередь и повтор не нужны — команда это классы вокруг обычного вызова. Для «просто отложить вызов» хватает functools.partial или лямбды: они тоже упаковывают функцию с аргументами, только не умеют откатывать.
Что Python уже умеет без паттернов
Часть каталога «банды четырёх» существует, чтобы обойти ограничения языков, где функция не объект, а класс нельзя положить в словарь. В Python таких ограничений нет, и три популярных паттерна сворачиваются во встроенные возможности.
Итератор — протокол языка. Достаточно __iter__, и объект работает в for, в распаковке, в in, в list():
class Cart:
def __init__(self, items):
self.items = items
def __iter__(self):
return iter(self.items)
cart = Cart(["чайник", "кружка", "поднос"])
for item in cart:
print(item)
print("кружка" in cart)
чайник
кружка
поднос
True
Ленивый обход, ради которого в других языках пишут класс-итератор с полем-позицией, в Python пишется как генератор — одна функция с yield.
Строитель — dataclass с значениями по умолчанию и именованными аргументами. Пошаговая сборка объекта через цепочку .with_x().with_y() нужна там, где нет именованных аргументов; здесь они есть:
from dataclasses import dataclass, field
@dataclass
class Order:
number: int
items: list[str] = field(default_factory=list)
delivery: str = "курьер"
comment: str = ""
print(Order(1042, ["чайник"], comment="позвонить за час"))
Order(number=1042, items=['чайник'], delivery='курьер', comment='позвонить за час')
Шаблонный метод — базовый класс со скелетом алгоритма и дырками под подклассы. Он в Python остаётся собой, но abc делает пропуск обязательного шага ошибкой при создании объекта, а не загадочным поведением потом:
from abc import ABC, abstractmethod
class Report(ABC):
def build(self):
return self.header() + "\n" + "\n".join(self.rows())
def header(self):
return "отчёт"
@abstractmethod
def rows(self): ...
class Sales(Report):
def header(self):
return "продажи за день"
def rows(self):
return ["чайник — 1990 ₽", "кружка — 270 ₽"]
print(Sales().build())
class Empty(Report):
pass
try:
Empty()
except TypeError as exc:
print(exc)
продажи за день
чайник — 1990 ₽
кружка — 270 ₽
Can't instantiate abstract class Empty without an implementation for abstract method 'rows'
Сюда же — контекстный менеджер: with решает ту же задачу, ради которой в C++ придумали RAII, а в Java пишут try/finally руками. Разбор — в статье про контекстные менеджеры.
Как выбрать паттерн
Паттерн не выбирают заранее — к нему приходят, когда код начал болеть. Порядок обычно такой:
- Напиши прямо, без паттернаПервая версия обязана быть тупой и работающей. Паттерн, вставленный до появления второго варианта, — это чистая цена без выгоды.
- Дождись третьего повторенияДва похожих места — совпадение, три — правило. До третьего непонятно, что именно повторяется: обычно первая догадка о «переменной части» оказывается неверной.
- Назови, что именно меняетсяСпособ посчитать — стратегия. Класс, который создаётся, — фабрика. Реакция на событие — наблюдатель. Порядок шагов — фасад. Формулировка «что меняется» и есть выбор паттерна.
- Возьми самый дешёвый вариант из подходящихВ Python это почти всегда функция, словарь или декоратор. Классы заводи, когда у варианта есть собственное состояние.
- Проверь, что стало прощеЕсли после рефакторинга новый случай добавляется в одном месте — паттерн сработал. Если по-прежнему приходится править три файла, ты угадал неверную ось изменений.
Ошибки, на которых спотыкаются
Паттерн ради паттерна. Фабрика, создающая один-единственный класс, и стратегия с одной реализацией — самый частый способ сделать код хуже, потратив на это день. Признак: чтобы понять, что произойдёт при вызове, нужно открыть четыре файла.
Классы там, где хватает функции. Прямой перенос примеров из книги 1994 года даёт AbstractDeliveryStrategyFactory, хотя в Python это словарь с тремя функциями. Проверяй каждый класс вопросом «какое состояние он хранит?» — если никакого, это функция.
Одиночка вместо аргумента. Соблазн сделать конфиг глобальным велик, а расплата приходит в тестах: состояние переезжает из теста в тест, порядок запуска начинает влиять на результат. Одиночка — последнее средство, а не первое.
Наследование вместо композиции. Паттерны почти всегда собирают поведение из объектов, а не наследуют его. Наследник знает внутренности родителя и ломается при его изменении, обёртка знает только интерфейс.
Паттерн выбран по названию, а не по задаче. «Адаптер» и «Фасад» на диаграмме похожи: оба стоят между клиентом и чужим кодом. Разница в цели: адаптер меняет интерфейс, не упрощая; фасад упрощает, не меняя. Если ответить, что из этого нужно, нельзя — паттерн пока не нужен.
Частые вопросы
Сколько всего паттернов проектирования
В классическом каталоге «банды четырёх» 1994 года — 23 паттерна: 5 порождающих, 7 структурных, 11 поведенческих. С тех пор описаны десятки других (репозиторий, единица работы, внедрение зависимостей), но эти 23 остаются общим языком, на котором разработчики договариваются об устройстве кода.
Нужны ли паттерны в Python
Нужны как словарь и как набор проверенных решений, но не как код для копирования. Часть паттернов в Python короче на порядок: стратегия — функция, фабрика — словарь классов, одиночка — модуль. Часть работает без изменений: наблюдатель, команда, адаптер, фасад.
Чем паттерн отличается от антипаттерна
Паттерн — решение, которое повторно доказало пользу. Антипаттерн — решение, которое повторно доказало вред: «божественный объект», знающий всё; «спагетти» без границ; «золотой молоток», когда одним инструментом решают любую задачу. Паттерн, вставленный без задачи, сам становится антипаттерном.
С какого паттерна начать
Со стратегии. Она встречается чаще всех, лечит самую распространённую боль — растущую цепочку if, — и в Python записывается настолько коротко, что ошибиться в реализации сложно.
Чем паттерн отличается от архитектуры
Масштабом. Паттерн — про устройство нескольких классов внутри модуля. Архитектура — про границы между модулями и сервисами: где проходит слой, кто кого имеет право звать, где живут данные. Ошибку в паттерне переписывают за час, ошибку в архитектуре — месяцами.
Где потренироваться
В пути «Python: ООП» на Koddo классы разбираются на тех же сценариях, что и выше: объект со своим состоянием, композиция вместо наследования, протокол вместо базового класса. Задачи проверяются тестами в браузере, а подсказку ассистента можно попросить, не открывая решение.