$ sudo teach IT
Модуль 7 · Инструменты Python · Урок 7.7

Аннотации типов

Как подписывать переменные и функции ожидаемыми типами, чтобы код объяснял сам себя, редактор помогал на лету, а инструменты вроде mypy ловили ошибки ещё до запуска

Теория~30 минутНовичокtype hintstypingOptional / Unionmypy

Представьте, что вы открыли чужой файл и видите функцию process(data, limit). Что такое data? Список чисел? Строка? Словарь? А limit — это сколько, максимум элементов или максимум секунд? Без подсказок приходится читать всё тело функции, чтобы догадаться. И хуже: через пару месяцев вы точно так же не вспомните, что имели в виду в своём собственном коде.

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

Аннотации переменных

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

Официально это называется type hint (подсказка типа) или аннотация типа (type annotation). Синтаксис: после имени переменной ставится двоеточие, затем тип, и уже потом, через =, значение.

age: int = 25
name: str = "Алиса"
price: float = 9.99
is_active: bool = True

count: int
count = 0

Разбор по строкам:

  • age: int = 25 — после имени age двоеточие и тип int, дальше как обычно = 25. Переменная работает точно так же, как без аннотации.
  • Строки 2–4 — та же схема для строки, дробного числа и логического значения: тип пишется один раз, сразу после имени.
  • count: int без значения — так можно объявить, что переменная count будет хранить int, но присвоить значение позже. До присваивания использовать count нельзя, интерпретатор выдаст обычную ошибку про необъявленное имя.
  • count = 0 на следующей строке — фактическое присваивание, здесь тип уже не повторяем: Python помнит, что count аннотирован как int.

Важно понимать: Python — язык с динамической типизацией, и аннотации он не проверяет во время выполнения. Можно написать age: int = "двадцать пять", и программа выполнится без единой ошибки. Аннотации нужны людям и специальным инструментам, а не самому интерпретатору — он их просто запоминает и идёт дальше.

Аннотации параметров и возвращаемого значения

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

Официальный термин здесь тот же — аннотация, только применяется к каждому параметру и к результату. Тип параметра пишется так же, через двоеточие после имени. Тип возвращаемого значения обозначается стрелкой -> перед двоеточием, которое закрывает заголовок функции.

def greet(name: str) -> str:
    return f"Привет, {name}!"

def add(a: int, b: int) -> int:
    return a + b

def send_message(text: str, repeat: int = 1) -> None:
    for _ in range(repeat):
        print(text)

Разбор по частям:

  • def greet(name: str) -> str: — параметр name должен быть строкой, а функция вернёт строку. После str идёт стрелка, а за ней ещё один тип — тип того, что стоит после return.
  • def add(a: int, b: int) -> int: — у каждого параметра своя аннотация, они пишутся отдельно через запятую, как обычные параметры.
  • def send_message(text: str, repeat: int = 1) -> None: — если у параметра есть значение по умолчанию, тип ставится ДО знака равенства: repeat: int = 1, а не наоборот.
  • -> None — так помечают функции, которые ничего не возвращают через return с значением, а просто что-то делают (здесь — печатают). None здесь означает «функция явно ничего не возвращает».

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

Проверка типа во время выполнения: type() и isinstance()

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

Для этого есть две встроенные функции. type(объект) возвращает тип объекта. А isinstance(объект, тип) возвращает True или False — является ли объект экземпляром указанного типа.

x = 42
print(type(x))              # <class 'int'>
print(type(x) == int)       # True

print(isinstance(x, int))   # True
print(isinstance(x, str))   # False

# Проверка сразу на несколько типов — типы передаются кортежем
print(isinstance(x, (int, float)))   # True

Разбор:

  • type(x) — вернёт сам тип, в примере это класс int. При печати он выглядит как <class 'int'>.
  • type(x) == int — сравнение типа с конкретным типом через ==, даст True, потому что x действительно int.
  • isinstance(x, int) — тот же вопрос, но заданный по-другому: «является ли x экземпляром int?». Первый аргумент — сам объект, второй — тип, который проверяем.
  • isinstance(x, (int, float)) — когда вторым аргументом передан кортеж типов в круглых скобках, функция вернёт True, если объект относится хотя бы к одному из них. Так удобно проверять «любое число».

