Типы стёрты

Аннотации не проверяются никем и ничем во время работы — это просто данные в атрибуте. Отсюда и отдельный слой валидации вроде pydantic, и пляска с TYPE_CHECKING, и принципиальная невозможность надёжного статического вывода.

L1 · 8 минL2 · 10 мин📱 телефонсверено · CPython 3.11 + pydantic 2.13, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
def g(x: int = 0):
    return x

print(g("строка"))
строка
Аннотация есть, значение не то, ошибки нет.
Фрагмент B
def f(x: int, y: "SomeUndefined") -> bool: ...
print(f.__annotations__)
{'x': <class 'int'>, 'y': 'SomeUndefined',
 'return': <class 'bool'>}
Несуществующее имя в аннотации не мешает функции существовать.
Фрагмент C
from dataclasses import dataclass

@dataclass
class D:
    a: int

d = D("не число")
print(repr(d.a))
'не число'
Датакласс сгенерировал __init__ по аннотациям и ничего не проверил.
Фрагмент D
class C:
    x: int

print(C.__annotations__)
print(hasattr(C, "x"))
{'x': <class 'int'>}
False
Аннотация записана, атрибута нет.

2Механизм

Аннотация — выражение, которое вычисляется и складывается в словарь. Для функции это __annotations__, для класса и модуля — одноимённый атрибут.

Интерпретатор ими не пользуется. Никакой проверки при вызове, при присваивании, нигде.

Проверку делает отдельный инструмент до запуска, читая тот же исходник. Валидацию данных во время работы — отдельная библиотека, читающая те же аннотации как спецификацию.

Следствие 1 · аннотация не влияет ни на что

Фрагменты A и C. Функция принимает что дали; датакласс кладёт в поле что дали. Аннотация — метаданные, лежащие рядом, и единственный, кто их читает, — тот, кого ты явно позвал.

Отсюда практическая формулировка, которую стоит держать в голове дословно: аннотации — это документация, машиночитаемая и потому проверяемая внешним инструментом, но всё-таки документация. Она не защищает от плохих данных на границе программы.

Следствие 2 · аннотация класса без значения не создаёт атрибут

Фрагмент D. Строка x: int в теле класса — это только аннотация: имя записывается в __annotations__, но никакого присваивания не происходит, и атрибута нет. Строка x: int = 0 делает и то, и другое.

Отсюда очень частая ошибка: объявили поле аннотацией, обратились к нему до присваивания в __init__ — получили AttributeError. И отсюда же понятно, как работает датакласс: он читает __annotations__, видит перечень полей и генерирует __init__, которого без него не было бы.

Следствие 3 · аннотация может ссылаться на то, чего нет

Фрагмент 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: ...
Следствие 4 · поэтому нужен второй слой

Раз рантайм ничего не проверяет, а данные приходят снаружи — из запроса, из файла, из очереди, — валидация должна выполняться явно. Так появился pydantic: он читает те же аннотации, но использует их как спецификацию для проверки и приведения во время работы.

Это не улучшенный датакласс, а другой слой, и разница видна в замере: там, где датакласс просто раскладывает аргументы по полям, pydantic каждый раз проверяет и приводит типы.

200 000 созданий объекта с тремя полямивремя
@dataclass0.032 с
pydantic.BaseModel0.192 с

Шестикратная разница — цена валидации, и её надо платить ровно на границе. Внутри системы, где данные уже проверены, датакласс уместнее.

Следствие 5 · надёжного статического вывода не будет

Из урока 04: атрибут резолвится в рантайме, класс можно менять после создания, __getattr__ синтезирует любое имя. Значит анализатор обязан предполагать, что этого не произошло.

Поэтому mypy не «недоделан» — он принципиально не может быть надёжным на языке с такой моделью. Его выводы полезны и не являются доказательствами, а Any существует как узаконенный выход из системы типов. Требовать от него гарантий уровня компилятора Rust — значит требовать другого языка.

3Границы модели

Где сказанное перестаёт держать

Некоторые аннотации всё же вычисляются. По умолчанию выражение в аннотации исполняется в момент определения функции, поэтому опечатка в типе даёт NameError при импорте. Строковая форма и from __future__ import annotations откладывают вычисление, превращая всё в строки.

Библиотеки на аннотациях ломаются от обёрток. Фреймворки, читающие сигнатуру, получают её через inspect.signature — а декоратор без functools.wraps её подменяет (урок 08). Отсюда неочевидная связка: забытый wraps ломает валидацию по типам.

get_type_hints не всегда справляется. Он вычисляет строковые аннотации в контексте модуля, и если тип виден только под TYPE_CHECKING, попытка их развернуть в рантайме упадёт с NameError. Это осознанный размен: не платить импортом ради проверки.

4Корень

Корень R8: язык сознательно отказался от статических гарантий. Синтаксис аннотаций появился в Python 3.0 (PEP 3107) вообще без семантики — просто «место, куда можно написать что угодно». Значение ему придал PEP 484 в 2014-м, и ключевое решение там сформулировано прямо: проверка не входит в рантайм и остаётся опциональной.

