Трек «Инженерное программирование» · Модуль 2 · Неделя 6

Структурные паттерны

Adapter · Facade · Proxy · Decorator · Composite

2 ч теории. Далее — семинар и ДЗ task-06.

Пять паттернов — про границы: как компоновать классы и объекты в более крупные структуры, не ломая интерфейсы.

Этап недели 6 «Платёжной системы»: Repository + Adapter, команды консоли

Какой паттерн спрячет старое хранилище? (не отвечаем — узнаем через 20 минут)

Не открывая конспект: что такое паттерн и каков критерий уместности?

Что такое паттерн проектирования? Назовите критерий уместности паттерна.

Подсказка: закончите фразу — «код с паттерном …»

Эталон: «типовое решение типовой задачи»; «код с паттерном проще менять, чем код без него» — этот критерий — лакмус для всех пяти паттернов недели.

К концу лекции вы сможете…

Все пять паттернов — про границы и интерфейсы; сегодня научитесь выбирать между ними по одному признаку.

Структурные паттерны компонуют классы; duck typing и Protocol упрощают их в Python

Что значит «объект имеет нужный метод» и как это проверить? (duck typing — вызов работает; Protocol — статическая проверка)

Боль Adapter: старое хранилище говорит на другом языке

class OldStorage: # чужой/legacy код def save_record(self, data: dict) -> None: ... class NewClient: def store(self, storage, item) -> None: storage.store(item.to_dict()) # у OldStorage нет store!
NewClient store(item) OldStorage save_record(data) не совпадают
Клиент вызывает store(item), а класс умеет только save_record(data) — интерфейсы не совпадают.

Боль: менять legacy нельзя (чужой код), менять клиента — дорого (много мест).

Какие есть варианты? (менять legacy — нельзя; менять клиента — дорого; обёртка)

StorageAdapter — обёртка, которая переводит вызовы, не меняя клиента

class OldStorage: # чужой/legacy код def save_record(self, data: dict) -> None: print(f"legacy сохранил: {data}") class StorageAdapter: # новый интерфейс def __init__(self, storage: OldStorage) -> None: self._storage = storage def store(self, item) -> None: self._storage.save_record(item.to_dict())
class Item: def __init__(self, name: str, price: float) -> None: self.name = name self.price = price def to_dict(self) -> dict: return {"name": self.name, "price": self.price} adapter = StorageAdapter(OldStorage()) adapter.store(Item("курс", 100.0)) # клиент работает через store legacy сохранил: {'name': 'курс', 'price': 100.0}

Правило: клиент и адаптируемый класс не меняются; Adapter только переводит вызовы.

Когда не применять: если исходный класс можно изменить — меняйте его; если интерфейсы слишком разные — нужна новая модель, а не адаптер.

Что напечатает вызов? (предскажите формат dict{'name': 'курс', 'price': 100.0})

Adapter: клиент — обёртка — legacy; клиент и legacy не меняются

Клиент store(item) StorageAdapter store(item) OldStorage save_record(data) вызывает делегирует
Клиент знает только store — для него Adapter и есть хранилище; внутри Adapter зовёт legacy: store(item)save_record(item.to_dict()).
Что случится, если в Adapter положить бизнес-логику? (он перестанет быть адаптером, станет сервисом — типичная ошибка)

Proxy vs Adapter: тот же интерфейс vs другой интерфейс

Adapter другой интерфейс Клиент store(item) StorageAdapter store(item) OldStorage save_record(data) интерфейсы разные Proxy тот же интерфейс Клиент get(code) Proxy get(code) Repo: get(code) тоже Обёртка меняет интерфейс — это Adapter; сохраняет и добавляет кэш/контроль — это Proxy
Главный критерий выбора недели: Proxy — тот же интерфейс, Adapter — другой. Если обёртка меняет интерфейс — это Adapter; если сохраняет и добавляет кэш/контроль — это Proxy.
Сформулируйте правило хором: «тот же — …, другой — …»
Предскажите результат

