Перейти к содержимому

Практика 11. Практическая работа 11. Поведенческие паттерны

Цель

  • Изучить и применить ключевые поведенческие паттерны GoF: Strategy, Observer, Command (с поддержкой undo) и Template Method.
  • Научиться распознавать ситуации, в которых каждый из паттернов уместен, а где он избыточен.
  • Сравнить классические ООП-реализации с питоническими альтернативами (первоклассные функции, callbacks, реестры) и осознанно выбирать между ними.
  • Закрепить принципы SOLID: открытость для расширения, программирование на уровне абстракций, композицию вместо наследования.

Краткая теория

Поведенческие паттерны описывают взаимодействие объектов и распределение ответственности — отвечают на вопрос «кто, кого и когда вызывает».

  • Strategy — семейство взаимозаменяемых алгоритмов, каждый инкапсулирован в отдельном объекте; алгоритм выбирается в рантайме. Контекст хранит ссылку на стратегию и делегирует ей работу.
  • Observer — зависимость «один-ко-многим»: при изменении состояния субъекта все подписчики автоматически уведомляются. Основа систем событий и реактивных обновлений.
  • Command — запрос, инкапсулированный как объект. Позволяет ставить операции в очередь, логировать их, собирать в макрокоманды и, главное, отменять (undo) за счёт хранения данных для обратной операции.
  • Template Method — скелет алгоритма зафиксирован в методе базового класса, а отдельные шаги переопределяются в подклассах.

Питонический взгляд. В Python функции — объекты первого класса, поэтому многие паттерны упрощаются:

  • Strategy часто сводится к передаче функции/lambda вместо иерархии классов.
  • Command без undo — это callable (функция или functools.partial) в списке; полноценный класс нужен именно ради отмены.
  • Observer — список callbacks (list[Callable]), вызываемых по очереди.
  • Template Method — иногда заменяется передачей функций-шагов вместо наследования.

Правило: сначала выбираем самое простое решение (функция, словарь, callback), а к полному ООП-варианту с ABC переходим, когда появляется состояние, несколько методов или требование строгого контракта.


Задания

Во всех заданиях используйте аннотации типов и abc.ABC/@abstractmethod для фиксации контракта. Полные решения реализуйте самостоятельно.

Задание 1. Strategy — стратегии расчёта стоимости

Реализуйте систему расчёта итоговой цены заказа с разными политиками скидок.

Требования:

  1. Интерфейс DiscountStrategy(ABC) с методом apply(amount: float) -> float.
  2. Конкретные стратегии:
    • NoDiscount — без скидки.
    • PercentDiscount(percent: float) — процент от суммы.
    • FixedDiscount(value: float) — фиксированная скидка (цена не ниже нуля).
    • LoyaltyDiscount(points: int) — скидка, зависящая от числа бонусных баллов.
  3. Контекст Order:
    • Хранит позиции и текущую DiscountStrategy.
    • Метод set_strategy(strategy) — смена стратегии в рантайме.
    • Метод total() -> float — сумма позиций с применённой скидкой.

Питоническая альтернатива (обязательно). Реализуйте функцию total_func(amount: float, discount: Callable[[float], float]) -> float, принимающую скидку как обычную функцию, и продемонстрируйте её работу с lambda.

Пример использования:

order = Order(items=[100, 200, 300])
order.set_strategy(PercentDiscount(10))
assert order.total() == 540.0
order.set_strategy(NoDiscount())
assert order.total() == 600.0

Задание 2. Observer — система событий

Реализуйте паттерн Observer для оповещения подписчиков об изменениях.

Требования:

  1. Интерфейс Subscriber(ABC) с методом update(event: str, data: dict) -> None.
  2. Субъект Publisher:
    • subscribe(event: str, sub: Subscriber) -> None — подписка.
    • unsubscribe(event: str, sub: Subscriber) -> None — отписка.
    • notify(event: str, data: dict | None = None) -> None — оповещение всех подписчиков события.
    • Один подписчик может слушать несколько событий.
  3. Конкретные подписчики:
    • LogSubscriber — копит записи в список logs.
    • CounterSubscriber — считает количество каждого события в dict[str, int].
  4. Класс WeatherStation(Publisher):
    • Метод set_temperature(t: float) генерирует событие "temperature_changed" с новым и предыдущим значением.

Питоническая альтернатива (обязательно). Добавьте в Publisher метод subscribe_fn(event: str, callback: Callable[[str, dict], None]), позволяющий подписать обычную функцию без создания класса-подписчика.

Пример использования:

station = WeatherStation()
log = LogSubscriber()
station.subscribe("temperature_changed", log)
station.set_temperature(25.0)
station.set_temperature(30.0)
assert len(log.logs) == 2

Задание 3. Command — редактор с undo/redo

