Типизация большого проекта на Python: Protocol и Generic
Аннотации перестают помогать, когда проект перерастает несколько модулей. Разбираем, где выбирать Protocol вместо ABC, как TypeVar связывает типы и во что упирается mypy на больших кодовых базах.

На проекте в 200+ тысяч строк mypy начинает падать не на ошибках типов, а на архитектуре аннотаций: базовые классы связывают модули в жёсткий граф импортов, а Any в одной сигнатуре молча гасит проверку в десятке зависимых мест. Ниже — про три инструмента, которые снимают эти боли: структурную типизацию через Protocol, параметризацию через TypeVar и обобщённые классы. Материал под Python 3.11–3.12 и mypy 1.x; синтаксис class C[T] из PEP 695 доступен с 3.12.
Задача: развязать типы без наследования
Классическая проблема большого кода — сервис принимает объект и ожидает у него метод save(). Очевидное решение — общий абстрактный класс Storage(ABC), от которого наследуются все реализации. Работает, пока реализации ваши. Как только объект приходит из чужой библиотеки или из другого слоя, который не должен знать про ваш ABC, наследование становится невозможным: чужой класс от вашего ABC не наследуется, а лезть в него нельзя.
Ещё одна цена наследования — импорты. Чтобы реализация наследовалась от базового класса, она обязана его импортировать. В большом проекте это тянет граф зависимостей вверх и рано или поздно даёт циклический импорт между слоями domain и infrastructure.
Protocol: совпадение по форме, а не по родословной
typing.Protocol описывает структуру: любой объект, у которого есть нужные методы и атрибуты с совместимыми сигнатурами, считается подходящим. Наследоваться и импортировать протокол реализации не нужно.
from typing import Protocol
class SupportsSave(Protocol):
def save(self, data: bytes) -> str: ...
def persist(storage: SupportsSave, blob: bytes) -> str:
# storage может быть любым объектом с методом save нужной формы
return storage.save(blob)
class S3Client: # не знает про SupportsSave и ничего не наследует
def save(self, data: bytes) -> str:
return "s3://bucket/key"
persist(S3Client(), b"payload") # mypy доволен: форма совпадает
mypy проверяет соответствие на месте вызова, статически. Никакого рантайм-оверхеда нет. Если нужна проверка isinstance() в рантайме, протокол помечают декоратором @runtime_checkable — но он проверяет только наличие методов по имени, не их сигнатуры.
Структурная типизация (duck typing на уровне типов): совместимость определяется набором методов и атрибутов объекта, а не тем, от какого класса он произошёл. Противоположность — номинальная типизация, где важна цепочка наследования.
Protocol стоит заводить со стороны потребителя, а не поставщика. Интерфейс описывает тот, кому он нужен, ровно в том объёме, который он использует. Так один класс-реализация естественно удовлетворяет нескольким узким протоколам из разных слоёв.
ABC против Protocol: когда что
| Критерий | ABC (наследование) | Protocol (структура) |
|---|---|---|
| Совместимость | Явное наследование | Совпадение по форме |
| Импорт в реализации | Обязателен | Не нужен |
| Чужие/сторонние классы | Нельзя подключить | Подходят, если форма совпала |
| Общая реализация методов | Можно вынести в базовый класс | Нет, только сигнатуры |
| Проверка в рантайме | Полноценный isinstance | Только @runtime_checkable, по именам |
| Гарантия «забыл метод» | Ошибка при инстанцировании | Ошибка только на месте использования |
Практический критерий: нужна общая логика в базовом классе или контроль в момент создания объекта — берите ABC. Нужно развязать слои и принимать чужие объекты — Protocol.
TypeVar: связать вход и выход
TypeVar нужен, когда типы аргумента и результата должны быть одним и тем же типом, а не просто «каким-то типом». Разница видна на first():
from typing import TypeVar, Sequence
T = TypeVar("T")
def first(items: Sequence[T]) -> T:
return items[0]
x = first([1, 2, 3]) # mypy выводит x: int
y = first(["a", "b"]) # mypy выводит y: str
Если написать items: Sequence[object] -> object, вызывающий код потеряет тип и получит object вместо int. TypeVar сохраняет связь.
Ограничения и bound
Голый TypeVar принимает что угодно. Часто нужно сузить множество допустимых типов:
from typing import TypeVar
# bound: тип должен быть подтипом указанного
NumberT = TypeVar("NumberT", bound=float)
def double(value: NumberT) -> NumberT:
return value * 2 # умножение допустимо для float и его подтипов
# constraints: тип — строго один из перечисленных, без промежуточных
StrOrBytesT = TypeVar("StrOrBytesT", str, bytes)
def concat(a: StrOrBytesT, b: StrOrBytesT) -> StrOrBytesT:
return a + b
Разница тонкая, но важная. bound=float разрешит любой подтип float и вернёт именно его. (str, bytes) запрещает смешивать: concat("x", b"y") — ошибка типов, потому что оба аргумента обязаны быть одним из двух конкретных типов.
Инвариантность/ковариантность. По умолчанию TypeVar инвариантен: list[int] не считается подтипом list[float], даже что int — подтип float. Это защищает от записи неправильного элемента в список. Для read-only контейнеров объявляют covariant=True, для write-only — contravariant=True.
Обобщённые классы
Тот же TypeVar параметризует классы — репозитории, кеши, обёртки. До 3.12 через Generic, с 3.12 доступен синтаксис PEP 695.
from typing import Generic, TypeVar
EntityT = TypeVar("EntityT")
# синтаксис для 3.9–3.11
class Repository(Generic[EntityT]):
def __init__(self) -> None:
self._items: dict[int, EntityT] = {}
def add(self, key: int, entity: EntityT) -> None:
self._items[key] = entity
def get(self, key: int) -> EntityT:
return self._items[key]
class User: ...
users: Repository[User] = Repository()
users.add(1, User())
u = users.get(1) # mypy знает: u: User
На Python 3.12 то же самое короче, без отдельного объявления TypeVar:
# Python 3.12+, PEP 695
class Repository[EntityT]:
def get(self, key: int) -> EntityT: ...
Оба варианта дают одинаковую проверку типов. Смешивать в одном файле стоит осторожно: если проект ещё поддерживает 3.11, новый синтаксис его сломает на этапе парсинга, а не проверки.
Что ломается на большом проекте
- Any как чёрная дыра. Одна функция, вернувшая
Any(часто из-за нетипизированной библиотеки), заражает все выражения дальше по цепочке — они тоже становятсяAny, и проверка молча отключается. Включите--warn-return-anyи--disallow-any-generics, чтобы такие места стали видны. - Protocol не ловит забытый метод у реализации. Класс без метода из протокола не даст ошибку при создании — только когда его передадут в функцию с этим протоколом. Если такого вызова в тестах нет, дыра доедет до прода.
- runtime_checkable обманывает.
isinstance(obj, MyProto)проверяет наличие атрибутов по имени, но не их сигнатуры. Объект с методомsaveдругой формы пройдёт проверку и упадёт при вызове. - Инвариантность контейнеров удивляет. Функция, принимающая
list[float], не приметlist[int]. Для параметра, который только читается, объявляйтеSequence[float]вместоlist—Sequenceковариантен. - Скорость mypy. На большой базе полная проверка идёт минуты. Включите кеш и режим демона:
dmypy run -- .переиспользует результаты между запусками и режет время инкрементальной проверки в разы.
Что настроить дальше
Заведите строгий профиль mypy и включайте флаги постепенно, модуль за модулем, а не всё сразу — иначе получите тысячу ошибок и желание удалить конфиг. Порядок, который работает на практике:
- Включите
--check-untyped-defsи--warn-unused-ignoresна всём проекте. - Добавьте per-module секции в конфиг и поднимайте
disallow_untyped_defs = Trueсначала на новых пакетах. - Уберите
Anyиз публичных сигнатур библиотечных модулей — именно они текут дальше всего. - Замените ABC-интерфейсы на границах слоёв на
Protocol, чтобы разорвать лишние импорты.
Инвалидация кеша dmypy и настройка stubs для сторонних библиотек — отдельная тема; здесь важно, что оба инструмента ускоряют цикл проверки, если не игнорировать их предупреждения о рассинхроне.
Помощник по курсовой и диплому: Большая работа поэтапно: от плана до защиты
Спросить за 25 ₽Источники: typing — Support for type hints (Python docs), PEP 544 — Protocols: Structural subtyping
Частые вопросы
У меня Python 3.11, а в примере синтаксис class Repository[T]. Что делать?
Protocol проверяется в рантайме?
В чём разница между bound и списком типов в TypeVar?
Почему функция с list[float] не принимает list[int]?
mypy не нашёл ошибку в проекте, хотя типы явно неверные. Почему?
mypy проверяет большой проект слишком медленно. Как ускорить?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.