CourseRepositoryProxy — кэш: повторный get не ходит в «базу»

class Course: def __init__(self, code: str) -> None: self.code = code class CourseRepository: # «дорогой» объект def __init__(self) -> None: self.calls = 0 def get(self, code: str) -> Course: self.calls += 1 # имитация обращения к БД return Course(code) class CourseRepositoryProxy: def __init__(self, repo: CourseRepository) -> None: self._repo = repo self._cache: dict[str, Course] = {} def get(self, code: str) -> Course: if code not in self._cache: self._cache[code] = self._repo.get(code) # дорогой вызов return self._cache[code]
repo = CourseRepository() proxy = CourseRepositoryProxy(repo) proxy.get("CS101") proxy.get("CS101") print(repo.calls) 1

Правило: интерфейс тот же — get(code); повторный запрос берётся из кэша, «база» не вызывается.

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

Сколько раз будет вызван repo.get при двух запросах одного кода? (один — второй запрос из кэша)

Proxy: ленивая инициализация, кэш, контроль доступа, лог, удалённый доступ

Какая задача из списка есть в вашем «Университете»? (например, кэш репозитория курсов)

Боль Facade: клиент разбирается во всей подсистеме оплаты

class FraudChecker: def is_suspicious(self, card) -> bool: ... class Gateway: def charge(self, amount: float, card) -> bool: ... class Ledger: def record(self, amount: float, card) -> None: ... # клиент делает платёж «вручную» if not fraud.is_suspicious(card): if gateway.charge(amount, card): ledger.record(amount, card)

Боль: чтобы провести платёж, клиент должен знать три класса, их порядок и обработку ошибок; этот код дублируется в каждом сценарии; подсистема «дышит» в лицо клиенту.

Что будет, если добавится четвёртый класс — комиссия? (клиента придётся править везде)

PaymentFacade.pay() — три действия за одной строкой клиента

class FraudChecker: def is_suspicious(self, card: str) -> bool: return card == "BANNED" class Gateway: def charge(self, amount: float, card: str) -> bool: return True class Ledger: def record(self, amount: float, card: str) -> None: print(f"проведено: {amount} {card}")
class PaymentFacade: def __init__(self, fraud: FraudChecker, gateway: Gateway, ledger: Ledger) -> None: self._fraud = fraud self._gateway = gateway self._ledger = ledger def pay(self, amount: float, card: str) -> bool: if self._fraud.is_suspicious(card): return False ok = self._gateway.charge(amount, card) if ok: self._ledger.record(amount, card) return ok
facade = PaymentFacade(FraudChecker(), Gateway(), Ledger()) print(facade.pay(1000.0, "1234")) print(facade.pay(1000.0, "BANNED")) проведено: 1000.0 1234 True False

Правило: клиент вызывает facade.pay(...), внутри фасад ходит в антифрод, платёжный шлюз, бухгалтерию.

Что вернёт pay для карты «BANNED»? (False — антифрод сработал)

Facade: когда применять; опасность — фасад-гигант

Когда применять

  • сложная подсистема с небольшим числом частых сценариев использования;
  • надо уменьшить связанность клиента с подсистемой.

Когда не применять

  • подсистема и так проста — фасад добавит лишний слой.
  • фасад превращается в класс-гигант — вся логика стекается в него, а подсистема остаётся недоступной для гибких сценариев.

Типичная ошибка: фасад тащит в себя бизнес-логику вместо делегирования.

Чем хороший фасад отличается от класса-гиганта? (фасад делегирует, не реализует логику сам)
Стоп-вопрос

Стоп-вопрос: чем Proxy отличается от Adapter?

  1. Чем Proxy отличается от Adapter?
  2. Как по интерфейсу обёртки определить, какой паттерн перед вами?

Эталон: Proxy — тот же интерфейс (кэш, контроль, лог); Adapter — другой интерфейс (перевод вызовов). Если обёртка меняет интерфейс — это Adapter.