Реализуйте паттерн Command для редактора списка задач с историей и отменой.

Требования:

  1. Интерфейс Command(ABC):
    • execute() -> None — выполнить.
    • undo() -> None — отменить (вернуть получателя в исходное состояние).
    • description() -> str — текстовое описание для истории.
  2. Получатель TaskList:
    • Атрибут tasks: list[str].
    • Методы add(task), remove(index), rename(index, new_name).
  3. Конкретные команды (каждая сохраняет данные, нужные для undo):
    • AddCommand(tasks, task)
    • RemoveCommand(tasks, index) — должна запомнить удалённый элемент.
    • RenameCommand(tasks, index, new_name) — должна запомнить старое имя.
  4. Инвокер History:
    • execute(command) -> None — выполнить и положить в стек выполненных.
    • undo() -> None — отменить последнюю команду (переносится в стек отменённых).
    • redo() -> None — повторить последнюю отменённую.
    • log() -> list[str] — список описаний выполненных команд.

Требование к undo. После undo() состояние TaskList обязано полностью совпадать с состоянием до execute() команды. Новая команда после серии undo очищает стек redo.

Питоническая альтернатива (по желанию). Покажите, что команду без отмены можно заменить functools.partial, и поясните, почему для undo этого мало.

Пример использования:

tasks = TaskList()
history = History()
history.execute(AddCommand(tasks, "купить хлеб"))
history.execute(RenameCommand(tasks, 0, "купить молоко"))
assert tasks.tasks == ["купить молоко"]
history.undo()
assert tasks.tasks == ["купить хлеб"]
history.redo()
assert tasks.tasks == ["купить молоко"]

Задание 4. Template Method — экспорт отчётов

Реализуйте паттерн Template Method для экспорта данных в разные форматы.

Требования:

  1. Базовый класс ReportExporter(ABC):
    • Шаблонный метод export(data: list[dict]) -> str, фиксирующий порядок шагов: make_header() → строки через format_row()make_footer().
    • Абстрактные методы: make_header(columns), format_row(row).
    • Hook-метод make_footer() -> str с пустой реализацией по умолчанию (подклассы могут переопределить).
  2. Конкретные экспортёры:
    • CSVExporter — строки через запятую, заголовок из имён колонок.
    • MarkdownExporter — таблица Markdown (с разделительной строкой ---).
    • HtmlExporter — переопределяет make_footer(), оборачивая всё в <table>.

Питоническая альтернатива (по желанию). Реализуйте функцию export(data, header_fn, row_fn), принимающую шаги-функции вместо наследования.

Пример: CSVExporter().export([{"name": "Иван", "age": 30}])"name,age\nИван,30".


Задание 5*. Комбинация паттернов: пайплайн обработки (бонус)

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

  • Pipeline хранит список шагов-стратегий (Step(ABC) с методом run(data: dict) -> dict).
  • При запуске пайплайн как субъект Observer генерирует события "before_step", "after_step", "error"; подписчики — логгер и сборщик метрик.
  • Запуск на конкретных данных оформлен как Command в очереди CommandQueue с batch-выполнением. Реализуйте 2–3 шага (валидация, нормализация, обогащение).

Критерии оценки

КритерийВесЧто проверяется
Задание 1 (Strategy)20%Иерархия стратегий, смена в рантайме, функциональная альтернатива
Задание 2 (Observer)20%Подписка/отписка, несколько событий и подписчиков, callback-вариант
Задание 3 (Command)25%Корректные execute/undo/redo, точное восстановление состояния, история
Задание 4 (Template Method)20%Неизменный скелет, переопределяемые шаги, hook-метод
Качество кода15%Типизация, ABC-контракты, читаемость, не менее 5 осмысленных тестов

Итого: 100%. Бонус до +15% за задание 5* (комбинация паттернов).

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


Вопросы для самопроверки

  1. Чем Strategy отличается от Template Method по способу варьирования поведения (композиция против наследования)?
  2. В каком случае класс-стратегию разумно заменить обычной функцией, а когда — нет?
  3. Как паттерн Command обеспечивает отмену операции? Какие данные команда обязана сохранить для корректного undo?
  4. Почему functools.partial или lambda не подходят для команды с отменой?
  5. В чём разница между push- и pull-моделью передачи данных в Observer?
  6. Что произойдёт, если подписчик отпишется во время оповещения (итерации по списку), и как это предотвратить?
  7. Что такое hook-метод в Template Method и чем он отличается от абстрактного шага?
  8. Почему шаблонный метод обычно не делают переопределяемым в подклассах?
  9. Где питоническая альтернатива (функция/callback) предпочтительнее полной ООП-реализации Strategy, Command и Observer?
  10. Как изученные паттерны реализуют принцип «открыт для расширения, закрыт для модификации»?