Лекция 2. Типы: почему и как#
План#
Зачем типы — что дают и чего не дают.
collections.abc— готовая иерархия абстракций для параметров.Generics —
Tкак переменная типа.Protocol — утиная типизация статически + LSP.
Variance — когда
list[Dog]подходит вместоlist[Animal].Forward-references — ссылки на ещё не определённые типы.
Overload — точный возвращаемый тип от значения аргумента.
Ещё из typing — TypedDict, Literal, Annotated, Self, Never, Final,
ParamSpec,TypeVarTuple.Gradual typing —
Anyvsobject, включение mypy по одному модулю.
1. Зачем типы#
Три уровня пользы:
Документация которая не врёт. Комментарии врут — код меняется, коммент забыли. Аннотации проверяет mypy.
Отлов багов до тестов. «Передал не то», «забыл про
None», «поменял сигнатуру» — ловится без запуска.IDE навигация. VS Code / PyCharm показывают методы по типу. Без аннотаций — гадают.
Что типы НЕ делают: не ускоряют, не заменяют тесты (int не гарантирует «неотрицательное»), не делают код читаемее автоматически.
Термин: static vs runtime check
- Static — проверяет тайпчекер (mypy, pyright) до запуска.
- Runtime — Python проверяет при выполнении (например,
isinstance).
Базовые аннотации (int, str, list[int], dict[str, int], X | None, Callable) считаем известными из базового курса Даниэля. Идём дальше — в то, что там не проходили: Protocol, generics, variance.
Шпаргалка по базовым аннотациям
from collections.abc import Callable
# переменные
age: int = 30
name: str = "Alice"
active: bool = True
# контейнеры (PEP 585, 3.9+)
nums: list[int] = [1, 2, 3]
mapping: dict[str, int] = {"a": 1}
pair: tuple[str, int] = ("x", 5)
uniq: set[int] = {1, 2}
# опциональность (PEP 604, 3.10+)
maybe_id: int | None = None
# функции
def greet(name: str, times: int = 1) -> str:
return name * times
# lambda
handler: Callable[[int, str], bool] = lambda n, s: len(s) > n
typing.List, typing.Dict, typing.Tuple, typing.Set, typing.Optional, typing.Union — в новом коде не нужны. List/Dict/Tuple/Set устарели с 3.9 (PEP 585), Optional/Union формально не устарели — их вытеснил X | Y из 3.10 (PEP 604). В новом коде — встроенные list, dict, tuple, set и X | None / X | Y. ruff с правилами UP006/UP007/UP045 их подсветит и предложит автофикс.
# было (до 3.9) -> стало
from typing import List, Dict, Optional, Union
x: List[int] -> x: list[int]
d: Dict[str, int] -> d: dict[str, int]
maybe: Optional[str] -> maybe: str | None
either: Union[int, str] -> either: int | str
2. Абстракции из collections.abc#
Мотивация#
Написали функцию:
%%writefile /tmp/abc_motivation.py
def total(items: list[int]) -> int:
return sum(items)
total([1, 2, 3]) # ok
total((1, 2, 3)) # mypy: tuple вместо list
total(range(10)) # mypy: range вместо list
total(x for x in [1, 2]) # mypy: generator вместо list
Writing /tmp/abc_motivation.py
!uv run mypy --strict /tmp/abc_motivation.py || true
/tmp/abc_motivation.py:6: error: Argument 1 to "total" has incompatible type "tuple[int, int, int]"; expected "list[int]" [arg-type]
/tmp/abc_motivation.py:7: error: Argument 1 to "total" has incompatible type "range"; expected "list[int]" [arg-type]
/tmp/abc_motivation.py:8: error: Argument 1 to "total" has incompatible type "Generator[int, None, None]"; expected "list[int]" [arg-type]
Found 3 errors in 1 file (checked 1 source file)
В рантайме всё работает: sum умеет итерироваться по чему угодно. А тайпчекер режет — параметр объявлен как list[int]. Хочется общее имя для «что-то, по чему можно проитерироваться». Оно уже есть в stdlib.
Иерархия collections.abc#
Каждый прямоугольник = один абстрактный класс. Внутри — его контракт: dunder-методы, которые наследник пишет сам, и то, что ABC даёт бесплатно (keys, get, index, count). Стрелки — наследование. А внизу связь другая: встроенные типы ABC не наследуют — MutableMapping в dict.__mro__ не найти, — но issubclass(dict, MutableMapping) всё равно True. Класс можно объявить подклассом ABC задним числом, не трогая его код: так сделали со встроенными типами, так же можно подтянуть и чужой класс из библиотеки, которая про ваш ABC не знает. Работает это через отдельный механизм, раскроем его чуть позже в курсе.
Термин: MRO (method resolution order)
Порядок, в котором Python ищет атрибут по цепочке базовых классов. Лежит в Cls.__mro__ — кортеж классов от самого класса до object:
dict.__mro__->(dict, object)bool.__mro__->(bool, int, object)
Это и есть «настоящее» наследование. Всё, чего в __mro__ нет, наследованием не является — даже если issubclass говорит True.
Самые простые ABC — по одному методу:
Sized—__len__Iterable[T]—__iter__Container[T]—__contains__Reversible[T]—__reversed__(наследникIterable, поэтому ещё и__iter__)
Collection[T] = Sized + Iterable + Container. Mapping/Set/Sequence — три контракта на чтение, Mutable-версии добавляют запись.
Наследоваться необязательно
Свой класс с __contains__ уже считается Container, наследовать не нужно — Container, Sized, Iterable, Reversible, Collection, Hashable, Callable устроены так, что isinstance смотрит на наличие метода. А Mapping, Sequence, Set — нет, одних методов им мало, нужно наследование.
from collections.abc import Container
class Range:
def __init__(self, lo: int, hi: int) -> None:
self.lo, self.hi = lo, hi
def __contains__(self, x: object) -> bool:
return isinstance(x, int) and self.lo <= x <= self.hi
r = Range(1, 10)
print(5 in r) # True — работает без наследования
print(isinstance(r, Container)) # True — метод есть, значит Container
True
True
Наследовать явно (class Range(Container): ...) стоит когда нужны бесплатные mixin-методы — например Sequence наследнику даёт __iter__, __reversed__, index, count «в подарок» из __getitem__ + __len__. Для Container бонусов нет — можно не наследовать.
Напоминание: как выглядят dunder-методы
Ниже — минимальные реализации, чтобы освежить память после базового курса.
class MyBag:
def __init__(self, items):
self._items = list(items)
# для Sized — len(x)
def __len__(self) -> int:
return len(self._items)
# для Iterable — for x in obj / list(obj) / sum(obj)
def __iter__(self):
yield from self._items
# для Container — x in obj
def __contains__(self, x: object) -> bool:
return x in self._items
# для Reversible — reversed(obj)
def __reversed__(self):
yield from reversed(self._items)
# для Sequence — obj[i], obj[a:b]
def __getitem__(self, i):
return self._items[i]
# для MutableSequence — obj[i] = v
def __setitem__(self, i, v) -> None:
self._items[i] = v
# для MutableSequence — del obj[i]
def __delitem__(self, i) -> None:
del self._items[i]
# для сравнения — obj == other
def __eq__(self, other: object) -> bool:
return isinstance(other, MyBag) and self._items == other._items
# для использования в set / как ключа dict — hash(obj)
def __hash__(self) -> int:
return hash(tuple(self._items))
Всё что делает ABC — проверяет наличие этих методов и/или подкидывает бесплатные, вычисляемые из них (index, count, keys, items, values, …).
Использование#
Правило: типизируй параметр по минимуму методов, которые реально нужны. Тогда функция примет максимум разных объектов.
Iterable[int] — просто «что-то, что можно проитерировать»:
%%writefile /tmp/iterable_ex.py
from collections.abc import Iterable
def total(items: Iterable[int]) -> int:
return sum(items)
total([1, 2, 3]) # list
total((1, 2, 3)) # tuple
total(range(10)) # range
total(x for x in [1, 2]) # generator
Writing /tmp/iterable_ex.py
!uv run mypy --strict /tmp/iterable_ex.py
Success: no issues found in 1 source file
Sized — только «у него есть длина»:
%%writefile /tmp/sized_ex.py
from collections.abc import Sized
def is_empty(x: Sized) -> bool:
return len(x) == 0
is_empty([]) # list
is_empty("") # str
is_empty({1, 2}) # set
Writing /tmp/sized_ex.py
!uv run mypy --strict /tmp/sized_ex.py
Success: no issues found in 1 source file
Sequence[T] — можно индексировать по int и есть длина. Строже чем Iterable, но принимает list, tuple, str, range:
%%writefile /tmp/sequence_ex.py
from collections.abc import Sequence
def second(items: Sequence[int]) -> int:
return items[1] # для Iterable — так нельзя, нет __getitem__
second([1, 2, 3])
second((10, 20, 30))
second(range(5, 10))
Writing /tmp/sequence_ex.py
!uv run mypy --strict /tmp/sequence_ex.py
Success: no issues found in 1 source file
Mapping[K, V] — словарь только для чтения. Принимает dict, collections.OrderedDict и свои классы, унаследованные от Mapping: в отличие от Iterable/Sized, одних методов тут мало.
%%writefile /tmp/mapping_ex.py
from collections.abc import Mapping
def get_name(users: Mapping[int, str], uid: int) -> str:
return users.get(uid, "unknown")
get_name({1: "Alice", 2: "Bob"}, 1)
Writing /tmp/mapping_ex.py
!uv run mypy --strict /tmp/mapping_ex.py
Success: no issues found in 1 source file
Обратно: чем уже параметр, тем меньше вызывающих подходит. list[int] не примет tuple, Iterable[int] — примет.
Шпаргалка:
Что делаешь с параметром |
Тип |
|---|---|
Только читаешь длину |
|
Один раз итерируешь |
|
Итерируешь несколько раз |
|
Индексируешь по |
|
Индексируешь по ключу |
|
Модифицируешь |
|
Раньше это было в typing
До Python 3.9 generic-версий у list, dict, tuple не было — нельзя было написать list[int]. И у ABC тоже: collections.abc.Iterable был обычным ABC без параметров типа. Поэтому в typing держали параметризованные копии: typing.List, typing.Dict, typing.Iterable, typing.Sequence, typing.Callable — единственный способ написать Iterable[int].
С 3.9 (PEP 585) generic-синтаксис работает у встроенных типов и у ABC напрямую. Теперь:
# было (до 3.9) -> стало from typing import Iterable, Sequence -> from collections.abc import Iterable, Sequence from typing import List, Dict, Callable -> list, dict, from collections.abc import Callable
Всё что в typing с этими именами — устаревшие алиасы, оставлены для обратной совместимости. В новом коде — collections.abc. ruff с правилом UP035 подсветит и предложит автофикс.
3. Generics: T как переменная типа#
Одна функция работает для list[int] и для list[str]. Как это записать в типах?
def first(items):
return items[0]
print(first([1, 2, 3])) # int
print(first(["a", "b"])) # str
1
a
%%writefile /tmp/generic_basic.py
def first[T](items: list[T]) -> T:
return items[0]
x: int = first([1, 2, 3]) # T = int
y: str = first(["a", "b"]) # T = str
Writing /tmp/generic_basic.py
!uv run mypy --strict /tmp/generic_basic.py
Success: no issues found in 1 source file
Синтаксис [T] — PEP 695 (3.12+). А раньше:
%%writefile /tmp/generic_old.py
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
Writing /tmp/generic_old.py
!uv run mypy --strict /tmp/generic_old.py
Success: no issues found in 1 source file
Алиасы типов (type aliases) — тот же PEP 695:
%%writefile /tmp/type_alias.py
type UserId = int
type UserMap = dict[UserId, str]
def load(users: UserMap) -> UserId | None:
for uid in users:
return uid
return None
Writing /tmp/type_alias.py
!uv run mypy --strict /tmp/type_alias.py
Success: no issues found in 1 source file
type X = Y — ленивый: правая часть вычисляется не сразу, а при первом обращении к значению алиаса (X.__value__). Значит X может ссылаться на имя, определённое ниже по файлу (это называется forward reference) — или на самого себя, то есть быть рекурсивным:
type JSON = dict[str, JSON] | list[JSON] | str | int | float | bool | None
sample: JSON = {"a": [1, 2, {"b": None}]}
print(sample)
{'a': [1, 2, {'b': None}]}
А как было до PEP 695#
Попытка 1: обычное присваивание — правая часть вычисляется сразу:
Nodes = list[TreeNode] # TreeNode ещё не определён
class TreeNode:
children: Nodes
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Cell In[21], line 1
----> 1 Nodes = list[TreeNode] # TreeNode ещё не определён
2
3
4 class TreeNode:
NameError: name 'TreeNode' is not defined
NameError — правая часть list[TreeNode] пытается зарезолвить TreeNode немедленно, а класса ещё нет.
Попытка 2: строка внутри правой части — тайпчекер видит «TreeNode» как forward-ref, рантайм — как обычную строку, не пытается её резолвить:
%%writefile /tmp/alias_string.py
Nodes = list["TreeNode"] # "TreeNode" в кавычках — forward-ref строкой
class TreeNode:
children: Nodes
Writing /tmp/alias_string.py
!uv run mypy --strict /tmp/alias_string.py
Success: no issues found in 1 source file
В двусмысленных случаях алиас помечали явно через TypeAlias (PEP 613) — тогда mypy не путал его с обычной переменной:
%%writefile /tmp/alias_typealias.py
from typing import TypeAlias
Nodes: TypeAlias = list["TreeNode"] # явно: это алиас, а не runtime-значение
class TreeNode:
children: Nodes
Writing /tmp/alias_typealias.py
!uv run mypy --strict /tmp/alias_typealias.py
Success: no issues found in 1 source file
В сумме, у второй попытки:
forward-ref только через строку — легко опечататься, тайпчекер найдёт (если запустишь), рантайм узнает поздно;
рекурсивные типы (
JSON = dict[str, "JSON"] | ...) без гимнастики со строками не выразить;TypeAlias— ещё одна конструкция и ещё один импорт, которые надо помнить.
PEP 695 решает всё сразу — ленивость на уровне языка, без строк, без импорта:
type Nodes = list[TreeNode] # ok, TreeNode может быть ниже
type JSON = dict[str, JSON] | ... # ok, рекурсия работает
4. Protocol и утиная типизация статически#
Как выразить «любой объект с методом close()»? Наследование от общей базы не работает — объект может быть из чужой библиотеки.
Утиная и гусиная типизация — напоминание#
У Даниэля разбирали два подхода:
Утиная типизация — «ходит как утка, крякает как утка -> утка». Смотрим только на наличие методов, никаких
isinstanceи наследований. Проверка в runtime — упадёт если метод не нашёлся.Гусиная типизация — класс сам заявляет, что реализует интерфейс: наследуется от
abc.ABC, аisinstanceпроверяет эту пометку.
typing.Protocol — это утиная типизация статически: mypy смотрит на форму (какие методы есть), но проверяет до запуска.
Академические названия
В доках mypy, PEP и учебниках то же самое зовут nominal typing (гусиная, по имени/наследованию) и structural typing (утиная, по структуре). Дальше в лекции — «гусиная»/»утиная», но в документации вы встретите именно эту пару.
ABC vs Protocol#
%%writefile /tmp/abc_example.py
from abc import ABC, abstractmethod
class Closeable(ABC):
@abstractmethod
def close(self) -> None: ...
class File(Closeable): # обязательно наследуем
def close(self) -> None: ...
class Socket: # НЕ наследуем
def close(self) -> None: ...
def cleanup(x: Closeable) -> None:
x.close()
cleanup(File()) # ok
cleanup(Socket()) # error — Socket не наследуется от Closeable
Writing /tmp/abc_example.py
!uv run mypy --strict /tmp/abc_example.py || true
/tmp/abc_example.py:22: error: Argument 1 to "cleanup" has incompatible type "Socket"; expected "Closeable" [arg-type]
Found 1 error in 1 file (checked 1 source file)
%%writefile /tmp/protocol_example.py
from typing import Protocol
class Closeable(Protocol):
def close(self) -> None: ...
class File:
def close(self) -> None: ...
class Socket:
def close(self) -> None: ...
def cleanup(x: Closeable) -> None:
x.close()
cleanup(File()) # ok
cleanup(Socket()) # ok — есть .close(), этого достаточно
Writing /tmp/protocol_example.py
!uv run mypy --strict /tmp/protocol_example.py
Success: no issues found in 1 source file
Protocol для чужого, ABC для своего.
runtime_checkable#
По умолчанию Protocol — только static. Для isinstance нужен декоратор:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class File:
def close(self) -> None: ...
print(isinstance(File(), Closeable)) # True
print(isinstance("hello", Closeable)) # False
True
False
Проверяет только наличие методов, не сигнатуры. Медленно для больших Protocol’ов.
Liskov substitution: как ломается наследование#
LSP — это L из SOLID, набора из пяти принципов ООП-дизайна (Роберт Мартин, 2000-е). В контексте типов интересен именно L — остальные под катом.
SOLID полностью
S — Single Responsibility Principle. Класс должен иметь одну причину для изменения. Один класс = одна ответственность.
O — Open/Closed Principle. Код открыт для расширения (новые фичи через новые классы/функции), закрыт для модификации (не переписываем существующее).
L — Liskov Substitution Principle. Подкласс должен быть подставим вместо родителя без нарушения работы клиента. Разбираем ниже.
I — Interface Segregation Principle. Много маленьких протоколов лучше одного большого — не заставляем клиента зависеть от методов, которые он не использует. У нас в §4 это «Protocol как минимальный контракт».
D — Dependency Inversion Principle. Высокоуровневые модули не зависят от низкоуровневых напрямую — оба зависят от абстракции (Protocol / ABC). В Python — тайпнули параметр как
Protocol, и подставится любая реализация.
LSP (Барбара Лисков, 1987): подкласс должен быть взаимозаменяем (substitutable) с родителем — везде, где ожидается объект родителя, должен подойти объект подкласса без сюрпризов для клиента.
Клиент работает через Base, но реальный объект может быть Subclass. Значит контракт Base должен выполняться у любого потомка. Три типовых нарушения:
1. Возвращаемое значение: подкласс возвращает шире -> плохо.
%%writefile /tmp/lsp_return.py
class Animal: ...
class Dog(Animal): ...
class Factory:
def create(self) -> Dog:
return Dog()
class BadFactory(Factory):
def create(self) -> Animal: # Animal шире Dog — клиент ждёт Dog
return Animal()
Writing /tmp/lsp_return.py
!uv run mypy --strict /tmp/lsp_return.py || true
/tmp/lsp_return.py:11: error: Return type "Animal" of "create" incompatible with return type "Dog" in supertype "Factory" [override]
Found 1 error in 1 file (checked 1 source file)
2. Аргумент: подкласс принимает уже -> плохо.
%%writefile /tmp/lsp_args.py
class Animal: ...
class Dog(Animal): ...
class Handler:
def handle(self, x: Animal) -> None: ...
class BadHandler(Handler):
def handle(self, x: Dog) -> None: ... # Dog уже Animal
Writing /tmp/lsp_args.py
!uv run mypy --strict /tmp/lsp_args.py || true
/tmp/lsp_args.py:10: error: Argument 1 of "handle" is incompatible with supertype "Handler"; supertype defines the argument type as "Animal" [override]
/tmp/lsp_args.py:10: note: This violates the Liskov substitution principle
/tmp/lsp_args.py:10: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides
Found 1 error in 1 file (checked 1 source file)
3. Добавили обязательный аргумент -> плохо.
%%writefile /tmp/lsp_arity.py
class Base:
def process(self, item: int) -> None: ...
class Bad(Base):
def process(self, item: int, priority: int) -> None: ...
Writing /tmp/lsp_arity.py
!uv run mypy --strict /tmp/lsp_arity.py || true
/tmp/lsp_arity.py:6: error: Signature of "process" incompatible with supertype "Base" [override]
/tmp/lsp_arity.py:6: note: Superclass:
/tmp/lsp_arity.py:6: note: def process(self, item: int) -> None
/tmp/lsp_arity.py:6: note: Subclass:
/tmp/lsp_arity.py:6: note: def process(self, item: int, priority: int) -> None
Found 1 error in 1 file (checked 1 source file)
Общее правило: возвращать можно уже, принимать — шире. Дальше увидим ту же логику в generic’ах.
5. Variance: list[Dog] vs list[Animal]#
Знаем: Dog подкласс Animal. А list[Dog] — это list[Animal]?
Ответ зависит от того, что тип делает с T.
Кратко:
Только producer (выдаёт
Tнаружу) — covariant, движется в ту же сторону чтоDog <: Animal.Только consumer (принимает
Tвнутрь) — contravariant, в обратную.И то и то — invariant, ни туда ни сюда.
list — invariant#
%%writefile /tmp/variance_list.py
class Animal: ...
class Dog(Animal):
def bark(self) -> str: return "woof"
class Cat(Animal): ...
def add_cat(animals: list[Animal]) -> None:
animals.append(Cat()) # если бы list был covariant — сюда пролез бы Cat
def first_bark(dogs: list[Dog]) -> str:
return dogs[0].bark() # ждём Dog — вызываем .bark()
dogs: list[Dog] = [Dog()]
add_cat(dogs) # если бы list[Dog] <: list[Animal] — прошло бы
first_bark(dogs) # но тогда в runtime внутри мог оказаться Cat, и .bark() упадёт
animals: list[Animal] = [Cat(), Cat()]
first_bark(animals) # обратно тоже нельзя: если бы list[Animal] <: list[Dog] —
# прошло бы, а внутри .bark() у Cat нет
Writing /tmp/variance_list.py
!uv run mypy --strict /tmp/variance_list.py || true
/tmp/variance_list.py:16: error: Argument 1 to "add_cat" has incompatible type "list[Dog]"; expected "list[Animal]" [arg-type]
/tmp/variance_list.py:16: note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance
/tmp/variance_list.py:16: note: Consider using "Sequence" instead, which is covariant
/tmp/variance_list.py:21: error: Argument 1 to "first_bark" has incompatible type "list[Animal]"; expected "list[Dog]" [arg-type]
Found 2 errors in 1 file (checked 1 source file)
Mypy правильно ругается — list invariant.
Sequence — covariant#
Формально: Sequence[Dog] <: Sequence[Animal]. Стрелка сохраняется относительно Dog <: Animal — потому и «covariance».
Почему: Sequence[Dog] умеет выдавать только Dog, а Dog — это и есть Animal. Функция, ждущая Sequence[Animal], будет только читать элементы и обращаться с ними как с Animal — Dog подходит.
%%writefile /tmp/variance_sequence.py
from collections.abc import Sequence
class Animal:
def speak(self) -> str: return "..."
class Dog(Animal): ...
def describe_first(animals: Sequence[Animal]) -> str:
return animals[0].speak() # индексируем и читаем — Animal-контракта хватает
dogs: list[Dog] = [Dog(), Dog()]
describe_first(dogs) # ok — Sequence только читает, Dog <: Animal
Writing /tmp/variance_sequence.py
!uv run mypy --strict /tmp/variance_sequence.py
Success: no issues found in 1 source file
Pushable — contravariant#
Формально: Pushable[Animal] <: Pushable[Dog]. Стрелка разворачивается относительно Dog <: Animal — потому и «contravariance».
Почему: Pushable[Animal] умеет глотать любое Animal (в том числе Dog). Если функция ждёт Pushable[Dog] — то есть будет пихать туда только Dog, — Pushable[Animal] подходит с запасом.
%%writefile /tmp/variance_consumer.py
from typing import Protocol, TypeVar
T_contra = TypeVar("T_contra", contravariant=True)
class Animal: ...
class Dog(Animal): ...
class Pushable(Protocol[T_contra]):
def push(self, item: T_contra) -> None: ...
class AnimalLog:
def push(self, item: Animal) -> None: ...
class DogLog:
def push(self, item: Dog) -> None: ...
def dump_dogs(sink: Pushable[Dog]) -> None:
sink.push(Dog())
# AnimalLog глотает любое Animal — Dog для него тем более ok
dump_dogs(AnimalLog()) # ok — contravariance
dump_dogs(DogLog()) # ok — точное совпадение
Writing /tmp/variance_consumer.py
!uv run mypy --strict /tmp/variance_consumer.py
Success: no issues found in 1 source file
Здесь мы явно объявили TypeVar contravariant через contravariant=True и суффикс _contra в имени — самодокументирующий контракт «этот протокол только принимает T внутрь». PEP 695 (class Pushable[T](Protocol)) тоже работает — mypy выведет contravariance сам из того что T только в аргументе метода. Но вывод типов молчит: контракт виден только из тела, из объявления — нет. В API-коде явное лучше.
Callable — обе стороны#
Callable[[Arg], Ret] — generic по двум позициям с разной variance:
Возвращаемый тип — covariant (producer).
Аргумент — contravariant (consumer).
%%writefile /tmp/callable_variance.py
from collections.abc import Callable
class Animal: ...
class Dog(Animal): ...
def take_animal(a: Animal) -> None: ...
def take_dog(d: Dog) -> None: ...
# Функция, принимающая шире (Animal), — годится там где ждут функцию для Dog.
handler_ok: Callable[[Dog], None] = take_animal # ok
# Обратное — нельзя.
handler_bad: Callable[[Animal], None] = take_dog # error
Writing /tmp/callable_variance.py
!uv run mypy --strict /tmp/callable_variance.py || true
/tmp/callable_variance.py:16: error: Incompatible types in assignment (expression has type "Callable[[Dog], None]", variable has type "Callable[[Animal], None]") [assignment]
Found 1 error in 1 file (checked 1 source file)
Это ровно та же LSP-логика из §4, только на generic’ах.
type[T] — сам класс, не экземпляр#
%%writefile /tmp/type_t.py
class Animal: ...
class Dog(Animal): ...
def make(cls: type[Animal]) -> Animal:
return cls()
make(Dog) # ok — Dog это type[Animal]
make(int) # error — int не подкласс Animal
Writing /tmp/type_t.py
!uv run mypy --strict /tmp/type_t.py || true
/tmp/type_t.py:10: error: Argument 1 to "make" has incompatible type "type[int]"; expected "type[Animal]" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Живой пример — фабрики / реестры: функция принимает cls: type[Base] и возвращает cls().
Bounded TypeVar#
Хочется generic только для сравнимых типов. Наивная попытка — обычный класс Comparable как bound:
%%writefile /tmp/bounded_nominal.py
class Comparable:
def __lt__(self, other: Comparable, /) -> bool:
raise NotImplementedError
def sort_pair[T: Comparable](a: T, b: T) -> tuple[T, T]:
return (a, b) if a < b else (b, a)
sort_pair(1, 2) # int не наследуется от Comparable
sort_pair("a", "b") # str не наследуется от Comparable
Writing /tmp/bounded_nominal.py
!uv run mypy --strict /tmp/bounded_nominal.py || true
/tmp/bounded_nominal.py:10: error: Value of type variable "T" of "sort_pair" cannot be "int" [type-var]
/tmp/bounded_nominal.py:11: error: Value of type variable "T" of "sort_pair" cannot be "str" [type-var]
Found 2 errors in 1 file (checked 1 source file)
Mypy режет: int и str формально не подклассы нашего Comparable, хотя __lt__ у них есть. Это гусиная типизация — проверка по имени/наследованию. Для встроенных типов не работает.
Решение — сделать Comparable Protocol. Тогда mypy проверит по форме (наличию __lt__), а не по наследованию:
%%writefile /tmp/bounded_protocol.py
from typing import Protocol, Self
class Comparable(Protocol):
def __lt__(self, other: Self, /) -> bool: ...
def sort_pair[T: Comparable](a: T, b: T) -> tuple[T, T]:
return (a, b) if a < b else (b, a)
sort_pair(1, 2) # у int есть __lt__ — подходит
sort_pair("a", "b") # у str есть __lt__ — подходит
Writing /tmp/bounded_protocol.py
!uv run mypy --strict /tmp/bounded_protocol.py
Success: no issues found in 1 source file
Правило: bounded TypeVar, который планируешь применять к встроенным / чужим типам, — почти всегда Protocol.
6. Forward-references (кратко)#
Ссылка на класс, который ещё не определён:
class Node:
def __init__(self, value: int, next: Node | None = None) -> None:
self.value = value
self.next = next
print(Node.__init__.__annotations__)
{'value': <class 'int'>, 'next': __main__.Node | None, 'return': None}
На 3.14 это работает: PEP 649 — аннотации ленивы, резолвятся при первом обращении к __annotations__. На 3.13 и раньше — падает NameError, надо было либо кавычить ("Node | None"), либо ставить from __future__ import annotations (PEP 563).
Практический вывод: в аннотациях на 3.14 — забудь про кавычки и from __future__ import annotations, всё работает.
7. Overload#
Возврат зависит от значения аргумента, не типа. Типичное — open("f", "r") -> TextIO, open("f", "rb") -> BinaryIO. Union плох: клиент получит TextIO | BinaryIO и вынужден делать isinstance.
Ниже используется Literal["r"] — тип, разрешающий ровно одно конкретное значение (строку "r", никакую другую). Разберём его подробно в следующем разделе, здесь достаточно понимать, что «передал не эту строку — тайпчекер отругал».
%%writefile /tmp/overload_open.py
from typing import Literal, TextIO, BinaryIO, overload
@overload
def my_open(path: str, mode: Literal["r"]) -> TextIO: ...
@overload
def my_open(path: str, mode: Literal["rb"]) -> BinaryIO: ...
def my_open(path: str, mode: str) -> TextIO | BinaryIO: # реализация
if mode == "r":
return open(path)
return open(path, "rb")
text = my_open("a.txt", "r") # mypy знает: TextIO
binary = my_open("a.bin", "rb") # mypy знает: BinaryIO
Writing /tmp/overload_open.py
!uv run mypy --strict /tmp/overload_open.py
Success: no issues found in 1 source file
Заглушки
@overload— только..., без тела.Реализация — последняя, без
@overload.Порядок важен: mypy берёт первый матч.
8. Ещё из typing#
TypedDict — типы для dict’ов#
JSON пришёл в dict, ключи известны. Заменяем dict[str, Any].
%%writefile /tmp/td_typed.py
from typing import TypedDict
class User(TypedDict):
id: int
name: str
email: str
def get_name(user: User) -> str:
name: str = user["nam"] # error: нет ключа "nam"
return name
Writing /tmp/td_typed.py
!uv run mypy --strict /tmp/td_typed.py || true
/tmp/td_typed.py:11: error: TypedDict "User" has no key "nam" [typeddict-item]
/tmp/td_typed.py:11: note: Did you mean "name"?
Found 1 error in 1 file (checked 1 source file)
Опциональные поля — PEP 655:
%%writefile /tmp/td_required.py
from typing import TypedDict, NotRequired
class User(TypedDict):
id: int
name: str
email: NotRequired[str] # может отсутствовать
ok: User = {"id": 1, "name": "Alice"} # email класть необязательно
bad: User = {"id": 2, "email": "a@b.c"} # error — обязательный "name" пропущен
def send(u: User) -> None:
print(u["email"]) # mypy молчит, а в runtime это KeyError
def send_ok(u: User) -> None:
print(u.get("email", "-")) # безопасно
Writing /tmp/td_required.py
!uv run mypy --strict /tmp/td_required.py || true
/tmp/td_required.py:11: error: Missing key "name" for TypedDict "User" [typeddict-item]
Found 1 error in 1 file (checked 1 source file)
Ругается только на конструирование: пропущенный обязательный ключ — ошибка, отсутствующий NotRequired — нет. А вот чтение u["email"] mypy не проверяет вообще: наличие опциональных ключей он не отслеживает. Поэтому .get() с дефолтом.
Термин: сужение типа (type narrowing)
Mypy сужает тип после проверки: isinstance(x, int), if x is not None, match. Внутри ветки тип становится уже.
%%writefile /tmp/narrowing.py
from typing import TypedDict, NotRequired, reveal_type
class User(TypedDict):
id: int
email: NotRequired[str]
def f(x: int | None) -> None:
if x is not None:
reveal_type(x) # сузилось
def g(u: User) -> None:
if "email" in u:
reveal_type(u) # не сузилось
Writing /tmp/narrowing.py
!uv run mypy --strict /tmp/narrowing.py
/tmp/narrowing.py:11: note: Revealed type is "int"
/tmp/narrowing.py:16: note: Revealed type is "TypedDict(narrowing.User, {'id': int, 'email'?: str})"
Success: no issues found in 1 source file
int | None стало int, а TypedDict остался с тем же email? — проверка in у него ничего не сужает.
NotRequired[str] vs str | None
email: NotRequired[str]— ключа может не быть в dict.email: str | None— ключ есть, значение может бытьNone.
TypedDict — чисто информация для тайпчекера. В рантайме это обычный dict, никаких проверок (что id действительно int, что name действительно str).
Literal — конкретные значения как типы#
PEP 586. Не «строка», а строго "r" | "rb".
%%writefile /tmp/literal_basic.py
from typing import Literal
Mode = Literal["r", "rb", "w", "wb"]
def do_open(path: str, mode: Mode) -> None: ...
do_open("f.txt", "r") # ok
do_open("f.txt", "rw") # error: "rw" не в Literal
Writing /tmp/literal_basic.py
!uv run mypy --strict /tmp/literal_basic.py || true
/tmp/literal_basic.py:10: error: Argument 2 to "do_open" has incompatible type "Literal['rw']"; expected "Literal['r', 'rb', 'w', 'wb']" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Literal vs Enum
Как только вариантов больше 2-3 или хочется автодополнения / рефакторинга — лучше enum.Enum:
from enum import Enum
class Mode(Enum):
READ = "r"
READ_BINARY = "rb"
WRITE = "w"
WRITE_BINARY = "wb"
def do_open(path: str, mode: Mode) -> None: ...
do_open("f.txt", Mode.READ)
Плюсы Enum: IDE подставит варианты по Mode., переименование значения — одно место, легко добавить методы (is_binary()), в рантайме — настоящий объект (можно передать между модулями, сравнить через is).
Literal остаётся хорош для флагов-паттернов, приходящих извне как есть (JSON, CLI, open(mode=»r») в stdlib), где enum-обёртка была бы лишней.
Annotated — метаданные к типу#
PEP 593. Прицепить к типу произвольный объект. Для mypy — исходный тип, для рантайм-фреймворков — источник конфигурации.
from typing import Annotated, get_type_hints
class Positive:
def __init__(self, min_value: int = 0) -> None:
self.min_value = min_value
Age = Annotated[int, Positive(min_value=0)]
def greet(age: Age) -> str:
return f"you are {age}"
# для mypy Age это int
greet(30)
# метаданные — через get_type_hints с include_extras=True
hints = get_type_hints(greet, include_extras=True)
print(hints["age"].__metadata__)
(<__main__.Positive object at 0x10a7e3e00>,)
Так работают:
pydantic v2 —
Annotated[int, Field(gt=0)]FastAPI —
Annotated[Session, Depends(get_db)]msgspec —
Annotated[int, msgspec.Meta(ge=0)]typer —
Annotated[int, typer.Option("--port")]
Self — цепочки методов через .#
%%writefile /tmp/self_builder.py
from typing import Self
class Query:
def __init__(self) -> None:
self._where: list[str] = []
def where(self, cond: str) -> Self:
self._where.append(cond)
return self
class UserQuery(Query):
def active(self) -> Self:
return self.where("active = true")
# .where() у UserQuery возвращает UserQuery, не Query — цепочка не теряет тип
q: UserQuery = UserQuery().where("age > 18").active()
Writing /tmp/self_builder.py
!uv run mypy --strict /tmp/self_builder.py
Success: no issues found in 1 source file
Без Self пришлось бы писать TypeVar("T", bound="Query") — с кавычками, потому что TypeVar объявляется до класса и вычисляет bound сразу, а имени Query в этот момент ещё нет.
Never / assert_never — exhaustive match#
PEP 484 даёт NoReturn. Never и assert_never добавили в 3.11 — для проверки полноты match/case.
%%writefile /tmp/never_match.py
from typing import Literal, assert_never
Colour = Literal["red", "green", "blue"]
def rgb(c: Colour) -> tuple[int, int, int]:
match c:
case "red":
return (255, 0, 0)
case "green":
return (0, 255, 0)
# забыли "blue"
case _:
assert_never(c) # error: "blue" не покрыт
Writing /tmp/never_match.py
!uv run mypy --strict /tmp/never_match.py || true
/tmp/never_match.py:14: error: Argument 1 to "assert_never" has incompatible type "Literal['blue']"; expected "Never" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Пропустил кейс — mypy покажет какой именно.
Final — константы#
PEP 591. Запрет переприсваивания.
%%writefile /tmp/final_const.py
from typing import Final
MAX_RETRIES: Final = 3
def retry() -> None:
global MAX_RETRIES
MAX_RETRIES = 5 # mypy: error, runtime: спокойно переприсвоится
retry()
print(MAX_RETRIES)
Writing /tmp/final_const.py
!uv run mypy --strict /tmp/final_const.py || true
/tmp/final_const.py:8: error: Cannot assign to final name "MAX_RETRIES" [misc]
Found 1 error in 1 file (checked 1 source file)
А в рантайме всё работает — Python про Final ничего не знает:
!uv run python /tmp/final_const.py
5
@final на классе — запрет наследования, на методе — запрет переопределения:
%%writefile /tmp/final_class.py
from typing import final
class Base:
@final
def critical(self) -> None: ...
class Sub(Base):
def critical(self) -> None: ... # error: cannot override final method
Writing /tmp/final_class.py
!uv run mypy --strict /tmp/final_class.py || true
/tmp/final_class.py:10: error: Cannot override final attribute "critical" (previously declared in base class "Base") [misc]
Found 1 error in 1 file (checked 1 source file)
Final vs UPPER_CASE
В Python нет настоящих констант — MAX_RETRIES = 3 можно переприсвоить. UPPER_CASE — только конвенция. Final — реальная проверка mypy. Для настоящей неизменяемости в runtime — frozen dataclass / tuple.
ParamSpec — типизируем декораторы#
Как типизировать @logged, который сохраняет сигнатуру
декорируемой функции?
%%writefile /tmp/paramspec_demo.py
from collections.abc import Callable
from functools import wraps
from typing import TYPE_CHECKING
def logged[**P, R](f: Callable[P, R]) -> Callable[P, R]:
@wraps(f)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {f.__name__}({args}, {kwargs})")
return f(*args, **kwargs)
return wrapper
@logged
def greet(name: str, age: int = 30) -> str:
return f"hello {name}, {age}"
print(greet("Ivan", age=25))
if TYPE_CHECKING:
reveal_type(greet)
# greet(123) # mypy сообщит, что ожидается str
Writing /tmp/paramspec_demo.py
!mypy --strict /tmp/paramspec_demo.py
!python /tmp/paramspec_demo.py
/tmp/paramspec_demo.py:19: note: Revealed type is "def (name: str, age: int =) -> str"
Success: no issues found in 1 source file
calling greet(('Ivan',), {'age': 25})
hello Ivan, 25
TypeVarTuple — набор типов произвольной длины#
TypeVarTuple описывает набор типов произвольной длины. Можно выделить первый
аргумент функции и сохранить типы всех остальных:
%%writefile /tmp/typevartuple_demo.py
from typing import TYPE_CHECKING
def first_and_rest[T, *Ts](first: T, *rest: *Ts) -> tuple[T, tuple[*Ts]]:
return first, rest
result = first_and_rest(1, "hello", 3.14)
print(result)
if TYPE_CHECKING:
reveal_type(result)
Writing /tmp/typevartuple_demo.py
!mypy --strict /tmp/typevartuple_demo.py
!python /tmp/typevartuple_demo.py
/tmp/typevartuple_demo.py:10: note: Revealed type is "tuple[int, tuple[str, float]]"
Success: no issues found in 1 source file
(1, ('hello', 3.14))
T — тип первого аргумента, а Ts — типы оставшихся, здесь str и float.
Первый аргумент обязателен; оставшихся может быть сколько угодно, в том числе ноль.
9. Gradual typing#
Python — динамический язык. Типы — опциональный слой: включаем где помогает, отключаем где мешает.
Any vs object#
Any отключает проверки. Один Any заражает всё вокруг:
%%writefile /tmp/any_leak.py
from typing import Any
def parse(data: Any) -> Any:
return data["result"]
x = parse({"result": 42})
y = x + "wrong" # runtime TypeError; mypy думал x: Any -> всё ок
Writing /tmp/any_leak.py
!uv run mypy --strict /tmp/any_leak.py
Success: no issues found in 1 source file
Правило: Any — только на границе с нетипизированным кодом. Внутри своей логики — никогда.
Плохо: def process(data: Any) -> Any: …
Хорошо: def process(data: str) -> dict[str, Any]: … — Any ограничен значениями dict’а, границей внешнего JSON-контракта.
Строгий режим по одному модулю#
Реальный проект 50k строк без типов. Включить strict глобально — нереально: сотни ошибок сразу. Включаем по одному модулю: строгий mypy для новых модулей, ignore_errors для старых. Постепенно двигаем модули из «игнора» в «строго».
[tool.mypy]
python_version = "3.14"
strict = true
[[tool.mypy.overrides]]
module = ["myapp.legacy.*"]
ignore_errors = true
[[tool.mypy.overrides]]
module = ["some_untyped_lib.*"]
ignore_missing_imports = true
# type: ignore[код] — с явным кодом#
result = untyped_lib.get() # type: ignore — плохо (закроет любую будущую ошибку)
result = untyped_lib.get() # type: ignore[no-untyped-call] — хорошо
Guido van Rossum, Python Language Summit 2016
»Type hints are optional and will always remain that way.»
Про то когда включать: »The larger a codebase is, the more benefit it will get from adding annotations and actually checking them with a tool.» — практический совет »sooner than you would like to».
Источник: LWN — The state of mypy.