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

Поведенческие паттерны

Strategy · Observer · Command · Template Method · State · Iterator

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

Неделя 5 создавала объекты, неделя 6 — компоновала их. А как объектам распределить ответственность и договориться о взаимодействии, не зная друг друга?

Этап недели 7 «Платёжной системы»: Strategy для комиссий + Command (undo) или Observer

Все три паттерна — про то, как объекты договариваются о поведении, не зная друг друга.

Рекомендуемый набор проекта — Factory + Strategy + Command/Observer. Какой из трёх паттернов вы выберете для своего проекта — и сможете обосновать почему?

Не открывая конспект: что такое полиморфизм?

Закончите фразу: «один интерфейс — …».

Подсказка: вспомните — разные классы, один метод, разное поведение.

Пауза 10–15 секунд. Сегодня каждый паттерн — это полиморфизм + способ договориться о взаимодействии.

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

Strategy и Observer — самые частые на собеседованиях и в промышленном коде. Обратите на них особое внимание.

Поведенческие паттерны — полиморфизм в действии: один интерфейс, разные реализации

Если вы умеете полиморфизм, вы уже наполовину знаете поведенческие паттерны. Чего не хватает до целого? (способа взаимодействия)

Боль Strategy: if/elif по типу скидки правится при каждом новом тарифе

def total(amount: float, promo: str) -> float: if promo == "none": return amount if promo == "summer": return amount * 0.9 if promo == "student": return amount * 0.85 raise ValueError(f"Неизвестный промо: {promo!r}")

Боль: добавление тарифа правит общий код; ветвление дублируется в каждом методе, где нужна скидка; логика смешана с бизнес-действием.

Решение — взаимозаменяемые стратегии, подставляемые снаружи.

Что придётся сделать, чтобы добавить тариф "new_year"? А если скидка нужна в трёх местах? (править функцию — и каждый раз три ветвления)
Предскажите результат

Checkout + стратегии: алгоритм подставляется снаружи

Checkout total(amount) делегирует DiscountStrategy (Protocol) NoDiscount SummerDiscount StudentDiscount реализует реализует реализует
Контекст знает только интерфейс; конкретная стратегия подставляется снаружи.
class DiscountStrategy(Protocol): def apply(self, amount: float) -> float: ... class NoDiscount: def apply(self, amount: float) -> float: return amount class SummerDiscount: def apply(self, amount: float) -> float: return amount * 0.9 class Checkout: def __init__(self, strategy: DiscountStrategy) -> None: self._strategy = strategy def total(self, amount: float) -> float: return self._strategy.apply(amount) checkout = Checkout(SummerDiscount()) print(checkout.total(1000.0)) checkout = Checkout(NoDiscount()) print(checkout.total(1000.0)) 900.0 1000.0

Правило: контекст не меняется при добавлении стратегии — меняется только подстановка.

Что вернёт Checkout(SummerDiscount()).total(1000)? (900.0 — полиморфизм в действии: Checkout знает только apply)

Strategy: когда применять / когда не применять; связь с полиморфизмом

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

  • взаимозаменяемые алгоритмы: скидки, тарифы, сортировки, форматы;
  • устранение ветвлений if/elif;
  • алгоритм меняется в рантайме.

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

  • один алгоритм;
  • выбор не меняется — функция с параметром проще.

Связь с полиморфизмом: Strategy — «полиморфизм в действии»: контекст знает только интерфейс, конкретная стратегия подставляется снаружи.

  1. Стратегия с внутренним состоянием там, где она должна быть чистой функцией;
  2. контекст лезет в детали стратегии.
Смена стратегии в рантайме — сильный признак Strategy. Если выбор фиксирован — не усложняйте: хватит функции с параметром.

Боль Observer: Course знает всех получателей — email, лог, журнал

class Course: def enroll(self, student: str) -> None: self._students.append(student) email.send(f"{student} записан") # жёсткая связь logger.log(f"{student} записан") # ещё одна journal.update(student) # и ещё одна

Боль: при каждом новом получателе (sms, telegram) правим Course; отправитель знает всех и как они реагируют; тестировать трудно.

Решение — субъект с подпиской: отправитель не знает получателей.

Что нужно поменять в Course, чтобы добавить SMS-уведомление? А если получателей станет пять? (добавить вызов — и Course раздувается)

CourseNotifier: субъект и подписчики «один ко многим»

