Аннотации не проверяются никем и ничем во время работы — это просто данные в атрибуте. Отсюда и отдельный слой валидации вроде pydantic, и пляска с TYPE_CHECKING, и принципиальная невозможность надёжного статического вывода.
Зафиксируй вывод и причину до того, как откроешь ответ.
def g(x: int = 0):
return x
print(g("строка"))строкаАннотация есть, значение не то, ошибки нет.def f(x: int, y: "SomeUndefined") -> bool: ...
print(f.__annotations__){'x': <class 'int'>, 'y': 'SomeUndefined',
'return': <class 'bool'>}Несуществующее имя в аннотации не мешает функции существовать.from dataclasses import dataclass
@dataclass
class D:
a: int
d = D("не число")
print(repr(d.a))'не число'Датакласс сгенерировал __init__ по аннотациям и ничего не проверил.class C:
x: int
print(C.__annotations__)
print(hasattr(C, "x")){'x': <class 'int'>}
FalseАннотация записана, атрибута нет.Аннотация — выражение, которое вычисляется и складывается в словарь. Для функции это __annotations__, для класса и модуля — одноимённый атрибут.
Интерпретатор ими не пользуется. Никакой проверки при вызове, при присваивании, нигде.
Проверку делает отдельный инструмент до запуска, читая тот же исходник. Валидацию данных во время работы — отдельная библиотека, читающая те же аннотации как спецификацию.
Фрагменты A и C. Функция принимает что дали; датакласс кладёт в поле что дали. Аннотация — метаданные, лежащие рядом, и единственный, кто их читает, — тот, кого ты явно позвал.
Отсюда практическая формулировка, которую стоит держать в голове дословно: аннотации — это документация, машиночитаемая и потому проверяемая внешним инструментом, но всё-таки документация. Она не защищает от плохих данных на границе программы.
Фрагмент D. Строка x: int в теле класса — это только аннотация: имя записывается в __annotations__, но никакого присваивания не происходит, и атрибута нет. Строка x: int = 0 делает и то, и другое.
Отсюда очень частая ошибка: объявили поле аннотацией, обратились к нему до присваивания в __init__ — получили AttributeError. И отсюда же понятно, как работает датакласс: он читает __annotations__, видит перечень полей и генерирует __init__, которого без него не было бы.
Фрагмент B. Строка в аннотации не вычисляется как имя — она просто хранится. Отсюда две вещи, которые иначе выглядят произволом.
Forward reference: тип, объявленный ниже по файлу, указывается строкой — def f() -> "Node". Инструмент разберёт её позже, когда всё определено.
TYPE_CHECKING: константа, равная False в рантайме и True для анализатора. Импорт под ней не выполняется при запуске, и это единственный способ импортировать тип, нужный только для аннотации, не создавая настоящей зависимости — а заодно разорвать циклический импорт из урока 17.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from heavy.module import Thing # не импортируется при запуске
def f(t: "Thing") -> None: ...Раз рантайм ничего не проверяет, а данные приходят снаружи — из запроса, из файла, из очереди, — валидация должна выполняться явно. Так появился pydantic: он читает те же аннотации, но использует их как спецификацию для проверки и приведения во время работы.
Это не улучшенный датакласс, а другой слой, и разница видна в замере: там, где датакласс просто раскладывает аргументы по полям, pydantic каждый раз проверяет и приводит типы.
| 200 000 созданий объекта с тремя полями | время |
|---|---|
@dataclass | 0.032 с |
pydantic.BaseModel | 0.192 с |
Шестикратная разница — цена валидации, и её надо платить ровно на границе. Внутри системы, где данные уже проверены, датакласс уместнее.
Из урока 04: атрибут резолвится в рантайме, класс можно менять после создания, __getattr__ синтезирует любое имя. Значит анализатор обязан предполагать, что этого не произошло.
Поэтому mypy не «недоделан» — он принципиально не может быть надёжным на языке с такой моделью. Его выводы полезны и не являются доказательствами, а Any существует как узаконенный выход из системы типов. Требовать от него гарантий уровня компилятора Rust — значит требовать другого языка.
Некоторые аннотации всё же вычисляются. По умолчанию выражение в аннотации исполняется в момент определения функции, поэтому опечатка в типе даёт NameError при импорте. Строковая форма и from __future__ import annotations откладывают вычисление, превращая всё в строки.
Библиотеки на аннотациях ломаются от обёрток. Фреймворки, читающие сигнатуру, получают её через inspect.signature — а декоратор без functools.wraps её подменяет (урок 08). Отсюда неочевидная связка: забытый wraps ломает валидацию по типам.
get_type_hints не всегда справляется. Он вычисляет строковые аннотации в контексте модуля, и если тип виден только под TYPE_CHECKING, попытка их развернуть в рантайме упадёт с NameError. Это осознанный размен: не платить импортом ради проверки.
Корень R8: язык сознательно отказался от статических гарантий. Синтаксис аннотаций появился в Python 3.0 (PEP 3107) вообще без семантики — просто «место, куда можно написать что угодно». Значение ему придал PEP 484 в 2014-м, и ключевое решение там сформулировано прямо: проверка не входит в рантайм и остаётся опциональной.
Причина практическая. Миллионы строк существующего кода без аннотаций должны были продолжать работать, а типизация — внедряться постепенно, файл за файлом. Обязательная проверка сделала бы переход невозможным, а любая проверка в рантайме — дорогой: она выполнялась бы на каждом вызове.
Цена — то, что разобрано выше: нет гарантий, нужен второй слой для данных, анализатор не может быть надёжным. Выигрыш — огромные кодовые базы, которые типизировали не переписывая, и полная свобода экспериментировать с системой типов вне релизов языка.
Аннотации Python — это комментарии, которые умеет читать инструмент, и ближайший знакомый аналог из мира C — заголовочные файлы, если бы компилятор их не проверял, а по ним работал только линтер. Объявление есть, договор описан, но принуждения нет.
Более точное сравнение с C++ такое: там тип — это то, что компилятор доказал перед стиранием, и потому в рантайме проверять нечего. В Python тип — это то, что автор заявил, и потому в рантайме проверять надо всё, что пришло снаружи. Одно и то же слово обозначает разные по силе утверждения, и вся практика вокруг типизации в Python растёт из этого различия.
Отсюда простой рабочий критерий: аннотация внутри системы — доказательство, которому можно доверять ровно настолько, насколько прогоняется анализатор; аннотация на границе системы — обещание, которое надо проверить кодом.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Затем, что проверяет их не интерпретатор, а три других потребителя — и все три реальные.
Первый — человек. Сигнатура def process(data, config, retries) не говорит ничего; def process(data: list[Record], config: Config, retries: int = 3) -> Report отвечает на большинство вопросов, ради которых иначе пришлось бы читать тело.
Второй — статический анализатор. mypy находит целый класс ошибок до запуска: None, приехавший туда, где его не ждали; перепутанные аргументы одного типа; забытая ветка в match. Не все ошибки — но те, что находит, находит бесплатно и на всей кодовой базе сразу.
Третий — рантайм-библиотеки. И вот это самое недооценённое: аннотация — данные в словаре, и кто-то их читает.
from dataclasses import dataclass
@dataclass
class P:
x: int
y: int = 0
print(P.__annotations__) # → {'x': <class 'int'>, 'y': <class 'int'>}
print(P(1, 2)) # → P(x=1, y=2)
dataclass не проверяет типы — он читает __annotations__, чтобы узнать список и порядок полей, и генерирует __init__, __repr__, __eq__. На том же механизме стоят pydantic, attrs, FastAPI, SQLAlchemy 2.0, typing.NamedTuple.
То есть без аннотаций половина современного Python-стека просто не работает — не потому, что типы проверяются, а потому что они объявляют структуру.
Нельзя, и это не недоделка mypy, а следствие R2.
Надёжный (sound) анализатор обязан гарантировать: если проверка прошла, ошибки типа при исполнении не будет. Для этого нужно, чтобы тип объекта нельзя было изменить после проверки. В Python можно:
class A:
def hello(self): return "A"
def use(a: A) -> str:
return a.hello() # mypy: всё в порядке
class B:
def hello(self): return 1 # не str!
a = A()
a.__class__ = B # тип объекта поменялся на лету
print(use(a)) # → 1, а обещали str
Добавь сюда setattr по вычисляемому имени, __getattr__, метаклассы, exec, C-расширения, которые могут подменить у объекта что угодно, — и станет ясно, что доказывать нечего.
Поэтому все анализаторы Python сознательно unsound: они находят большую часть реальных ошибок и молчат про остальное. Это инженерный компромисс, а не временное состояние. Any в системе типов — не заглушка, а честное «здесь доказательства нет».
Что из этого следует практически. Типы полезны, но не заменяют тесты. Данные, пришедшие снаружи — из HTTP, из базы, из файла, — надо валидировать в рантайме: там аннотация не говорит ничего вообще. И disallow_untyped_defs на новых модулях даёт больше, чем попытка вычистить Any из старых.
На границе процесса — почти всегда. Внутри — почти никогда.
Замер из этого урока: создание объекта через pydantic примерно в шесть раз дороже, чем через dataclass. Разница — это цена валидации: проверка типов, приведение, разбор вложенных структур, сборка сообщений об ошибках.
from dataclasses import dataclass
from pydantic import BaseModel
@dataclass
class D:
x: int
class P(BaseModel):
x: int
D(x="не число") # → создаётся, поле остаётся строкой
P(x="не число") # → ValidationError
Где платить стоит. Данные пришли снаружи и не проверены: тело HTTP-запроса, ответ чужого API, конфиг, строка из очереди, содержимое файла. Здесь аннотация не даёт ничего, а валидация — единственное, что стоит между чужими данными и вашим кодом. Шестикратная разница на объекте, который создаётся один раз на запрос, не измерима на фоне сети.
Где не стоит. Внутренние структуры, созданные вашим же кодом и уже проверенные на входе: узлы дерева, промежуточные записи, точки. Здесь валидировать нечего — данные пришли не снаружи, — а объектов миллионы. dataclass(slots=True) или NamedTuple.
Правило одной фразой: валидировать надо один раз, на границе, и дальше носить проверенные типы. Валидация в середине — это недоверие к собственному коду, оплаченное шестикратно.
TYPE_CHECKING — зачем такой костыль?Он разрешает конфликт между двумя правдами: типу нужен импорт, а рантайму — нет.
Ситуация типовая: модуль a аннотирует функцию классом из модуля b, а b импортирует a. Импорт ради аннотации создаёт цикл — при том что в рантайме он не нужен вовсе, аннотация всё равно не вычисляется.
from __future__ import annotations # аннотации становятся строками
from typing import TYPE_CHECKING
if TYPE_CHECKING: # False при исполнении, True для mypy
from heavy_module import Thing
def use(t: Thing) -> None: ... # mypy видит тип, рантайм не импортирует
TYPE_CHECKING — обычная константа, равная False; анализаторы считают её True по соглашению. Никакой магии.
Два выигрыша. Разрыв цикла импортов — и время старта: тяжёлые модули, нужные только для аннотаций, не грузятся вообще. Урок 22 показывает, что import numpy стоит 88 мс; если он нужен только для типа в сигнатуре, платить незачем.
Цена. Всё, что читает аннотации в рантайме — get_type_hints, pydantic, FastAPI, — не найдёт имя и упадёт. Поэтому для моделей pydantic так делать нельзя, а для обычных функций можно.
Костыльность признана: PEP 649 (3.14) меняет модель на отложенное вычисление аннотаций по требованию, что убирает и необходимость в from __future__ import annotations, и большую часть случаев TYPE_CHECKING. Но старый способ проживёт ещё долго — R9.
__annotations__; интерпретатор ею не пользуется никогда. Отсюда: функция принимает что дали, датакласс кладёт в поле что дали, а x: int в теле класса записывает аннотацию, но не создаёт атрибут. Раз аннотация ничего не исполняет, в ней можно упомянуть то, чего ещё нет — так работают forward reference и TYPE_CHECKING, который равен False в рантайме и позволяет импортировать тип, не создавая зависимости. И раз рантайм не проверяет, для данных с границы нужен отдельный слой: pydantic читает те же аннотации как спецификацию и платит за это шестикратной разницей во времени создания объекта. А надёжного статического вывода на этом языке не будет принципиально — атрибут резолвится в рантайме, класс изменяем, __getattr__ синтезирует что угодно, поэтому выводы анализатора полезны, но не являются доказательствами.TypeScript — самый чистый естественный эксперимент: те же градуальные типы, то же стирание при компиляции, тот же результат. Данные с границы приходится валидировать отдельной библиотекой (zod там, pydantic здесь), и это не совпадение вкусов, а следствие одинакового решения. Два независимых языка, один вывод.
C++ — противоположный полюс: типы существуют только на этапе компиляции и там же полностью проверяются, а в рантайме от них не остаётся почти ничего, кроме RTTI. То есть стирание есть и здесь — но после проверки, а не вместо неё. Разница ровно в этом: C++ стирает то, что уже доказал, Python стирает то, что никто не проверял.
Rust идёт дальше: система типов доказывает не только «это целое», но и время жизни с владением, и без доказательства программа не собирается. Цена — модель, которую надо держать в голове, и невозможность постепенного внедрения.
Три точки на одной оси: доказано и стёрто, объявлено и стёрто, доказано вместе с владением.
Разбор ответа внешнего API. Типы объявлены, ошибка всплывает через три слоя.
@dataclass
class Order:
id: int
amount: float
created: datetime
def parse(payload: dict) -> Order:
return Order(**payload) # ← ничего не проверено
order = parse(response.json())
total += order.amount # amount оказался строкой "12.50"
Внешний сервис прислал число строкой — обычное дело. Датакласс это принял: он генерирует __init__ по списку полей и не смотрит на типы (следствие 1). Падение произойдёт позже, при арифметике, и трейсбек будет указывать на строку, где данные уже давно неверны.
Хуже, когда не падает: created остаётся строкой, сравнение дат начинает работать лексикографически, и ошибка становится тихой.
Ревью пропускает, потому что аннотации выглядят проверкой. Это самая коварная часть: код читается как типизированный.
class Order(BaseModel):
id: int
amount: float
created: datetime
order = Order.model_validate(response.json())
# "12.50" -> 12.5, ISO-строка -> datetime, мусор -> ValidationError здесь
Правило, выводимое из следствия 4: на границе системы — валидация, внутри — датаклассы. Граница это всё, что пришло не из твоего кода: HTTP, очередь, файл, переменные окружения, ответ БД в сыром виде. Внутри, где данные уже проверены, платить шестикратную цену за каждый объект незачем.
Типизация большой кодовой базы окупается неравномерно, и порядок внедрения важнее полноты.
Где окупается сразу: сигнатуры публичных функций модуля, модели данных, границы между подсистемами. Там аннотация заменяет чтение реализации и ловит реальные ошибки при рефакторинге.
Где почти не окупается: локальные переменные (вывод и так работает), короткие внутренние функции, тесты. Там аннотация — шум, увеличивающий диффы.
Как включать на живом проекте. Не «пройти всё подряд», а поставить проверку в CI сразу, но строгую — только для нового кода:
# pyproject.toml
[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true
# строго — только там, где уже типизировано
[[tool.mypy.overrides]]
module = ["myapp.core.*", "myapp.api.*"]
disallow_untyped_defs = true
strict_equality = true
Список модулей в overrides растёт по мере готовности — это и есть градуальность, ради которой язык отказался от обязательной проверки (см. «Корень»). Обратный путь, когда строгость включают глобально и заводят тысячу исключений, обычно заканчивается тем, что исключения не убирают никогда.
Что даёт больше всего за строчку: disallow_untyped_defs на новых модулях и запрет неявного Any на границах. Protocol из урока 11 — там, где описываешь ожидание от чужого класса: не требует наследования и не создаёт зависимость.
Прогони фрагменты, сверяясь с предсказанием. Затем — первая пачка, вставляется целиком:
def f(x: Undefined) -> None: ...
# NameError при определении? А если написать x: "Undefined"?
from typing import get_type_hints
class Node:
parent: "Node"
print(get_type_hints(Node))
# Как строка превратилась обратно в класс?
import typing
print(typing.get_type_hints(len))
# Почему у встроенной функции пусто?
Вторая — обязательно в новом файле или свежем ядре: from __future__ должен стоять первой строкой, иначе SyntaxError. Это само по себе часть ответа на вопрос, почему PEP 563 не стал умолчанием.
from __future__ import annotations
def g(x: Undefined) -> None: ...
print(g.__annotations__)
# Что изменилось и во что превратились аннотации?
Второй вопрос — практически важный: отложенное вычисление аннотаций убирает NameError и стоимость импорта, но ломает всё, что пытается прочитать типы в рантайме без get_type_hints.
Lib/dataclasses.py. Возвращаемся сюда в третий раз, и теперь с главного входа: _process_class читает __annotations__, строит список полей и генерирует __init__ текстом через exec. Заметь, что тип поля используется только как признак «это поле» — сравнения с ним нигде нет. Следствие 1 в исходнике стандартной библиотеки.
Lib/typing.py, функция get_type_hints. Здесь видно, как строковые аннотации превращаются обратно в объекты: они вычисляются через eval в пространстве имён модуля. Отсюда и ограничение из «Границ модели» — если имя доступно только под TYPE_CHECKING, вычислять нечего.
Lib/functools.py, singledispatch. Редкий случай, когда стандартная библиотека использует аннотацию как рабочую информацию: тип первого аргумента определяет, какая реализация будет вызвана. Механизм диспетчеризации по типам, построенный поверх метаданных.
Из L1 ты знаешь, что аннотация — данные. Здесь — где именно и во что обходится.
У функции это словарь __annotations__, заполняемый при создании объекта функции. У класса и модуля — одноимённый атрибут, собираемый компилятором из строк с двоеточием. Ключевая деталь: по умолчанию выражения вычисляются в момент определения.
from typing import List, Dict
def f(x: List[Dict[str, int]]) -> None: ...
print(f.__annotations__)
# → {'x': typing.List[typing.Dict[str, int]], 'return': None}
То есть при импорте модуля создаются настоящие объекты обобщённых типов. На большом проекте с тысячами сигнатур это заметная часть времени старта — и одна из причин, по которой появился from __future__ import annotations: с ним аннотации остаются строками и не вычисляются вовсе.
from __future__ import annotations
def g(x: List[Dict[str, int]]) -> None: ...
print(g.__annotations__)
# → {'x': 'List[Dict[str, int]]', 'return': 'None'}
Размен явный: быстрее импорт и никаких NameError в аннотациях — но всё, что читает типы в рантайме, обязано вызывать get_type_hints, а тот может не справиться.
Про цену валидации. Шестикратная разница из следствия 4 — это pydantic 2, у которого ядро скомпилировано на Rust. У первой версии, целиком на Python, разрыв был кратно больше. Полезный ориентир при выборе: валидация тысячи объектов на запрос — заметно, десятка — нет.
Тег v3.11.15.
Python/compile.c — компиляция аннотаций: видно, что для функции строится словарь, а для переменных в классе — запись в __annotations__ без присваивания (следствие 2).Lib/typing.py — вся система типов на Python: обобщения, Protocol, get_type_hints. Ничего в C.obj.x насквозь — почему статический вывод не может быть надёжнымwraps ломает валидацию по сигнатуреTYPE_CHECKING как способ разорвать цикл импортов