isinstance() в реальном коде используют чаще, чем type() ==: она умеет работать не только с простыми типами, но и корректно ведёт себя, когда типы связаны наследованием (это пригодится в курсе про ООП). Для наших сегодняшних задач достаточно запомнить: isinstance() — основной инструмент проверки типа в рабочем коде, type() — удобна для быстрой отладки и печати.

Модуль typing: аннотации для составных типов

int и str аннотировать просто. А как подписать список строк? Или словарь, где ключ — строка, а значение — число? Для таких случаев в стандартной библиотеке есть модуль typing, который мы подключаем через уже знакомый import.

from typing import List, Dict, Tuple, Optional, Union, Any

Здесь from typing import ... — та же конструкция, что вы уже использовали для from statistics import mean: подключаем не весь модуль, а сразу несколько конкретных имён из него, чтобы дальше писать их без typing. впереди.

List — список с известным типом элементов

def get_names() -> List[str]:
    return ["Аня", "Боб", "Влад"]

def total(nums: List[int]) -> int:
    return sum(nums)

List[str] читается как «список, а внутри квадратных скобок — тип элементов»: список строк. List[int] — список целых чисел. Квадратные скобки здесь играют ту же роль, что и при обращении по индексу, но в аннотации они уточняют тип содержимого, а не достают элемент.

Dict — словарь с известными типами ключей и значений

def word_lengths(words: List[str]) -> Dict[str, int]:
    result: Dict[str, int] = {}
    for word in words:
        result[word] = len(word)
    return result

Dict[str, int] — словарь, где первый тип в скобках относится к ключам, второй — к значениям: здесь ключ — слово (строка), значение — его длина (целое число). Обратите внимание: строка result: Dict[str, int] = {} аннотирует и параметр функции, и локальную переменную одновременно — это те же аннотации переменных из первого раздела, просто со сложным типом.

Tuple — кортеж с фиксированным набором типов по порядку

def get_person() -> Tuple[str, int, bool]:
    return "Мария", 30, True

Tuple[str, int, bool] расписывает тип каждого элемента кортежа по позиции: первый элемент — строка (имя), второй — целое число (возраст), третий — логическое значение. В отличие от List, где в скобках всегда один тип для всех элементов, у Tuple типов может быть несколько — ровно столько, сколько элементов в кортеже.

Optional — значение либо None

def find_user(user_id: int, users: Dict[int, str]) -> Optional[str]:
    return users.get(user_id)

users.get(user_id) возвращает значение по ключу либо None, если ключа нет — вы уже пользовались этим методом словарей. Optional[str] честно сообщает: «эта функция вернёт либо строку, либо None». Писать просто str здесь было бы неточно: тогда пришлось бы врать, что None никогда не случится.

Union — один из нескольких заранее известных типов

def stringify(value: Union[int, float, str]) -> str:
    return str(value)

Union[int, float, str] означает «int, или float, или str — любой из перечисленных». Внутри функции всё равно можно позвать str(value), потому что все три типа умеют превращаться в строку. Кстати, теперь видно, что Optional[str] из прошлого блока — это просто короткая запись для Union[str, None].

Any — тип действительно любой

def log(value: Any) -> None:
    print(f"[LOG] {value}")

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

Короткий синтаксис Python 3.10+: X | Y

Писать Union[int, str] каждый раз чуть громоздко. Начиная с Python 3.10 разработчики Python добавили более короткую запись через вертикальную черту.

Официально черта | здесь называется оператором объединения типов. Смысл ровно тот же, что у Union, просто компактнее и без импорта из typing.

def old_style(x: Union[int, str]) -> Optional[str]:
    return str(x) if x else None

def new_style(x: int | str) -> str | None:
    return str(x) if x else None

def process(value: int | float | str | None) -> str:
    return str(value) if value is not None else "пусто"