CourseNotifier субъект email logger journal уведомляет notify(message) подписывается
Связь «один ко многим»: отправитель не знает, кто и как реагирует.
from typing import Callable class CourseNotifier: def __init__(self) -> None: self._observers: list[Callable[[str], None]] = [] def subscribe(self, observer: Callable[[str], None]) -> None: self._observers.append(observer) def notify(self, message: str) -> None: for observer in self._observers: observer(message) def email(message: str) -> None: print(f"email: {message}") def logger(message: str) -> None: print(f"log: {message}") notifier = CourseNotifier() notifier.subscribe(email) notifier.subscribe(logger) notifier.notify("студент записан на курс") email: студент записан на курс log: студент записан на курс

Правило: добавление подписчика не меняет CourseNotifier; в Python наблюдатели часто — просто callable.

Что напечатается после notify? (два сообщения в порядке подписки: сначала email, потом log)

Observer: когда применять / когда не применять; отписка и изоляция ошибок

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

  • связь «один ко многим»;
  • независимые реакции на события;
  • событийная архитектура — база для асинхронности С3.

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

  • один получатель — просто вызов метода;
  • получатели не меняются — обойдёмся без подписки.
  1. Наблюдатель не отписывается — утечка ссылок и неожиданные вызовы;
  2. исключение в одном наблюдателе рушит всех — нужно изолировать обработку ошибок;
  3. порядок уведомлений не документирован.
Где «живёт» подписчик после того, как объект уже не нужен? (в списке наблюдателей — утечка ссылок; на семинаре проверим, что сбой одного не рушит остальных)

Боль Command: действие нужно отложить, отменить, залогировать

account.withdraw(200.0) # прямое действие # нельзя отложить, отменить, поставить в очередь

Боль: прямой вызов метода не позволяет отложить действие, поставить в очередь, отменить (undo), залогировать; вызывающий код знает исполнителя.

Решение — действие становится объектом с execute() и undo().

Как отменить только что сделанный withdraw? (нужен deposit — но кто запомнит сумму и порядок действий?)
Предскажите результат

PayCommand: execute() и undo() — действие стало объектом

from typing import Protocol class Command(Protocol): def execute(self) -> None: ... def undo(self) -> None: ... class Account: def __init__(self, balance: float) -> None: self.balance = balance def withdraw(self, amount: float) -> None: self.balance -= amount def deposit(self, amount: float) -> None: self.balance += amount class PayCommand: def __init__(self, account: Account, amount: float) -> None: self._account = account self._amount = amount def execute(self) -> None: self._account.withdraw(self._amount) def undo(self) -> None: self._account.deposit(self._amount) acc = Account(1000.0) cmd = PayCommand(acc, 200.0) cmd.execute() print(acc.balance) cmd.undo() print(acc.balance) 800.0 1000.0

Правило: команда хранит ровно свой контекст; вызывающий код не знает исполнителя; undo — обратное действие.

Чему равен баланс после execute? после undo? (800.0 и 1000.0 — команды можно складывать в стек истории, увидим в live-coding 2)

Command: когда применять / когда не применять; undo/redo, очередь, транзакции

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

  • undo/redo;
  • очередь задач, макрокоманды;
  • логирование действий;
  • транзакции.

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

  • простое прямое действие — команда добавит лишние классы.
  1. команда хранит слишком много контекста;
  2. заявлен undo, но не реализован;
  3. команды вызываются напрямую, минуя очередь/историю.

Downstream: Command — основа транзакций в БД и очередей задач в backend.

Сигнал к Command — слово «отменить» или «отложить» в требованиях. Если действие всегда мгновенно и необратимо — команда не нужна.
Стоп-вопрос

Стоп-вопрос: Strategy vs Template Method? Когда нужен Command?

  1. Чем Strategy отличается от Template Method? (пока только по одному признаку — ответим полностью на Слайде 18)
  2. Когда нужен Command, а не обычный вызов метода?

Эталон: Strategy — композиция, алгоритм подставляется снаружи; Template Method — наследование, скелет в базе; Command — когда действие нужно отложить/отменить/залогировать.

Вопрос всей группе, пауза. Если ответов меньше половины — полное различение TM/S на Слайде 18.

Боль Template Method: одинаковые шаги дублируются в каждом наследнике

class TextReport: def generate(self) -> str: data = self.collect_data() body = self.render(data) return f"=== Отчёт ===\n{body}" # общий шаг class CsvReport: def generate(self) -> str: data = self.collect_data() body = self.render(data) return f"=== Отчёт ===\n{body}" # тот же общий шаг

Боль: общий скелет (собрать → отрисовать → оформить) дублируется в каждом наследнике; изменение шапки отчёта требует правки всех классов.

Решение — скелет в базовом классе, вариативные шаги — в наследниках.

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

ReportGenerator: скелет алгоритма в базе, вариативные шаги — в наследниках

