Protocol в Python: заменить зависимость без наследования
Расчёт бюджета читает лимит из хранилища. В тесте хочется обойтись объектом в памяти, но основной класс требует конкретный клиент базы. Код расчёта знает о зависимости больше, чем ему нужно: ему достаточно одного метода.
Описывать потребность вызывающего кода
Protocol задаёт структурный интерфейс. Объект подходит, если имеет совместимые методы и атрибуты; наследоваться от Protocol реализации не обязаны. В примере функция проверки покупки нуждается только в read_limit(account). Она не создаёт таблицы, не открывает транзакции и не должна требовать эти операции от тестового объекта.
Интерфейс стоит разместить рядом с потребителем. Тогда его форма выражает задачу расчёта, а не весь API существующего клиента. Если позднее появится файловое хранилище, ему достаточно предоставить тот же метод. Это уменьшает связь между расчётом и способом получения данных.
Отделить совместимость типов от поведения
Проверяющий типы инструмент увидит, что MemoryLimits соответствует LimitReader по структуре. Во время выполнения обычный Python всё равно вызовет метод переданного объекта. Protocol не проверяет доступность базы и правильность результата чтения. Поведение закрепляется отдельными тестами, где известны входной лимит и допустимое действие.
Ниже функция также отклоняет отрицательную цену. Это правило принадлежит расчёту и остаётся настоящим в тесте. Подмена ограничивается источником лимитов: если заменить саму can_buy на мок, смысл такой проверки исчезнет. Для запуска достаточно стандартной библиотеки; файл также можно передать mypy.
from typing import Protocol
class LimitReader(Protocol):
def read_limit(self, account: str) -> int: ...
class MemoryLimits:
def __init__(self, limits: dict[str, int]) -> None:
self.limits = limits
def read_limit(self, account: str) -> int:
return self.limits[account]
def can_buy(reader: LimitReader, account: str, price: int) -> bool:
if price < 0:
raise ValueError("negative price")
return price <= reader.read_limit(account)
reader = MemoryLimits({"team": 500})
assert can_buy(reader, "team", 500)
assert not can_buy(reader, "team", 501)
try:
can_buy(reader, "team", -1)
except ValueError:
pass
else:
raise AssertionError("negative price accepted")
print("consumer contract checked")Что проверять на стороне реального адаптера
Для клиента базы потребуется отдельная проверка: корректно ли он переводит запрос и ответ в read_limit. Функция расчёта уже проверена независимо от этого механизма. Такое разделение помогает локализовать сбой: изменилось правило покупки либо адаптер перестал читать лимит.
Не добавляйте в Protocol методы «на будущее». Каждый обязательный член увеличивает требования ко всем реализациям. Если одному потребителю нужно чтение, а другому запись, два небольших интерфейса зачастую яснее одного универсального. Точка разделения определяется реальными вызовами.
Начать с аннотаций, затем сузить зависимость
В публичной практике type hints и mypy отработайте описание входов и результата. Затем примените это к интерфейсу зависимости из статьи. Откройте программу курса: в ней есть отдельные темы Protocol и тестируемого дизайна.
Продолжить на практике
- Python для продвинутых: ООП, типизация и тестирование ↗
Практический курс-мост от базового Python к разработке поддерживаемых приложений. Вы разберётесь со структурой пакетов, объектной моделью, современной типизацией и автоматическими тестами, а затем соедините эти инструменты в небольшом приложении с хранением данных и логированием.
