Generic functions и classes — PEP 695 modern syntax
В уроке 01 мы аннотировали конкретные типы: list[int], dict[str, int], int | None. Но что если функция работает с любым типом? def first(items: list) — теряет информацию (return type — Any). Нужны generic functions — функции параметризованные типом: def first[T](items: list[T]) -> T: — “если получили list of T, вернём T”.
PEP 695 (Python 3.12, 2023) дал inline syntax для generics — class Stack[T]:, def first[T](...), type Vector = list[float]. До PEP 695 (Python ≤ 3.11) приходилось импортировать TypeVar и наследовать Generic[T] — verbose. Modern PEP 695 syntax — default в M07/M08 курса.
В этом уроке:
- PEP 695 generic functions —
def first[T](items: list[T]) -> T:. - PEP 695 generic classes —
class Stack[T]:. - PEP 695 type aliases —
type Vector = list[float],type JSON = .... - Legacy bridge —
TypeVar('T')+Generic[T]для existing codebases. - Variance — covariance / contravariance (recipe-level).
- ParamSpec — typed decorators preserve signature; cross-link M03 урок 04 (closure carries arguments через
*args, **kwargs).
PEP 695 generic function — def first[T]
Простейший generic — функция возвращающая первый элемент списка с сохранением типа:
def first[T](items: list[T]) -> T:
"""Return first element. Raises IndexError if empty."""
return items[0]
# T inferred mypy'ом по call site
ints: list[int] = [1, 2, 3]
strs: list[str] = ['a', 'b', 'c']
x: int = first(ints) # mypy знает: T = int → x: int
y: str = first(strs) # mypy знает: T = str → y: str
Что произошло на уровне Python:
[T]объявляет type parameterTдля функцииfirst. Это scope-level аннотация, action — на parser-уровне, не runtime (PEP 695 Section “Generic Functions”).items: list[T]— параметр типа list-of-T (T resolved per-call mypy’ом).-> T— return type same T.- mypy / pyright при call
first(ints)substitutesT = int— return type становитсяint.
В runtime T — instance typing.TypeVar (создан implicitly из PEP 695 syntax). first.__type_params__ — кортеж type parameters:
print(first.__type_params__) # (T,)
print(first.__type_params__[0]) # T
print(type(first.__type_params__[0])) # <class 'typing.TypeVar'>
Cite: Lib/typing.py — TypeVar class; PEP 695 описывает как compiler def fn[T](...) синтаксис desugars в TypeVar creation.
PEP 695 generic class — class Stack[T]
Generic класс — параметризованный data structure. Stack-of-T:
class Stack[T]:
"""Generic LIFO stack."""
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()
def peek(self) -> T | None:
return self._items[-1] if self._items else None
def __len__(self) -> int:
return len(self._items)
# Использование — T resolved при construction
ints = Stack[int]()
ints.push(1)
ints.push(2)
print(ints.pop()) # 2 — mypy знает: int
strs = Stack[str]()
strs.push('hello')
print(strs.peek()) # 'hello' — mypy знает: str | None
# Type error при неправильном типе:
# ints.push('oops') # mypy: Argument 1 has incompatible type "str"; expected "int"
Что compiler делает в Python 3.12+:
class Stack[T]:— добавляет type parameterTв class scope.Stack.__type_params__=(T,)— runtime accessible.Stack[int]()— сначалаStack.__class_getitem__(int)создаётtypes.GenericAlias(как в PEP 585), потом()инстанциирует.- mypy разрезает signature
Stack[int].push(self, item: T)→(self, item: int)substitution.
print(Stack.__type_params__) # (T,)
print(Stack[int]) # __main__.Stack[int]
print(type(Stack[int])) # <class 'types.GenericAlias'>
PEP 695 type alias — type Vector = list[float]
PEP 695 ввёл новый statement type для алиасов:
type Vector = list[float]
type Matrix = list[list[float]]
type JSON = int | str | bool | None | list['JSON'] | dict[str, 'JSON']
def magnitude(v: Vector) -> float:
"""Euclidean norm."""
return sum(x * x for x in v) ** 0.5
def transpose(m: Matrix) -> Matrix:
"""Transpose square matrix."""
return [[m[j][i] for j in range(len(m))] for i in range(len(m[0]))]
print(magnitude([3.0, 4.0])) # 5.0
print(transpose([[1.0, 2.0], [3.0, 4.0]])) # [[1.0, 3.0], [2.0, 4.0]]
type statement vs обычное присваивание:
# OK — PEP 695:
type Vec1 = list[float]
print(type(Vec1)) # <class 'typing.TypeAliasType'>
# Старый стиль (всё ещё работает, но не recommended):
Vec2 = list[float]
print(type(Vec2)) # <class 'types.GenericAlias'> — не distinct alias
PEP 695 type создаёт typing.TypeAliasType instance — мypy и pyright treat это как named alias (better error messages, recursive types работают). Cite: Lib/typing.py — TypeAliasType class.
Legacy bridge — TypeVar + Generic
В существующих codebases (Python 3.11 и старше) generics писали через TypeVar + Generic[T]:
# Legacy (Python 3.11 и старше):
from typing import TypeVar, Generic
T = TypeVar('T')
def first_legacy(items: list[T]) -> T:
return items[0]
class Stack_legacy(Generic[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()
Эквивалентно PEP 695 syntax. Для новых проектов используйте PEP 695 (компактнее, читабельнее). Для maintenance существующих codebases — может быть удобно остаться на legacy syntax (consistency).
Bound TypeVars — type parameter ограничен subclass’ом:
# Legacy:
from typing import TypeVar
NumT = TypeVar('NumT', bound=int | float)
def double_legacy(x: NumT) -> NumT:
return x * 2
# PEP 695 modern:
def double[T: int | float](x: T) -> T:
return x * 2
print(double(5)) # 10 — T inferred as int
print(double(3.14)) # 6.28 — T inferred as float
# double('hello') # mypy error: incompatible bound
T: int | float (PEP 695) ≡ TypeVar('T', bound=int | float) (legacy) — type parameter может быть int, float, или их subclass.
Variance — covariant / contravariant
Variance — направление subtyping для generic types. Это deep типовая теория, но на practice одного recipe достаточно:
# Covariant T_co — read-only contexts (output positions)
class ImmutableList[T_co]:
"""Read-only list — covariant в T."""
def __init__(self, items: list[T_co]) -> None:
self._items: list[T_co] = list(items)
def __getitem__(self, idx: int) -> T_co:
return self._items[idx]
def __len__(self) -> int:
return len(self._items)
# Contravariant T_contra — write-only contexts (input positions)
class Sink[T_contra]:
"""Write-only sink — contravariant в T."""
def write(self, item: T_contra) -> None:
print(f'received: {item}')
Pragmatic правило:
- Covariant (
T_co) — type parameter появляется только в return positions (output). Pattern для read-only, immutable types.ImmutableList[Cat]— subtypeImmutableList[Animal](еслиCat— subtypeAnimal). - Contravariant (
T_contra) — type parameter появляется только в parameter positions (input). Pattern для callbacks, sinks.Sink[Animal]— subtypeSink[Cat](callback принимающий Animal принимает и Cat). - Invariant (default) — type parameter в обоих positions.
list[T]invariant —list[Cat]НЕ subtypelist[Animal](mutable — push Animal вlist[Cat]сломает type safety).
В PEP 695 variance специфицируется через suffix conventions на TypeVar (covariant T_co, contravariant T_contra) — mypy проверяет что usage consistent с declared variance. Cite: PEP 484 Section “Covariance and contravariance” — original treatment; PEP 695 carries semantics.
Для прикладного кода 90% времени достаточно invariant (default) — variance becomes important когда вы пишете library APIs с inheritance hierarchies.
ParamSpec — typed decorators preserve signature
Pitfall в декораторах из M06: wrapper’ы теряют signature ((*args, **kwargs) вместо оригинальной сигнатуры). Тогда mypy теряет проверку — caller декорированной функции может передать неправильные типы. Решение — ParamSpec (PEP 612, Python 3.10):
from collections.abc import Callable
import functools
def log_calls[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
"""Decorator preserving signature через ParamSpec."""
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f'calling {fn.__name__} args={args}, kwargs={kwargs}')
return fn(*args, **kwargs)
return wrapper
@log_calls
def add(x: int, y: int) -> int:
return x + y
# mypy preserves signature: add(x: int, y: int) -> int
result: int = add(1, 2) # OK
# add('a', 'b') # mypy error: incompatible types
# add(1) # mypy error: missing argument 'y'
Что нового в PEP 695 syntax:
[**P, R]— параметризация:**P— ParamSpec (capture’ит signature args/kwargs),R— обычный TypeVar для return type.Callable[P, R]— function принимающаяP.args, **P.kwargs(всю signature) и возвращающаяR.*args: P.args, **kwargs: P.kwargs— explicit forwarding с типами.
Cross-link M03 урок 04 (closure): wrapper — closure замыкающая fn через cell. M03 урок 04 показал как wrapper.__closure__[0].cell_contents is оригинал. ParamSpec — type-system overlay поверх closure mechanism: closure carries *args/**kwargs в runtime, ParamSpec обеспечивает type-safety на этом forwarding.
# Эмпирически verified (Python 3.12.7):
print(add.__name__) # 'add' (благодаря @functools.wraps)
print(add.__wrapped__) # <function add at ...> (оригинал)
print(add.__type_params__) # () (decorator's type params не попадают)
Cite: PEP 612 — Parameter Specification Variables; Lib/typing.py — ParamSpec class.
PEP 696 — type parameter defaults (Python 3.13+)
PEP 696 — Type Defaults for Type Parameters (accepted, реализован в Python 3.13, October 2024) добавил default values для type parameters. До PEP 696 type parameter без явной аннотации со стороны caller’а превращался в Any (или Unknown в pyright). С 3.13+ можно задать default — class Container[T = int]: — если caller не указал тип, T = int.
# PEP 696 — Python 3.13+ syntax
class Container[T = int]:
"""Container с default type parameter — T = int если не указан явно."""
def __init__(self, item: T) -> None:
self.item: T = item
def get(self) -> T:
return self.item
# Без явного [int] — T defaults к int (PEP 696)
c1 = Container(42) # mypy: Container[int]
print(c1.get()) # 42
# С явным параметром — overrides default
c2: Container[str] = Container('hello') # T = str
print(c2.get()) # 'hello'
Что было до PEP 696 (Python ≤ 3.12 — TypeVar не имел default):
# Python 3.12 — без default, нужен explicit annotation
from typing import TypeVar, Generic
T = TypeVar('T') # default не поддерживается в 3.12
class Container_legacy(Generic[T]):
def __init__(self, item: T) -> None:
self.item: T = item
# Без annotation — T = Unknown / Any
c = Container_legacy(42) # mypy: Container_legacy[<nothing>] — теряется типизация
Generic functions с defaults — рабочая комбинация PEP 695 + PEP 696:
def first_or_default[T = int](items: list[T], default: T) -> T:
"""Возвращает первый элемент или default. T defaults к int."""
return items[0] if items else default
# Caller без явного [T]:
x: int = first_or_default([1, 2, 3], 0) # T inferred как int
y: str = first_or_default(['a', 'b'], 'fallback') # T inferred как str
Comparison с TypeVar(default=...) (PEP 696 legacy form, тоже работает в 3.13+):
# Legacy syntax (3.13+ only — TypeVar получил параметр default):
from typing import TypeVar, Generic
T_legacy = TypeVar('T_legacy', default=int)
class Container_legacy_default(Generic[T_legacy]):
def __init__(self, item: T_legacy) -> None:
self.item: T_legacy = item
# Same semantics что PEP 695 [T = int]:
c = Container_legacy_default(42) # T_legacy = int (default)
TypeVar default ranges — default может быть constrained: T = int | str, или ссылаться на bound:
# Default + bound:
class Numeric[T: int | float = float]:
"""Numeric box — T bounded к числам, defaults к float."""
def __init__(self, value: T) -> None:
self.value: T = value
n1 = Numeric(3.14) # T = float (default match'нулся)
n2: Numeric[int] = Numeric(42) # T = int (override)
Production use case: library API с reasonable default — caller не должен thinking about generic parameter в простых случаях, но может override для advanced patterns. Cite PEP 696 Section “Specification” + Python 3.13 What’s New — typing.
Pitfall: PEP 696 defaults — Python 3.13+. На 3.12 syntax
class Container[T = int]:приведёт кSyntaxError. mypy/pyright tooling уже supports defaults в 3.13+ codebase; для compat с 3.12 — оставайтесь без defaults или используйтеfrom __future__ importpatterns.
Diagram: PEP 695 vs legacy generic syntax
Recipe: typed retry decorator (PEP 695 + ParamSpec)
End-to-end production pattern — @retry декоратор с сохранением signature через ParamSpec:
from collections.abc import Callable
import functools
def retry[**P, R](max_attempts: int = 3) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Retry decorator factory — preserves signature через ParamSpec."""
def decorator(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last_exc: Exception | None = None
for attempt in range(max_attempts):
try:
return fn(*args, **kwargs)
except Exception as e:
last_exc = e
assert last_exc is not None
raise last_exc
return wrapper
return decorator
@retry(max_attempts=3)
def flaky_compute(x: int, y: int) -> int:
"""Может бросить, попытается до 3 раз."""
return x * y
# mypy preserves signature: flaky_compute(x: int, y: int) -> int
result: int = flaky_compute(3, 4) # OK
print(result) # 12
@retry — three nested defs (M06 урок 02 pattern), теперь typed. **P, R — type params factory, не consumed на factory call (retry(max_attempts=3)); они resolve’ятся на decorator application (@retry(...) def flaky). mypy полностью типизированно — caller flaky_compute('a', 'b') поднимет error.
Ключевые выводы
- PEP 695 (Python 3.12, 2023) — inline generic syntax:
class Stack[T]:,def first[T](...),type Vector = list[float]. Default в M07/M08. __type_params__— runtime tuple type parameters (атрибут наclass/def/type). Empirically inspectable.- Legacy bridge —
TypeVar('T')+Generic[T]всё ещё работает; используется для existing codebases. PEP 695 эквивалент функционально. - Bound TypeVars —
def double[T: int | float](x: T)(PEP 695) ≡TypeVar('T', bound=int | float)(legacy). - Variance: covariant
T_co(output, immutable), contravariantT_contra(input, sinks), invariant default (mutable). 90% production-кода — invariant default. - ParamSpec (PEP 612) —
[**P, R]+Callable[P, R]+*args: P.args, **kwargs: P.kwargsдля typed decorators preserving signature. Cross-link M03 урок 04: closure carries*args/**kwargs, ParamSpec — type-system overlay поверх этого. type Vector = list[float]— PEP 695 statement создаётtyping.TypeAliasTypeinstance. Distinct alias (recursive types работают, better mypy error messages).- PEP 696 type defaults (Python 3.13+) —
class Container[T = int]:/def fn[T = int](...)/TypeVar('T', default=int). Если caller не указал параметр явно — используется default; иначе — overrides. Реальная type-safe alternative дляT = Anyfallback в legacy code.
Дальше — урок 03 — Protocol (structural subtyping) и TypedDict (typed dict-as-record).