class ReportGenerator: def generate(self) -> str: # шаблонный метод data = self.collect_data() body = self.render(data) return self.finalize(body) def collect_data(self): ... def render(self, data): ... def finalize(self, body: str) -> str: return f"=== Отчёт ===\n{body}" # общий шаг class TextReport(ReportGenerator): def collect_data(self) -> dict: return {"students": 10} def render(self, data: dict) -> str: return f"Студентов: {data['students']}" class CsvReport(ReportGenerator): def collect_data(self) -> list[list[str]]: return [["name", "group"], ["Иван", "101"]] def render(self, data: list[list[str]]) -> str: return "\n".join(",".join(row) for row in data) print(TextReport().generate()) print(CsvReport().generate()) === Отчёт === Студентов: 10 === Отчёт === name,group Иван,101

Правило: generate задаёт последовательность шагов; наследники меняют только вариативные шаги; общий finalize не дублируется.

Что напечатает TextReport().generate()? («=== Отчёт ===» и «Студентов: 10»)

Template Method vs Strategy: наследование vs композиция; когда/не

Template Method — наследование

  • скелет алгоритма в базовом классе, наследники переопределяют шаги;
  • подходит, когда меняются шаги, а не весь алгоритм.

Strategy — композиция

  • алгоритм целиком подставляется снаружи;
  • подходит, когда меняется весь алгоритм.

Когда не применять Template Method: шаги не вариативны; если меняется весь алгоритм — предпочесть Strategy.

  1. Слишком много абстрактных шагов — наследники переопределяют всё подряд.
«Каркас с дырками» vs «сменный модуль»: отчёт меняет шапку — какой паттерн? Скидка меняется целиком — какой? (TM — шаг finalize; Strategy — весь алгоритм)

Боль State: if state == ... расползается по всем методам

class Order: def __init__(self) -> None: self.state = "new" def pay(self) -> None: if self.state == "new": self.state = "paid" elif self.state == "paid": raise ValueError("Уже оплачен") elif self.state == "cancelled": raise ValueError("Заказ отменён") def cancel(self) -> None: if self.state == "new": self.state = "cancelled" elif self.state == "cancelled": raise ValueError("Уже отменён") # и так в каждом методе

Боль: ветвление if state == ... дублируется в каждом методе; добавление состояния требует править все методы; допустимые переходы не видны.

Решение — каждое состояние становится классом.

Сколько мест нужно править при добавлении состояния "refunded"? (все методы — это и есть «боль» State)
Предскажите результат

OrderState: каждое состояние — класс; переходы меняют объект состояния

class OrderState: def pay(self, order) -> None: ... def cancel(self, order) -> None: ... class Order: def __init__(self) -> None: self.state: OrderState = NewOrder() def pay(self) -> None: self.state.pay(self) def cancel(self) -> None: self.state.cancel(self) class NewOrder(OrderState): def pay(self, order) -> None: order.state = PaidOrder() print("оплачен") def cancel(self, order) -> None: order.state = CancelledOrder() print("отменён") class PaidOrder(OrderState): def pay(self, order) -> None: raise ValueError("Заказ уже оплачен") def cancel(self, order) -> None: print("отмена оплаченного заказа") class CancelledOrder(OrderState): def pay(self, order) -> None: raise ValueError("Отменённый заказ нельзя оплатить") def cancel(self, order) -> None: raise ValueError("Заказ уже отменён") o = Order() o.pay() print(type(o.state).__name__) оплачен PaidOrder

Правило: контекст не содержит ветвлений — он делегирует текущему состоянию; переходы меняют объект состояния.

Что будет, если вызвать o.pay() повторно? (ValueError: Заказ уже оплачен — запрещённые переходы это исключения в классах состояний)

State: диаграмма состояний заказа

Новый NewOrder Оплачен PaidOrder Отменён CancelledOrder pay() cancel() cancel() повторные pay()/cancel() → ValueError
Каждое состояние — класс; переходы — методы, меняющие order.state; недопустимые переходы — исключения.
Какой переход запрещён, но часто забывается? (из «оплачен» в «новый» — возврата назад нет)

State: когда применять / когда не применять; 2–3 состояния — Enum проще

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

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

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

  • 2–3 состояния без сложной логики — булев флаг или Enum проще.
  1. состояния меняют друг друга напрямую, минуя контекст;
  2. не проверяются допустимые переходы (из «оплачен» в «новый»).

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

Сколько состояний должно быть, чтобы State окупился? (много и ветвящихся переходов — иначе хватит Enum)

Iterator встроен в язык: __iter__ и генераторы — паттерн GoF «из коробки»

