Паттерны проектирования в Python

Python Автор: Среда и версия: CPython 3.14.5
содержание

Паттерн проектирования — типовое решение повторяющейся задачи в устройстве кода. Не библиотека и не готовый класс: схема, которую ты каждый раз пишешь заново под свою задачу. Каталог из 23 паттернов «банды четырёх» вышел в 1994 году под C++ и Smalltalk, и в Python половина из них короче на порядок: то, ради чего там заводят три класса, здесь делает функция, словарь или декоратор.

Ниже — восемь самых ходовых, все на одном сквозном примере: интернет-магазин с корзиной, доставкой, оплатой и уведомлениями. Для каждого показано, какую боль он лечит, как выглядит код без него и с ним, и когда он лишний. Весь код выполнен на CPython 3.14.5.

Три группы паттернов

Паттерны делят на три группы по вопросу, на который они отвечают: кто создаёт объекты, как объекты собираются в целое, как они договариваются между собой. Деление придумано не ради классификации — оно подсказывает, где искать: если больно в момент Класс(...), смотри порождающие; если больно от того, что объектов много и они переплетены, смотри структурные; если больно от того, кто кого зовёт, смотри поведенческие.

Python / 01

Паттерны группируются по задаче

Паттерны группируются по задаче01 / вход 02 / операция 03 / результат создать объект порождающие Factory · Builder Singleton соединить объекты структурные Adapter · Facade Decorator разделить поведение поведенческие Strategy · Observer Command01 / вход02 / операция03 / результатсоздать объектпорождающиеFactory · BuilderSingletonсоединить объектыструктурныеAdapter · FacadeDecoratorразделитьповедениеповеденческиеStrategy · ObserverCommand
Порождающие паттерны управляют созданием, структурные — связями объектов, поведенческие — распределением работы. В Python часть задач Builder решает dataclass.

Стратегия: подменить алгоритм, не трогая клиента

Стратегия выносит взаимозаменяемые способы что-то посчитать в отдельные объекты или функции, а клиент получает нужный способ снаружи. Признак, что она нужна: цепочка 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 / 02

Оформление заказа не знает формулу доставки

Оформление заказа не знает формулу доставки01 / вход 02 / операция 03 / результат самовывоз pickup(order) 0 ₽ курьер courier(order) 200 + 15 × км почта post(order) 150 + 40 × кг01 / вход02 / операция03 / результатсамовывозpickup(order)0 ₽курьерcourier(order)200 + 15 × кмпочтаpost(order)150 + 40 × кг
Клиент вызывает один метод расчёта. Формула зависит от выбранной стратегии: новый способ доставки добавляется отдельно.

В 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.

Задача в магазине: уведомления уходят по каналу, который выбрал покупатель, а канал приходит строкой из базы.

Python / 03

Фабрика выбирает класс по ключу

Фабрика выбирает класс по ключу01 / вход 02 / операция 03 / результат email SENDERS["email"] Email() sms SENDERS["sms"] Sms() telegram SENDERS["telegram"] Telegram()01 / вход02 / операция03 / результатemailSENDERS["email"]Email()smsSENDERS["sms"]Sms()telegramSENDERS["telegram"]Telegram()
Слева входит строка из базы, справа выходит готовый объект. Ветвление живёт в одном месте — в реестре, а не в пятнадцати вызовах по коду.
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__.

Python / 04

Один экземпляр можно обеспечить разными средствами

Один экземпляр можно обеспечить разными средствами01 Singleton-класс Class() → instance Класс контролирует создание и возвращает общий экземпляр. 02 Модуль Python import config Импортированный модуль кэшируется: часто этого достаточно для общего состояния.01Singleton-классClass() → instanceКласс контролирует создание и возвращаетобщий экземпляр.02Модуль Pythonimport configИмпортированный модуль кэшируется:часто этого достаточно для общегосостояния.
Справа — то же свойство без единой строки кода: Python выполняет файл модуля один раз, а все последующие импорты достают готовый объект из 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() знает только про список функций.

