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

Практика 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
@contextmanager
def 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:

  1. transaction(name) — эквивалент задания 4, но через @contextmanager с try/except/else. В except печатать ROLLBACK и пробрасывать исключение, в elseCOMMIT.
  2. timer(label) — измеряет время выполнения блока (time.perf_counter) и печатает его в finally, чтобы время выводилось даже при исключении.

Требования:

  • Обязательно оборачивать yield в try/finally (или try/except/else), объяснив в комментарии, зачем это нужно.
  • Показать вложенное использование: timer снаружи, transaction внутри.
  • (по желанию) реализовать менеджер temporary_attr(obj, name, value), который временно подменяет атрибут объекта и восстанавливает старое значение на выходе.

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

КритерийВес
Задание 1: протокол и два независимых класса, корректная функция export15%
Задание 2: @runtime_checkable, проверка isinstance, объяснение ограничения15%
Задание 3: протокол Storage и обёртка через композицию/делегирование20%
Задание 4: корректный класс-менеджер (COMMIT/ROLLBACK, проброс исключения)20%
Задание 5: генераторные менеджеры с try/finally, таймер, вложенность20%
Качество кода: аннотации типов, docstrings, читаемость, демонстрации10%

Итого: 100%. Бонус до +10% за выполнение всех пунктов «по желанию».

Ориентир оценок: «отлично» — от 85%, «хорошо» — от 70%, «удовлетворительно» — от 55%.


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

  1. Чем структурная типизация (Protocol) отличается от номинальной (ABC)? Почему класс не обязан наследоваться от протокола, которому удовлетворяет?
  2. Что делает декоратор @runtime_checkable и какое у него ограничение при проверке isinstance?
  3. В чём разница между отношениями «является» (is-a) и «имеет» (has-a)? Когда композиция предпочтительнее наследования?
  4. Что такое делегирование и как оно связано с композицией?
  5. Какие два метода составляют протокол контекстного менеджера и за что отвечает каждый? Что попадает в переменную после as?
  6. Какие три аргумента получает __exit__ и какими они будут при нормальном завершении блока? Как заставить __exit__ подавить исключение?
  7. Почему yield внутри @contextmanager-функции почти всегда оборачивают в try/finally?
  8. Как соотносятся try/except/else в генераторном менеджере с COMMIT/ROLLBACK транзакции?