Продвинутая типизация: Generic, Protocol, TypedDict
Обобщённые функции
Функция first(items) возвращает элемент того же типа, что и в списке. Без обобщений пришлось бы писать Any и терять информацию. Решение — параметр типа:
from collections.abc import Sequence
def first[T](items: Sequence[T]) -> T: # Python 3.12+, PEP 695
return items[0]
name = first(["Анна", "Боря"]) # mypy знает: name — str
num = first((1, 2, 3)) # num — int
print(name.upper(), num + 1, first.__type_params__)
До Python 3.12 то же самое писали через TypeVar:
from typing import TypeVar
T = TypeVar("T")
def first(items: Sequence[T]) -> T:
return items[0]
Ограничения параметров
from decimal import Decimal
def biggest[T: (int, float, Decimal)](a: T, b: T) -> T: # T — один из перечисленных
return a if a > b else b
from collections.abc import Hashable
def dedupe[H: Hashable](items: list[H]) -> list[H]: # bound: любой подтип Hashable
return list(dict.fromkeys(items))
Обобщённые классы
from dataclasses import dataclass
@dataclass
class Page[T]:
items: list[T]
page: int
total: int
def has_next(self, size: int) -> bool:
return self.page * size < self.total
@dataclass
class User:
name: str
page: Page[User] = Page([User("Анна"), User("Боря")], page=1, total=5)
print(page.items[0].name, page.has_next(size=2))
print(Page[int]([1, 2], 1, 2))
Так типизируют ответы API (Page[OrderOut]), репозитории (Repository[User]), кеши.
Вариантность
int — подтип float для mypy. Но list[int] не подтип list[float]:
def add_half(values: list[float]) -> None:
values.append(0.5)
ints: list[int] = [1, 2]
add_half(ints) # mypy: error — иначе в list[int] оказался бы float
| Тип | Вариантность | Почему |
|---|---|---|
list[T], dict[K, V], set[T] |
инвариантны | изменяемые: можно и читать, и писать |
Sequence[T], Iterable[T], Mapping[K, V] (по V), tuple[T, ...] |
ковариантны | только чтение |
Callable[[A], R] |
контравариантен по A, ковариантен по R | функция, принимающая float, подойдёт там, где нужна принимающая int |
Практический вывод: в параметрах функций принимайте Sequence/Iterable/Mapping, если не собираетесь менять коллекцию.
Protocol
Структурная типизация (подробно — в уроке «Абстрактные классы, утиная типизация и Protocol»):
from typing import Protocol
class SupportsRead(Protocol):
def read(self, size: int = -1) -> bytes: ...
def checksum(stream: SupportsRead) -> int: # подойдёт файл, BytesIO, сокет — любой с read()
return sum(stream.read())
TypedDict
from typing import TypedDict, NotRequired, Required
class Address(TypedDict):
city: str
street: str
class UserPayload(TypedDict, total=False): # по умолчанию все ключи необязательны
id: Required[int] # кроме этого
name: str
address: Address
tags: NotRequired[list[str]]
payload: UserPayload = {"id": 1, "name": "Анна", "address": {"city": "Казань", "street": "Баумана"}}
print(payload["address"]["city"], UserPayload.__required_keys__)
TypedDict — для словарей, которые остаются словарями (JSON из API, **kwargs). Во время выполнения это обычный dict.
overload
Когда тип результата зависит от типа аргумента:
from typing import overload
@overload
def parse(value: str) -> int: ...
@overload
def parse(value: list[str]) -> list[int]: ...
def parse(value: str | list[str]) -> int | list[int]: # настоящая реализация
if isinstance(value, str):
return int(value)
return [int(v) for v in value]
n = parse("5") # mypy: int
ns = parse(["1", "2"]) # mypy: list[int]
Типизация декораторов: ParamSpec
import functools, time
from collections.abc import Callable
def timed[**P, R](func: Callable[P, R]) -> Callable[P, R]: # P — параметры, R — результат
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
print(f" {func.__name__}: {(time.perf_counter() - start) * 1000:.2f} мс")
return wrapper
@timed
def power(base: int, exp: int = 2) -> int:
return base ** exp
print(power(3, exp=4)) # mypy проверит аргументы: power("3") — ошибка
Self, NewType, псевдонимы
from typing import Self, NewType
class QueryBuilder:
def where(self, cond: str) -> Self: # наследник вернёт свой тип
self.conds.append(cond)
return self
UserId = NewType("UserId", int) # отдельный тип для mypy, int во время выполнения
OrderId = NewType("OrderId", int)
def get_user(uid: UserId) -> None: ...
get_user(OrderId(5)) # mypy: error — перепутали идентификаторы
type JSON = dict[str, JSON] | list[JSON] | str | int | float | bool | None # рекурсивный псевдоним (3.12+)
Сужение: TypeGuard и исчерпывающие проверки
from enum import Enum
from typing import assert_never, TypeGuard
class Status(Enum):
NEW = "new"
PAID = "paid"
CANCELLED = "cancelled"
def label(status: Status) -> str:
match status:
case Status.NEW:
return "Новый"
case Status.PAID:
return "Оплачен"
case Status.CANCELLED:
return "Отменён"
case _:
assert_never(status) # добавят Status.REFUNDED — mypy укажет сюда
def is_str_list(values: list[object]) -> TypeGuard[list[str]]: # своя функция-сужатель
return all(isinstance(v, str) for v in values)
print([label(s) for s in Status])
print(is_str_list(["a", "b"]), is_str_list(["a", 1]))
# В Python 3.13+ есть более точный TypeIs — он сужает тип и в ветке else.
Вопрос на собеседовании
«Что такое Generic и зачем он нужен?» — Обобщённый тип с параметром: list[T], Repository[User]. Позволяет описать связь типов (что вернёт first(list[str]) — str) без потери информации, в отличие от Any. В Python 3.12+ — синтаксис def f[T](x: T) -> T и class Box[T].
Итог
- Параметры типа:
def f[T](...),class Box[T], раньше —TypeVar; ограниченияT: (int, str)иT: Bound. - Изменяемые коллекции инвариантны; для чтения принимайте
Sequence/Iterable/Mapping. TypedDictдля словарей,Protocolдля структурных интерфейсов,overloadдля зависимых типов.ParamSpec(**P) сохраняет сигнатуру в декораторах;Self,NewType,type X = ....assert_never— исчерпывающая проверка вариантов.
Практика решено 0 из 5
Напишите обобщённую функцию first_or(items, default) с синтаксисом PEP 695 (def first_or[T](...)):
возвращает первый элемент последовательности или default, если она пустая.
Тип элементов, default и результата — один и тот же T.
Какие тесты проверяют код (2)
assert first_or([3, 4], 0) == 3 and first_or([], "нет") == "нет" assert len(first_or.__type_params__) == 1, "Объявите параметр типа: def first_or[T](...)"
from collections.abc import Sequence
def first_or[T](items: Sequence[T], default: T) -> T:
return items[0] if items else default
Создайте обобщённый класс Stack[T] (синтаксис PEP 695) с методами push(item: T) -> None,
pop() -> T (на пустом стеке — IndexError) и __len__.
Какие тесты проверяют код (3)
s = Stack[int](); s.push(1); s.push(2); assert s.pop() == 2 and len(s) == 1
try:
Stack().pop()
ok = False
except IndexError:
ok = True
assert ok
assert len(Stack.__type_params__) == 1, "class Stack[T]:"
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:
if not self._items:
raise IndexError("pop from empty stack")
return self._items.pop()
def __len__(self) -> int:
return len(self._items)
Почему mypy не разрешает передать list[int] в функцию, принимающую list[float]?
Для чего нужен ParamSpec?
Что даёт assert_never(x) в последней ветке match по Enum?
Войдите, чтобы отмечать пройденные уроки.