Причина практическая. Миллионы строк существующего кода без аннотаций должны были продолжать работать, а типизация — внедряться постепенно, файл за файлом. Обязательная проверка сделала бы переход невозможным, а любая проверка в рантайме — дорогой: она выполнялась бы на каждом вызове.

Цена — то, что разобрано выше: нет гарантий, нужен второй слой для данных, анализатор не может быть надёжным. Выигрыш — огромные кодовые базы, которые типизировали не переписывая, и полная свобода экспериментировать с системой типов вне релизов языка.

5Аналогия

Аннотации Python — это комментарии, которые умеет читать инструмент, и ближайший знакомый аналог из мира C — заголовочные файлы, если бы компилятор их не проверял, а по ним работал только линтер. Объявление есть, договор описан, но принуждения нет.

Более точное сравнение с C++ такое: там тип — это то, что компилятор доказал перед стиранием, и потому в рантайме проверять нечего. В Python тип — это то, что автор заявил, и потому в рантайме проверять надо всё, что пришло снаружи. Одно и то же слово обозначает разные по силе утверждения, и вся практика вокруг типизации в Python растёт из этого различия.

Отсюда простой рабочий критерий: аннотация внутри системы — доказательство, которому можно доверять ровно настолько, насколько прогоняется анализатор; аннотация на границе системы — обещание, которое надо проверить кодом.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Если аннотации ничего не проверяют, зачем их писать?

Затем, что проверяет их не интерпретатор, а три других потребителя — и все три реальные.

Первый — человек. Сигнатура 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 пропускает баги — это можно починить?

Нельзя, и это не недоделка 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 стоит шестикратно — когда это оправдано?

На границе процесса — почти всегда. Внутри — почти никогда.

Замер из этого урока: создание объекта через 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__ синтезирует что угодно, поэтому выводы анализатора полезны, но не являются доказательствами.
Дальше — по желанию
контрфактуалC++, TypeScript и RustСтёртые типы против проверяемых — и естественный эксперимент, поставленный дважды

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 — там, где описываешь ожидание от чужого класса: не требует наследования и не создаёт зависимость.

💻 терминалПроверь в REPLЧетыре фрагмента плюс NameError в аннотации, from __future__ и get_type_hints

Прогони фрагменты, сверяясь с предсказанием. Затем — первая пачка, вставляется целиком:

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.

исходникиdataclasses и typingГде стандартная библиотека читает аннотации как данные

Lib/dataclasses.py. Возвращаемся сюда в третий раз, и теперь с главного входа: _process_class читает __annotations__, строит список полей и генерирует __init__ текстом через exec. Заметь, что тип поля используется только как признак «это поле» — сравнения с ним нигде нет. Следствие 1 в исходнике стандартной библиотеки.

Lib/typing.py, функция get_type_hints. Здесь видно, как строковые аннотации превращаются обратно в объекты: они вычисляются через eval в пространстве имён модуля. Отсюда и ограничение из «Границ модели» — если имя доступно только под TYPE_CHECKING, вычислять нечего.

Lib/functools.py, singledispatch. Редкий случай, когда стандартная библиотека использует аннотацию как рабочую информацию: тип первого аргумента определяет, какая реализация будет вызвана. Механизм диспетчеризации по типам, построенный поверх метаданных.

L2Где лежат аннотации и сколько стоят__annotations__, отложенное вычисление и цена импорта typing

Из 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, разрыв был кратно больше. Полезный ориентир при выборе: валидация тысячи объектов на запрос — заметно, десятка — нет.

L3Где это в исходниках и стандартахcompile.c, typing.py, PEP 484 и 563

Тег v3.11.15.

  • Python/compile.c — компиляция аннотаций: видно, что для функции строится словарь, а для переменных в классе — запись в __annotations__ без присваивания (следствие 2).
  • Lib/typing.py — вся система типов на Python: обобщения, Protocol, get_type_hints. Ничего в C.
  • PEP 484 — решение о том, что проверка не входит в рантайм; читать раздел «Non-goals».
  • PEP 563 — отложенное вычисление аннотаций и история о том, почему оно так и не стало поведением по умолчанию.
связиКуда это ведётL04, L08, L11, L17 · контраст с TypeScript, C++ и Rust
корень R8 · Отказ от статических гарантий
← основа L04 · obj.x насквозь — почему статический вывод не может быть надёжным
← основа L08 · Функции и декораторы — забытый wraps ломает валидацию по сигнатуре
← основа L17 · Импорт насквозь — TYPE_CHECKING как способ разорвать цикл импортов
↔ контраст TypeScript: то же решение, тот же результат — независимое подтверждение
↔ контраст C++ стирает доказанное, Python стирает заявленное
↔ контраст L11 · Протоколы — структурная типизация против номинальной внутри самой системы типов
источникиЧто почитатьPEP 484 🟥 · typing 🟧 · PEP 563 🟦