Практика 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 — стратегии расчёта стоимости
Реализуйте систему расчёта итоговой цены заказа с разными политиками скидок.
Требования:
- Интерфейс
DiscountStrategy(ABC)с методомapply(amount: float) -> float. - Конкретные стратегии:
NoDiscount— без скидки.PercentDiscount(percent: float)— процент от суммы.FixedDiscount(value: float)— фиксированная скидка (цена не ниже нуля).LoyaltyDiscount(points: int)— скидка, зависящая от числа бонусных баллов.
- Контекст
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.0order.set_strategy(NoDiscount())assert order.total() == 600.0Задание 2. Observer — система событий
Реализуйте паттерн Observer для оповещения подписчиков об изменениях.
Требования:
- Интерфейс
Subscriber(ABC)с методомupdate(event: str, data: dict) -> None. - Субъект
Publisher:subscribe(event: str, sub: Subscriber) -> None— подписка.unsubscribe(event: str, sub: Subscriber) -> None— отписка.notify(event: str, data: dict | None = None) -> None— оповещение всех подписчиков события.- Один подписчик может слушать несколько событий.
- Конкретные подписчики:
LogSubscriber— копит записи в списокlogs.CounterSubscriber— считает количество каждого события вdict[str, int].
- Класс
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 для редактора списка задач с историей и отменой.
Требования:
- Интерфейс
Command(ABC):execute() -> None— выполнить.undo() -> None— отменить (вернуть получателя в исходное состояние).description() -> str— текстовое описание для истории.
- Получатель
TaskList:- Атрибут
tasks: list[str]. - Методы
add(task),remove(index),rename(index, new_name).
- Атрибут
- Конкретные команды (каждая сохраняет данные, нужные для
undo):AddCommand(tasks, task)RemoveCommand(tasks, index)— должна запомнить удалённый элемент.RenameCommand(tasks, index, new_name)— должна запомнить старое имя.
- Инвокер
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 для экспорта данных в разные форматы.
Требования:
- Базовый класс
ReportExporter(ABC):- Шаблонный метод
export(data: list[dict]) -> str, фиксирующий порядок шагов:make_header()→ строки черезformat_row()→make_footer(). - Абстрактные методы:
make_header(columns),format_row(row). - Hook-метод
make_footer() -> strс пустой реализацией по умолчанию (подклассы могут переопределить).
- Шаблонный метод
- Конкретные экспортёры:
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,
оставляющий получателя в изменённом состоянии; отсутствие обязательных
питонических альтернатив.
Вопросы для самопроверки
- Чем Strategy отличается от Template Method по способу варьирования поведения (композиция против наследования)?
- В каком случае класс-стратегию разумно заменить обычной функцией, а когда — нет?
- Как паттерн Command обеспечивает отмену операции? Какие данные команда обязана
сохранить для корректного
undo? - Почему
functools.partialилиlambdaне подходят для команды с отменой? - В чём разница между push- и pull-моделью передачи данных в Observer?
- Что произойдёт, если подписчик отпишется во время оповещения (итерации по списку), и как это предотвратить?
- Что такое hook-метод в Template Method и чем он отличается от абстрактного шага?
- Почему шаблонный метод обычно не делают переопределяемым в подклассах?
- Где питоническая альтернатива (функция/callback) предпочтительнее полной ООП-реализации Strategy, Command и Observer?
- Как изученные паттерны реализуют принцип «открыт для расширения, закрыт для модификации»?