Практика 5. Практическая работа 5. Протоколы и контекстные менеджеры
Цель
- Освоить структурную типизацию в Python через
typing.Protocolи научиться отличать её от номинальной типизации (ABC). - Применять декоратор
@runtime_checkableи понимать его ограничения. - Заменять жёсткие иерархии наследования композицией и делегированием.
- Реализовывать контекстные менеджеры двумя способами: как класс с методами
__enter__/__exit__и как генератор с декоратором@contextmanager. - Понимать роль
__exit__в обработке исключений и освобождении ресурсов.
Краткая теория
Структурная типизация. Объект совместим с протоколом, если у него есть
нужные методы и атрибуты — наследоваться от протокола не требуется. Это
формализация утиной типизации: контракт становится явным, а статический
анализатор (mypy) может проверить его до запуска.
from typing import Protocol
class Drawable(Protocol): def draw(self) -> str: ...
def render(shape: Drawable) -> str: return shape.draw()@runtime_checkable разрешает проверку isinstance(obj, ProtocolName),
но смотрит только на наличие методов по именам, а не на их сигнатуры.
Композиция вместо наследования. Отношение «имеет» (has-a) гибче отношения «является» (is-a): поведение собирается из частей, которые легко комбинировать и подменять. Делегирование — перепоручение работы внутреннему объекту — основной приём реализации композиции.
Контекстные менеджеры. Объект, реализующий протокол with:
__enter__(self)— вход в блок; возвращаемое значение попадает вas;__exit__(self, exc_type, exc_val, exc_tb)— выход; вызывается всегда. ВозвратTrueподавляет исключение,False/None— пробрасывает дальше.
Альтернатива — генератор с @contextmanager: код до yield играет роль
__enter__, код после (в finally) — роль __exit__.
from contextlib import contextmanager
@contextmanagerdef timed(label: str): import time start = time.perf_counter() try: yield finally: print(f"{label}: {time.perf_counter() - start:.4f} с")Задания
Задание 1. Протокол сериализатора
Опишите протокол Serializer, требующий метод serialize(self, data: dict) -> str.
Реализуйте два класса, не наследуя их от протокола: JsonSerializer
(возвращает JSON-строку через json.dumps) и KeyValueSerializer (формат
key=value, через точку с запятой).
from typing import Protocol
class Serializer(Protocol): def serialize(self, data: dict) -> str: ...
class JsonSerializer: def serialize(self, data: dict) -> str: ...
class KeyValueSerializer: def serialize(self, data: dict) -> str: ...
def export(data: dict, serializer: Serializer) -> str: ... # вернуть результат serializer.serialize(data)Требования:
- Функция
exportпринимает любой объект, удовлетворяющий протоколу. - Аннотации типов обязательны; код должен проходить проверку
mypy. - Покажите в демонстрации вызов
exportс обоими сериализаторами.
Задание 2. @runtime_checkable и проверка во время выполнения
Пометьте протокол Serializer из задания 1 декоратором @runtime_checkable.
Напишите функцию safe_export(data, obj), которая через isinstance проверяет,
является ли obj сериализатором, и либо вызывает serialize, либо выбрасывает
TypeError с понятным сообщением.
Требования:
- Продемонстрируйте
isinstanceна подходящем объекте (True) и на объекте без методаserialize(False). - В комментарии поясните ограничение
@runtime_checkable: почему объект с методомserialize(self, data, extra)тоже пройдётisinstance, хотя вызвать его какserialize(data)не получится.
Задание 3. Замена наследования композицией
Дан «плохой» код на наследовании. Логирование добавляется отдельным подклассом для каждого хранилища, что приводит к комбинаторному взрыву классов.
class MemoryStorage: def __init__(self): self._data: dict[str, str] = {} def save(self, key: str, value: str) -> None: self._data[key] = value def load(self, key: str) -> str: return self._data[key]
class LoggingMemoryStorage(MemoryStorage): # плохо: жёсткая привязка def save(self, key: str, value: str) -> None: print(f"LOG save {key}") super().save(key, value)Требования:
- Опишите протокол
Storageс методамиsaveиload. - Реализуйте обёртку
LoggingStorage, которая принимает любойStorageв конструктор (композиция) и логирует операции, делегируя их вложенному объекту. - Реализуйте второе хранилище (например,
DictStorageс другой реализацией), чтобы показать, чтоLoggingStorageработает с обоими без изменений. - (по желанию) добавьте обёртку
CachingStorageтем же приёмом и покажите, что обёртки можно комбинировать:LoggingStorage(CachingStorage(MemoryStorage())).
Задание 4. Контекстный менеджер-класс: транзакция
Реализуйте класс Transaction с методами __enter__ и __exit__, имитирующий
транзакцию базы данных.
class Transaction: def __init__(self, name: str): ... def __enter__(self) -> "Transaction": ... def __exit__(self, exc_type, exc_val, exc_tb) -> bool: ... def execute(self, query: str) -> None: ...Требования:
- При входе печатать
BEGIN, возвращатьself(чтобы вызыватьexecute). - При нормальном завершении печатать
COMMIT. - Если в теле
withвозникло исключение — печататьROLLBACKи пробросить исключение дальше (вернутьFalse). - Метод
executeнакапливает запросы в списке; приCOMMITпоказать их число. - Продемонстрируйте оба сценария: успешный и с исключением (например,
raise ValueError).
Задание 5. Тот же менеджер через @contextmanager + таймер
Реализуйте два генераторных контекстных менеджера в модуле contextlib:
transaction(name)— эквивалент задания 4, но через@contextmanagerсtry/except/else. ВexceptпечататьROLLBACKи пробрасывать исключение, вelse—COMMIT.timer(label)— измеряет время выполнения блока (time.perf_counter) и печатает его вfinally, чтобы время выводилось даже при исключении.
Требования:
- Обязательно оборачивать
yieldвtry/finally(илиtry/except/else), объяснив в комментарии, зачем это нужно. - Показать вложенное использование:
timerснаружи,transactionвнутри. - (по желанию) реализовать менеджер
temporary_attr(obj, name, value), который временно подменяет атрибут объекта и восстанавливает старое значение на выходе.
Критерии оценки
| Критерий | Вес |
|---|---|
Задание 1: протокол и два независимых класса, корректная функция export | 15% |
Задание 2: @runtime_checkable, проверка isinstance, объяснение ограничения | 15% |
Задание 3: протокол Storage и обёртка через композицию/делегирование | 20% |
| Задание 4: корректный класс-менеджер (COMMIT/ROLLBACK, проброс исключения) | 20% |
Задание 5: генераторные менеджеры с try/finally, таймер, вложенность | 20% |
| Качество кода: аннотации типов, docstrings, читаемость, демонстрации | 10% |
Итого: 100%. Бонус до +10% за выполнение всех пунктов «по желанию».
Ориентир оценок: «отлично» — от 85%, «хорошо» — от 70%, «удовлетворительно» — от 55%.
Вопросы для самопроверки
- Чем структурная типизация (
Protocol) отличается от номинальной (ABC)? Почему класс не обязан наследоваться от протокола, которому удовлетворяет? - Что делает декоратор
@runtime_checkableи какое у него ограничение при проверкеisinstance? - В чём разница между отношениями «является» (is-a) и «имеет» (has-a)? Когда композиция предпочтительнее наследования?
- Что такое делегирование и как оно связано с композицией?
- Какие два метода составляют протокол контекстного менеджера и за что отвечает
каждый? Что попадает в переменную после
as? - Какие три аргумента получает
__exit__и какими они будут при нормальном завершении блока? Как заставить__exit__подавить исключение? - Почему
yieldвнутри@contextmanager-функции почти всегда оборачивают вtry/finally? - Как соотносятся
try/except/elseв генераторном менеджере с COMMIT/ROLLBACK транзакции?