Пауза 10–15 секунд; сверимся со схемой Слайда 9.

Decorator-паттерн ≠ @decorator: композиция обёрток vs синтаксис

@staticmethod — это паттерн Decorator? (нет — это синтаксис; паттерн — про обёртки объектов)

LoggedPayment + RateLimitedPayment — обёртки комбинируются, интерфейс сохранён

class Payment: def pay(self, amount: float) -> bool: return True class LoggedPayment: def __init__(self, payment: Payment) -> None: self._payment = payment def pay(self, amount: float) -> bool: print(f"Платёж на сумму {amount}") return self._payment.pay(amount) # делегирование
class RateLimitedPayment: def __init__(self, payment: Payment) -> None: self._payment = payment def pay(self, amount: float) -> bool: if amount > 10_000: print("Превышен лимит") return False return self._payment.pay(amount) # делегирование base = Payment() wrapped = LoggedPayment(RateLimitedPayment(base)) print(wrapped.pay(500.0)) Платёж на сумму 500.0 True

Правило: обёртки можно комбинировать: лог + лимит + проверка; интерфейс pay(amount) сохранён — параметр пробрасывается сквозь обёртки.

Что будет при сумме 20 000? (лимит вернёт False, но лог всё равно напечатается — сначала внешняя обёртка)

Боль Composite: клиент различает лист и узел через isinstance

class Task: def __init__(self, estimate: int) -> None: self.estimate = estimate class TaskGroup: def __init__(self, children: list) -> None: self.children = children def total_time(node) -> int: if isinstance(node, Task): # клиент различает лист и узел return node.estimate return sum(total_time(c) for c in node.children)

Боль: каждый новый тип узла требует правки клиента; ветвление isinstance дублируется в каждой операции (total_time, count, print).

Что нужно поменять, чтобы добавить операцию count? (ещё одно ветвление isinstance)
Предскажите результат

Task и TaskGroup — единый интерфейс; total_time() считает всё поддерево