Python / 05

Одно событие запускает независимых подписчиков

Одно событие запускает независимых подписчиков01 / вход 02 / операция 03 / результат Order 1042 создан склад зарезервировать то же событие чеки сформировать чек то же событие покупатель отправить сообщение01 / вход02 / операция03 / результатOrder 1042 созданскладзарезервироватьто же событиечекисформировать чекто же событиепокупательотправить сообщение
Заказ не знает ни одного подписчика по имени — он знает только список. Четвёртая реакция (аналитика, бонусные баллы) добавляется одной строкой подписки и не трогает 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 для этого есть синтаксис @, поэтому паттерн выглядит как часть языка:

Python / 06

Обёртки добавляют поведение вокруг одного вызова

Обёртки добавляют поведение вокруг одного вызова01 Внешний декоратор log → cache Логирование передаёт вызов кэшу. 02 Попадание в кэш USD → сохранённый курс Исходный источник не вызывается. 03 Промах источник → курс → cache Значение сохраняется и возвращается через обёртки.01Внешний декораторlog → cacheЛогирование передаёт вызов кэшу.02Попадание в кэшUSD → сохранённый курсИсходный источник не вызывается.03Промахисточник → курс → cacheЗначение сохраняется и возвращается через обёртки.
Порядок важен: то, что написано выше, оборачивает снаружи. @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.

Python / 07

Адаптер переводит контракт в формат чужого SDK

Адаптер переводит контракт в формат чужого SDK01 Клиент pay(2260) Сумма передана в рублях. 02 Адаптер 2260 × 100 = 226000 Переводим рубли в копейки для SDK из примера. 03 Чужой интерфейс YooKassa SDK Особенности SDK остаются внутри адаптера.01Клиентpay(2260)Сумма передана в рублях.02Адаптер2260 × 100 = 226000Переводим рубли в копейки для SDK из примера.03Чужой интерфейсYooKassa SDKОсобенности SDK остаются внутри адаптера.
Адаптер переводит рубли в копейки и ответ 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.

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

Фасад: одна дверь вместо пяти вызовов

Фасад — объект с простым методом, за которым спрятана последовательность вызовов к нескольким подсистемам. Клиент перестаёт знать порядок шагов, а порядок шагов перестаёт дублироваться в каждом месте, где нужен заказ.

Оформление заказа в магазине — четыре шага, и все четыре должны идти в правильном порядке: резерв, оплата, чек, письмо. Если это набирают руками в контроллере, в фоновом джобе и в админке, рано или поздно где-то забудут чек.

Python / 08

Фасад даёт одну точку входа в несколько подсистем

Фасад даёт одну точку входа в несколько подсистем01 Вызов клиента checkout.place(order) Клиент описывает действие целиком. 02 Подсистемы stock → payments Проверяем остатки и проводим оплату. 03 Завершение receipts → mail Создаём чек и отправляем сообщение.01Вызов клиентаcheckout.place(order)Клиент описывает действие целиком.02Подсистемыstock → paymentsПроверяем остатки и проводим оплату.03Завершениеreceipts → mailСоздаём чек и отправляем сообщение.
Фасад не запрещает лезть в подсистемы напрямую — там, где нужен нестандартный сценарий, склад доступен как и раньше. Он лишь даёт короткую дорогу для типичного случая.
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

Когда лишний. Если подсистема одна — фасад над ней это просто лишний файл. И следи, чтобы он не начал расти: фасад, который вместо делегирования сам считает скидки и валидирует адрес, превращается в тот самый «божественный объект», от которого паттерн должен был спасти.

Команда: превратить действие в объект

Команда упаковывает вызов вместе с аргументами в объект, у которого есть метод «сделать» и, если нужно, «отменить». Как только действие стало объектом, его можно положить в список, отправить в очередь, повторить и откатить.