def get_ids() -> list[int]:
    return [1, 2, 3]

Разбор:

  • old_style и new_style — одна и та же аннотация, записанная двумя способами: Union[int, str] и int | str означают одно и то же, точно так же Optional[str] и str | None.
  • int | float | str | None — через | можно перечислить сколько угодно типов подряд, включая None, без отдельного Optional.
  • list[int] — с версии Python 3.9 встроенные коллекции (list, dict, tuple, set) можно указывать с типом элементов напрямую, маленькими буквами, без импорта List из typing. Смысл тот же, что у List[int] из прошлого раздела.

Совместимость: запись X | Y и строчные list[int]/dict[str, int] работают только в достаточно новых версиях Python. Если код должен запускаться и на старых версиях — используйте Union, Optional и List/Dict из typing. В новых проектах короткий синтаксис уже стал стандартом.

Статическая проверка типов: mypy

Мы выяснили, что сам Python аннотации в момент запуска не проверяет. Но кроме интерпретатора есть отдельные программы, которые умеют читать код не запуская его и искать несовпадения типов заранее — ещё до того, как вы нажали «запустить».

Такой подход называется статической проверкой типов, а самый известный инструмент для Python — mypy. Слово «статическая» здесь означает «без выполнения программы», в отличие от «динамической» проверки, которая происходит уже во время работы кода.

Установка (в терминале, один раз):

pip install mypy

Файл с ошибкой типа, check.py:

def add(a: int, b: int) -> int:
    return a + b

result = add("привет", 5)

Запуск проверки и её ответ:

mypy check.py

check.py:4: error: Argument 1 to "add" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)

mypy прочитал аннотацию a: int, увидел, что в четвёртой строке вместо числа передана строка "привет", и сообщил об этом — программа при этом ни разу не запускалась. В больших проектах, где функции вызывают друг друга сотни раз, это ловит целый класс ошибок ещё на этапе написания кода, а не после жалобы пользователей.

mypy — не единственный такой инструмент, есть и другие (например, pyright, встроенный в некоторые редакторы). Но именно mypy чаще всего рекомендуют для первого знакомства со статической проверкой типов.

Частые ошибки

Ждать, что аннотация сама преобразует или проверит значение. Это просто подпись, а не проверка:

age: int = "25"
print(age + 1)
# TypeError: can only concatenate str (not "int") to str
# Аннотация не превратила строку в число — ошибка вылезла позже, в другом месте

Забыть импорт из typing перед использованием List, Dict, Optional и других составных типов.

def get_ids() -> List[int]:
    return [1, 2, 3]
# NameError: name 'List' is not defined
# Нужно сначала: from typing import List

Перепутать порядок в аннотации параметра со значением по умолчанию. Тип — всегда до знака равенства:

def repeat(text: str = "hi": int):
    pass
# SyntaxError — правильно: def repeat(text: str, times: int = 1):

Что важно запомнить

  • Аннотации — подсказки для людей и инструментов, Python их не проверяет во время выполнения
  • Переменная: x: int = 5; функция: def f(a: int) -> str:; тип параметра со значением по умолчанию — до знака =
  • type(x) — узнать тип объекта; isinstance(x, тип) — проверить принадлежность к типу, основной способ в рабочем коде
  • Модуль typing: List[T], Dict[K, V], Tuple[...], Optional[T] (то же, что Union[T, None]), Union[...], Any
  • С Python 3.10 короче: Union[int, str] → int | str, Optional[str] → str | None; с 3.9 — list[int] вместо List[int]
  • mypy — статический анализатор: читает аннотации и находит несовпадения типов до запуска программы

Проверьте себя

7 вопросов

Аннотации типов в функциях

Добавьте аннотации типов к следующим функциям, не меняя их код:

  1. greet(name) — принимает строку, возвращает строку
  2. add(a, b) — принимает два целых числа, возвращает целое число
  3. is_even(n) — принимает целое число, возвращает bool
  4. get_squares(nums) — принимает список целых чисел, возвращает список целых чисел

Optional и Union

Premium