Продвинутая типизация: Generic, Protocol, TypedDict

⏱ 40 мин · 🧩 заданий: 5

Обобщённые функции

Функция 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

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Напишите обобщённую функцию 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](...)"
Задание 2 Программа на Python

Создайте обобщённый класс 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]:"
Задание 3 Один вариант ответа

Почему mypy не разрешает передать list[int] в функцию, принимающую list[float]?

Задание 4 Один вариант ответа

Для чего нужен ParamSpec?

Задание 5 Один вариант ответа

Что даёт assert_never(x) в последней ветке match по Enum?

Войдите, чтобы отмечать пройденные уроки.