Отмена — главная причина, по которой этот паттерн живёт: чтобы отменить действие, нужно помнить, что именно оно изменило. Функция этого не помнит, объект помнит.

Python / 09

Команда хранит действие, чтобы его можно было отменить

Команда хранит действие, чтобы его можно было отменить01 / вход 02 / операция 03 / результат Add("чайник") execute() Cart: [чайник] Clear execute() Cart: [] History.undo() Clear.undo() Cart: [чайник]01 / вход02 / операция03 / результатAdd("чайник")execute()Cart: [чайник]Clearexecute()Cart: []History.undo()Clear.undo()Cart: [чайник]
Перед очисткой Clear сохраняет содержимое корзины. Поэтому undo может вернуть чайник; обычный cart.items.clear() историю не хранит.
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 руками. Разбор — в статье про контекстные менеджеры.

Как выбрать паттерн

Паттерн не выбирают заранее — к нему приходят, когда код начал болеть. Порядок обычно такой:

  1. Напиши прямо, без паттернаПервая версия обязана быть тупой и работающей. Паттерн, вставленный до появления второго варианта, — это чистая цена без выгоды.
  2. Дождись третьего повторенияДва похожих места — совпадение, три — правило. До третьего непонятно, что именно повторяется: обычно первая догадка о «переменной части» оказывается неверной.
  3. Назови, что именно меняетсяСпособ посчитать — стратегия. Класс, который создаётся, — фабрика. Реакция на событие — наблюдатель. Порядок шагов — фасад. Формулировка «что меняется» и есть выбор паттерна.
  4. Возьми самый дешёвый вариант из подходящихВ Python это почти всегда функция, словарь или декоратор. Классы заводи, когда у варианта есть собственное состояние.
  5. Проверь, что стало прощеЕсли после рефакторинга новый случай добавляется в одном месте — паттерн сработал. Если по-прежнему приходится править три файла, ты угадал неверную ось изменений.

Ошибки, на которых спотыкаются

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

Классы там, где хватает функции. Прямой перенос примеров из книги 1994 года даёт AbstractDeliveryStrategyFactory, хотя в Python это словарь с тремя функциями. Проверяй каждый класс вопросом «какое состояние он хранит?» — если никакого, это функция.

Одиночка вместо аргумента. Соблазн сделать конфиг глобальным велик, а расплата приходит в тестах: состояние переезжает из теста в тест, порядок запуска начинает влиять на результат. Одиночка — последнее средство, а не первое.

Наследование вместо композиции. Паттерны почти всегда собирают поведение из объектов, а не наследуют его. Наследник знает внутренности родителя и ломается при его изменении, обёртка знает только интерфейс.

Паттерн выбран по названию, а не по задаче. «Адаптер» и «Фасад» на диаграмме похожи: оба стоят между клиентом и чужим кодом. Разница в цели: адаптер меняет интерфейс, не упрощая; фасад упрощает, не меняя. Если ответить, что из этого нужно, нельзя — паттерн пока не нужен.

Частые вопросы

Сколько всего паттернов проектирования

В классическом каталоге «банды четырёх» 1994 года — 23 паттерна: 5 порождающих, 7 структурных, 11 поведенческих. С тех пор описаны десятки других (репозиторий, единица работы, внедрение зависимостей), но эти 23 остаются общим языком, на котором разработчики договариваются об устройстве кода.

Нужны ли паттерны в Python

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

Чем паттерн отличается от антипаттерна

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

С какого паттерна начать

Со стратегии. Она встречается чаще всех, лечит самую распространённую боль — растущую цепочку if, — и в Python записывается настолько коротко, что ошибиться в реализации сложно.

Чем паттерн отличается от архитектуры

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

Где потренироваться

В пути «Python: ООП» на Koddo классы разбираются на тех же сценариях, что и выше: объект со своим состоянием, композиция вместо наследования, протокол вместо базового класса. Задачи проверяются тестами в браузере, а подсказку ассистента можно попросить, не открывая решение.

Источники