class Course: def __init__(self, students: list[str]) -> None: self._students = students def __iter__(self): yield from self._students c = Course(["Иван", "Мария"]) for s in c: print(s) Иван Мария

Правило: __iter__/__next__, генераторы, for — это Iterator GoF «из коробки» (С2 М3); итерируемый класс — паттерн без дополнительных классов; отдельный класс-итератор почти всегда заменяется генератором.

Вы уже писали __iter__ в С2 М3 — значит, уже применяли Iterator. Где ещё понадобится итерация? (дерево из Composite — обход по категориям)

Iterator: когда применять / когда не применять; __getitem__ без __iter__ — ошибка

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

  • итерация по кастомной структуре: дерево из Composite, курс по студентам, «бесконечные» последовательности.

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

  • стандартные коллекции — for и так работает.
  1. реализуют __getitem__ без __iter__ и удивляются медленной итерации;
  2. путают итерируемый объект и итератор: iter(obj) vs next(obj).

iter(course) возвращает итератор, next(...) двигает его; сам курс — итерируемый, но не итератор.

Что вернёт iter(course) и что делает next(...)? (итератор; двигает его на следующий элемент)
Live-coding

Live-coding: Strategy для комиссий/скидок

Задача: Checkout с комиссиями FixedFee(10), PercentFee(0.05), CombinedFee(10, 0.05); смена стратегии в рантайме checkout.set_strategy(PercentFee(0.02)).

FixedFee(10).apply(100) → 10 PercentFee(0.05).apply(100) → 5.0 CombinedFee(10, 0.05).apply(100) → 15.0

Порядок написания:

  1. показать «боль» — if/elif по типу тарифа;
  2. Protocol FeeStrategy;
  3. три реализации;
  4. смена стратегии в рантайме.

Намеренная ошибка: стратегия с внутренним состоянием (счётчик вызовов) — почему стратегия должна быть чистой функцией?

Повторите за лектором на своих данных: какой результат вернёт CombinedFee(10, 0.05).apply(100)? (15.0 — фиксированная часть + 5%)
Live-coding

Live-coding: Command с undo — история и откат серии

Пишем с нуля: Account, PayCommand, RefundCommand; список history; функции run(cmd) и undo_last(); демонстрируем откат серии из трёх действий.

# заготовка: команды вызываются через историю history = [] # стек выполненных команд def run(cmd): cmd.execute() history.append(cmd) def undo_last(): history.pop().undo()

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

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

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

  1. Чем Strategy отличается от Template Method?
  2. Как Observer связан с событийной архитектурой?
  3. Когда нужен Command, а не обычный вызов метода?
  4. Почему в Python Iterator не требует отдельного класса из GoF?
2 минуты письменно, затем сверка с залом; эталоны — в решения-7.md.
Разбор ошибки

Исключение в одном наблюдателе рушит всех — изолируйте обработку

class CourseNotifier: def __init__(self) -> None: self._observers: list[Callable[[str], None]] = [] def subscribe(self, observer: Callable[[str], None]) -> None: self._observers.append(observer) def notify(self, message: str) -> None: for observer in self._observers: observer(message) # падение одного рушит всех def broken(message: str) -> None: raise RuntimeError("сбой в наблюдателе") notifier = CourseNotifier() notifier.subscribe(email) notifier.subscribe(broken) notifier.subscribe(logger) notifier.notify("студент записан") RuntimeError: сбой в наблюдателе

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

Найдите баг: где именно падает программа и кто никогда не получит уведомление? (logger — цикл прервался на broken)

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

  1. Strategy: стратегия с внутренним состоянием там, где нужна чистая функция; контекст лезет в детали стратегии.
  2. Observer: наблюдатель не отписывается — утечка ссылок и неожиданные вызовы; исключение в одном наблюдателе рушит всех.
  3. Command: команда хранит слишком много контекста; заявлен undo, но не реализован; команды вызываются напрямую, минуя историю.
  4. Template Method vs Strategy: путаница наследования и композиции; слишком много абстрактных шагов.
  5. State: состояния меняют друг друга напрямую, минуя контекст; не проверяются допустимые переходы.
  6. Iterator: __getitem__ без __iter__ — медленная итерация.
Отметьте знакомые ошибки: каждая задача семинара «ловит» одну из них.

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

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

Комиссия меняется в рантайме — какой паттерн? Отмена платежа — какой? (Strategy и Command)

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

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

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

Напишите (1 минута, не подписывая):

  1. одно самое важное, что вы узнали сегодня;
  2. один вопрос, который остался неясным.

Соберите ответы: 2–3 вопроса преподаватель разбирает сразу или переносит на семинар.

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

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

Готовы? Откройте семинар-7.md и выполните базовый уровень.