Дженерики и Protocol: синтаксис PEP 695, вариантность без махания руками, структурная типизация
PEP 695 делает дженерики родным синтаксисом: class Stack[T] без церемоний с TypeVar. Вариантность объясняет, почему list[Dog] — не list[Animal], а Sequence[Dog] — Sequence[Animal]. Protocol — структурная типизация в духе Go; runtime_checkable проверяет лишь имена.
Чекер сказал прямым текстом: Argument 1 has incompatible type "list[Dog]"; expected "list[Animal]". Автор был уверен, что это баг чекера — Dog есть Animal, так в любом курсе по ООП написано, — и потянулся за универсальным растворителем: cast(list[Animal], dogs). Ревью одобрило; сборка позеленела. Три спринта спустя кто-то доработал общий хелпер: тот стал дописывать в полученный список сентинел Cat — совершенно легально для list[Animal]. «Список собак» исходного вызова теперь содержал кота, и ночная джоба, звавшая .bark() на каждом элементе, умерла на позиции 3 114 с AttributeError — на четвёртом часу пятичасового батча. Чекер запрещал ровно этот сценарий: изменяемый контейнер собак — не контейнер животных, потому что любой, у кого в руках широкий тип, может положить туда не-собаку. Cast не чинил ложное срабатывание. Он удалил доказательство.
От церемоний с TypeVar к class Stack[T]
Если вы когда-нибудь открывали исходники библиотеки и видели пять объявлений TypeVar (переменной типа, параметризующей дженерик) до первой строчки бизнес-логики — вы знаете цену. PEP 695 убирает большую её часть.
Десять лет дженерики в Python означали объявить TypeVar на уровне модуля, протащить его через Generic[T] и помнить, какой из семнадцати T к чему привязан. PEP 695 (Python 3.12, 2023) сворачивает всё это в место определения:
# Старый стиль (всё ещё валиден, повсюду в легаси-коде)
from typing import Generic, TypeVar
T = TypeVar("T")
class OldStack(Generic[T]):
def push(self, item: T) -> None: ...
# PEP 695: параметр типа живёт там, где используется
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
class Repo[M: Model]: # bound: M must subtype Model
def get(self, pk: int) -> M | None: ...
def first[T](items: list[T]) -> T | None: # generic function
return items[0] if items else None
type Result[T] = T | None # generic type aliasНовый синтаксис — не только сахар: параметры скоупятся на класс или функцию (никаких протекающих модульных T), ограничения читаются прямо в строке ([M: Model]), а алиасы через type вычисляются лениво, что убивает целое семейство головных болей с форвард-ссылками. Чекер выводит Stack[int] из Stack[int]() и дальше доказывает каждый push/pop относительно этого. Честный компромисс: PEP 695 требует 3.12+, поэтому библиотеки, поддерживающие старые версии, по-прежнему пишут TypeVar, — читать придётся оба диалекта, даже если пишете только новый.
Вариантность честно
Вариантность отвечает на один вопрос: если Dog — подтип Animal, как соотносятся list[Dog] и list[Animal]? Для изменяемых контейнеров ответ — никак: list инвариантен. Хук — доказательство: если бы list[Dog] принимался как list[Animal], вызываемая сторона могла бы легально сделать append(Cat()), испортив список вызывающей. Read-only-представления безопасны в одну сторону: Sequence[Dog] есть Sequence[Animal] (ковариантность) — из типа, из которого можно только читать собак, безопасно читать как из источника животных. Функции разворачивают стрелку: Callable[[Animal], None] подходит туда, где ждут Callable[[Dog], None] (контравариантность по параметрам) — обработчик любого животного точно справится с собаками. Практические правила отсюда: принимайте в сигнатурах Sequence/Iterable/Mapping, если по-настоящему не мутируете, — и ошибка инвариантности исчезает бесплатно; возвращайте конкретные типы; никогда не «чините» ошибку вариантности через cast — чекер описывает реальную опасность алиасинга, а лечение — read-only-тип или копия списка.
def tag_all(animals: list[Animal]) -> None отвергает аргумент list[Dog]. Какой минимальный корректный фикс?
Protocol: структурная типизация и где врёт runtime_checkable
Когда вы проектируете типизированный API для функции, которой нужно лишь отправить метрику или закрыть ресурс, зачем классу вызывающего кода наследоваться от базы, которую контролируете вы? Protocol (PEP 544 — механизм структурной типизации, аналог интерфейсов Go) отвечает: незачем. Подходит любой класс с нужными методами — в том числе тестовые фейки, написанные коллегами без единого импорта из вашего модуля.
Protocol (PEP 544) — ответ Python на интерфейсы Go: соответствие по форме, а не по наследованию. Потребитель объявляет, что ему нужно; любой класс с подходящими методами удовлетворяет протоколу, и стороны не импортируют друг друга:
from typing import Protocol, overload, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class MetricsSink(Protocol):
def emit(self, name: str, value: float) -> None: ...
def flush_all(sinks: list[MetricsSink]) -> None:
for s in sinks:
s.emit("flush", 1.0) # statsd, datadog, a test fake — all conform
class Config:
@overload
def get(self, key: str) -> str | None: ...
@overload
def get(self, key: str, default: str) -> str: ...
def get(self, key, default=None):
return self._data.get(key, default)Так на сениорском уровне проектируются типизированные API: зависьте от самого узкого протокола, который нужен функции (Closeable, а не IOBase), — и тестовые фейки соответствуют автоматически, без гимнастики с наследованием моков. overload довершает набор: Config.get возвращает str | None без дефолта и гарантированный str с ним, так что вызывающие не пишут мёртвых проверок на None. Теперь пределы — они кусаются: @runtime_checkable включает isinstance(x, Closeable), но проверка сверяет только существование методов — close(self, force: bool) с несовместимой сигнатурой всё равно проходит isinstance и взрывается при вызове; протоколы с данными-атрибутами и немето́дные детали в рантайме тоже не проверяются. isinstance по протоколу к тому же ощутимо медленный (интроспекция атрибутов на каждый вызов) — нормально на границе, неправильно в горячем цикле. Полную сигнатуру верифицирует только статический чекер; рантайм-проверка — это обнюхивание имён. Считайте runtime_checkable пограничной эвристикой и никогда — валидацией.
isinstance(obj, SomeRuntimeCheckableProtocol) возвращает True, но вызов метода протокола на obj бросает TypeError. Как так?
- 01Объясните вариантность через случай list[Dog] и list[Animal]: почему чекер прав, отвергая его, и какие два корректных фикса?
- 02Что Protocol даёт такого, чего не дают ABC, и что именно проверяет @runtime_checkable в момент isinstance?
PEP 695 (Python 3.12) сделал дженерики родным синтаксисом: class Stack[T] и def first[T](...) скоупят параметр на определение, ограничения читаются как [M: Model], а алиасы type Result[T] = T | None вычисляются лениво — хотя церемония с TypeVar остаётся в любой библиотеке со старыми Python, так что читать нужно оба диалекта. Вариантность — то, о чём большинство инженеров машут руками, а чекер нет: изменяемые контейнеры инвариантны — list[Dog] не есть list[Animal], потому что широкий тип легализует дозапись кота в список собак, ровно то рантайм-падение из Хука после того, как cast заглушил предупреждение. Read-only-типы ковариантны (Sequence[Dog] есть Sequence[Animal]), а вызываемые контравариантны по параметрам (обработчик Animal служит там, где ждут обработчик Dog). Правила проектирования отсюда: принимайте самый широкий read-only-тип (Sequence, Iterable, Mapping), возвращайте конкретные типы и трактуйте ошибку вариантности как реальную опасность алиасинга — правьте сигнатуру, никогда не кастуйте. Protocol даёт структурную типизацию: потребители объявляют нужную форму, производители соответствуют без импортов и наследования, тестовые фейки достаются бесплатно. Его рантайм-лазейка @runtime_checkable сверяет только имена методов — сигнатуры в isinstance не сравниваются никогда, и проверка медленная, — значит это пограничная эвристика, а overload позволяет одному методу показывать точные типы под каждый паттерн вызова. Теперь, когда встретите ошибку вариантности от чекера, первый инстинкт — потянуться к Sequence вместо cast, а при необходимости сделать заглушку в тесте — к Protocol вместо наследования от продового кода.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.