class TaskComponent: def total_time(self) -> int: ... class Task(TaskComponent): # лист def __init__(self, estimate: int) -> None: self._estimate = estimate def total_time(self) -> int: return self._estimate class TaskGroup(TaskComponent): # узел def __init__(self, children: list[TaskComponent]) -> None: self._children = children def total_time(self) -> int: return sum(c.total_time() for c in self._children)
project = TaskGroup([ Task(3), # «Написать код» TaskGroup([ Task(2), # «Юнит-тесты» Task(4), # «Интеграционные» ]), ]) print(project.total_time()) 9

Правило: клиент не знает, лист перед ним или узел — операция узла рекурсивно применяется к детям. В клиенте больше нет isinstance.

Чему равен project.total_time()? (3 + (2 + 4) = 9)

Composite: дерево категорий — листья и узлы обрабатываются одинаково

Проект TaskGroup Написать код (3) Task · лист Тесты TaskGroup · узел Юнит-тесты (2) Task Интеграционные (4) Task содержит содержит
project.total_time() = 3 + (2 + 4) = 9; клиент вызывает один метод — рекурсию делает узел. Считаем снизу вверх: лист возвращает оценку, узел — сумму детей.
Что вернёт пустой TaskGroup([])? (0 — сумма пустого списка; важно для граничного случая)

Composite: когда применять / когда не применять; где живут деревья

Когда применять

  • иерархии «часть — целое» с единой обработкой — категории, меню, файловая система, оргструктура, курсы и занятия.
  • базовый приём для деревьев в алгоритмах и структурах данных (обход, поиск, агрегация).

Когда не применять

  • плоские данные без вложенности — композит избыточен.
  • если поведение узла зависит от состояния — присмотритесь к State (неделя 7).

Типичные ошибки: клиент проверяет isinstance на лист/узел — теряется единообразие; рекурсия без ограничения глубины; узел без проверки детей.

Какие деревья вы уже видели в своих проектах? (категории, меню, файлы)
Live-coding

Live-coding: Adapter для «старого» хранилища

Задача: дан OldStorage с методом save_record(data); написать StorageAdapter под новый интерфейс store(item); показать, что клиент, написанный под новый интерфейс, работает со старым хранилищем без правок.

Ожидаемые результаты: adapter.store(Item(...)) печатает сохранённый dict; клиент вызывает только store.

Порядок:

  1. показать «боль» — несовместимые методы;
  2. написать класс-обёртку;
  3. проверить, что клиент не менялся;
  4. обсудить, почему бизнес-логика в адаптере — ошибка.
Повторите на своих данных: зачем адаптеру делегирование?
Live-coding

Live-coding: Facade для подсистемы оплаты

Задача: пишем с нуля классы FraudChecker, Gateway, Ledger (заглушки); PaymentFacade.pay(amount, card); клиент — одна строка.

Ключевая проверка: добавление нового шага (комиссия) меняет только фасад, не клиента.

Акцент: «фасад — тонкая дверь, без бизнес-логики».

Где граница между фасадом и классом-гигантом? (фасад делегирует, не реализует)
Проверь себя

Проверь себя: 4 вопроса перед семинаром

  1. Чем Proxy отличается от Adapter? Чем Decorator-паттерн от @decorator?
  2. Когда фасад уместен, а когда превращается в класс-гигант?
  3. Как duck typing упрощает структурные паттерны в Python?
  4. Приведите пример дерева, где Composite даёт единый интерфейс.
2 минуты письменно, затем короткий разбор; эталоны — в решения-6.md.
Разбор ошибки

Обёртка без делегирования: метод «съеден», клиент ломается

class Payment: def pay(self, amount: float) -> bool: return True class LoggedPayment: # обёртка def __init__(self, payment: Payment) -> None: self._payment = payment def pay(self, amount: float) -> bool: print("логирую...") # return self._payment.pay(amount) # забыли делегирование! return True wrapped = LoggedPayment(Payment()) print(wrapped.pay(500.0)) логирую... True

Разбор: формально метод есть и возвращает True, но настоящее поведение Payment не выполняется — обёртка «съела» делегирование. В большом коде это выглядит как «почему-то не работает оплата», и traceback не поможет — ошибка логическая.

Правило: обёртка обязана делегировать вызов реальному объекту; если метод обёртки возвращает результат сам — проверьте, не забыли ли вы self._payment.pay().

Как защититься? (тест, который проверяет, что внутренний объект реально вызван — например, через счётчик вызовов)

Типичные ошибки недели 6

  1. Адаптер тащит бизнес-логику (должен только переводить вызовы); адаптация «на всякий случай» без реальной несовместимости.
  2. Фасад превращается в класс-гигант — вся логика стекается в него.
  3. Путаница Proxy (тот же интерфейс) и Adapter (другой).
  4. Кэш без инвалидации — устаревшие данные.
  5. Путаница паттерна Decorator и синтаксического @decorator; обёртка не сохраняет интерфейс; забыто делегирование.
  6. isinstance на лист/узел в Composite — теряется единообразие.
Отметьте знакомые ошибки в своём коде — каждая задача семинара «ловит» одну из них.

Связка с проектом: этап недели 6 «Платёжной системы»

консоль PaymentService Storage (Repository) старый JSON-модуль Adapter → тоже Storage
Storage — это интерфейс, а Adapter прячет реализацию: завтра поменяете JSON на SQLite — клиент не заметит.
Какой паттерн спрячет старое хранилище? (Adapter — ответ на вопрос Слайда 2)

Сегодня вы научились…

Всё, что обещали в начале (Слайд 4), — сделали?
Рефлексия

One-minute paper: главное + один вопрос

Это сигнал для семинара: что разобрать подробнее.

Что дальше: семинар недели 6, ДЗ task-06, анонс недели 7

Готовы? Откройте семинар-6.md — по своему уровню.