Аннотации типов в Python: коротко о главном

Привет, Хабр!
Сегодня рассмотрим, как Python, оставаясь динамически типизированным, может приближаться к строгой типизации. Всё дело в аннотациях типов, которые позволяют явно указывать, какие данные ожидаются в переменных, аргументах функций и возвращаемых значениях.
Аннотации сами по себе не заставляют Python проверять типы во время выполнения, но их можно использовать вместе с инструментами статического анализа. В первую очередь мы будем работать с mypy — популярным инструментом, который выявляет ошибки до запуска программы.
Для установки:
pip install mypy
Если в коде аннотирована строка, а передано число, mypy заранее предупредит об ошибке.
Существует также pyright — более быстрый инструмент от Microsoft, интегрированный в VS Code. Однако сосредоточимся на mypy.
Аннотации типов в Python
Базовые аннотации типов
Можно аннотировать типы аргументов и возвращаемого значения функции, чтобы сделать код более читаемым и понятным. Например, def add (a: int, b: int) → int: чётко говорит, что оба аргумента должны быть int, а результат тоже будет int. Такие аннотации помогают статическим анализаторам, вроде mypy, находить ошибки до выполнения кода.
Начнём с простого примера. Есть функция, которая принимает два числа и возвращает их сумму:
def add(a, b):
return a + b
Какие типы у a и b? Кто его знает. Может, int, может, float, может, вообще строки, которые кто‑то решил сложить. Теперь добавим аннотации:
def add(a: int, b: int) -> int:
return a + b
Теперь Python хотя бы предупредит, что если кто‑то попробует передать строку.
Попробуем передать не тот тип:
add(5, "10")
Запускаем mypy:
$ mypy script.py
error: Argument 2 to "add" has incompatible type "str"; expected "int"
IDE (если у вас PyCharm или VS Code с pylance) начнёт подсказывать, если типы не сходятся.
Коллекции
Аннотация списков, словарей и других контейнеров позволяет задать не только сам тип структуры, но и тип её элементов. Например, List[int] указывает, что список состоит только из целых чисел. Аналогично можно аннотировать множества (Set[str])
, кортежи (Tuple[int, str])
и даже вложенные структуры (Dict[str, List[float]])
.
Допустим, есть список чисел, и мы хотим их сложить:
from typing import List
def sum_numbers(numbers: List[int]) -> int:
return sum(numbers)
Окей, List[int]
означает «список, в котором только целые числа». Но что, если в списке могут быть float
?
def sum_numbers(numbers: list[float | int]) -> float:
return sum(numbers)
Теперь в numbers
можно передавать и int
, и float
, но не строки.
А если в списке могут быть вообще разные типы данных (например, числа и строки), можно использовать Any
:
from typing import Any
def process_list(data: list[Any]) -> None:
for item in data:
print(f"Обрабатываю {item}")
Но не стоит злоупотреблять Any
, потому что это убивает смысл статической типизации.
TypedDict
Обычные словари в Python — это просто хаотичный набор ключей и значений, и Python никак не проверяет, какие именно ключи там должны быть. Но когда работаешь с JSON, конфигами или API, важно, чтобы IDE знала структуру словаря.
TypedDict позволяет явно задать типы ключей и их значений. mypy будет следить, чтобы словарь содержал только нужные ключи.
Допустим, есть пользователь с id
, name
и email
:
from typing import TypedDict
class User(TypedDict):
id: int
name: str
email: str
def print_user(user: User) -> None:
print(f"ID: {user['id']}, Name: {user['name']}, Email: {user['email']}")
user = {'id': 1, 'name': 'Roman', 'email': 'roman@example.com'}
print_user(user) # Всё работает!
Теперь если какой‑то ключ будет отсутствовать, mypy предупредит нас:
user = {'id': 1, 'name': 'Roman'} # Нет email!
print_user(user)
error: Missing key "email" in TypedDict "User"
Теперь IDE и mypy помогут избежать проблем из‑за отсутствующих ключей.
Optional-поля в TypedDict
Иногда бывает, что не все поля обязательны. Например, у пользователя может не быть email. Тогда указываем NotRequired:
from typing import NotRequired
class User(TypedDict):
id: int
name: str
email: NotRequired[str] # Email может отсутствовать
user: User = {'id': 1, 'name': 'Alice'} # ✅ Теперь это не ошибка
*NotRequired появился в Python 3.11 и в старых версиях находится в typing_extensions
Protocol
Python изначально поддерживает утиную типизацию: «если что‑то выглядит как утка и крякает как утка, значит, это утка». Однако без явных интерфейсов это иногда приводит к багам. Protocol из модуля typing позволяет формализовать этот подход и заставить Python проверять, действительно ли объект соответствует нужному интерфейсу.
Допустим, есть объекты, которые умеют летать. Можно определить протокол, которому они должны соответствовать:
from typing import Protocol
class CanFly(Protocol):
def fly(self) -> str:
...
class Bird:
def fly(self) -> str:
return "I am flying!"
class Airplane:
def fly(self) -> str:
return "Engines running, takeoff!"
def launch(flyer: CanFly) -> None:
print(flyer.fly())
bird = Bird()
plane = Airplane()
launch(bird) # "I am flying!"
launch(plane) # "Engines running, takeoff!"
Здесь Bird
и Airplane
не наследуеются от CanFly, но всё равно работают, потому что соответствуют его структуре.
Union и Literal
В Python иногда бывает необходимо разрешить несколько возможных типов для одной переменной. Например, функция может работать и с int, и с str, и с float. Вместо Any, который снимает все проверки, лучше использовать Union, который ограничивает список допустимых типов, но всё же позволяет некоторую гибкость.
Функция, которая принимает число или строку и приводит её к строковому виду:
from typing import Union
def to_uppercase(value: Union[int, float, str]) -> str:
return str(value).upper()
print(to_uppercase(100)) # "100"
print(to_uppercase(3.14)) # "3.14"
print(to_uppercase("hello")) # "HELLO"
Теперь to_uppercase () принимает только int, float или str, но не list или dict.
Что будет, если передать неподдерживаемый тип?
print(to_uppercase([1, 2, 3])) # Ошибка. mypy предупредит
mypy тут же выдаст предупреждение:
error: List[int] is not compatible with expected type "Union[int, float, str]"
Теперь IDE и mypy заранее предотвратят использование неподходящего типа.
Бывает, что параметр должен принимать строго определённые значения, например «light» или «dark». В таких случаях Union[str]
не спасает, ведь str
включает любые строки. Чтобы сузить список разрешённых значений, используется Literal.
Функция, которая принимает только «light» или «dark»:
from typing import Literal
def set_mode(mode: Literal["light", "dark"]) -> str:
return f"Mode set to {mode}"
set_mode("light") # Окей
set_mode("blue") # Ошибка.
set_mode("blue")
приведёт к ошибке ещё до выполнения кода, потому что «blue» не входит в разрешённые значения.
А теперь представим, что есть функция, которая может принимать либо число (int | float), либо строку из конкретного набора значений:
from typing import Union, Literal
def format_size(size: Union[int, float, Literal["small", "medium", "large"]]) -> str:
return f"Selected size: {size}"
print(format_size(42)) # "Selected size: 42"
print(format_size("medium")) # "Selected size: medium"
print(format_size("tiny")) # Ошибка! "tiny" не входит в допустимые значения
Так можно комбинировать свободный ввод Union и строгие ограничения Literal.
Generic
В Python часто пишем функции, которые должны работать с разными типами данных, но при этом сохранять строгую типизацию. Вместо того чтобы делать Union[int, str, float]
, можно использовать TypeVar — параметризированный тип, который позволяет создавать обобщённые (generic) функции.
Допустим, есть функция, которая возвращает первый элемент из списка:
from typing import TypeVar, List
T = TypeVar("T") # Объявляем универсальный тип
def get_first_item(items: List[T]) -> T:
return items[0]
print(get_first_item([1, 2, 3])) # int
print(get_first_item(["a", "b", "c"])) # str
print(get_first_item([3.14, 2.71])) # float
Теперь get_first_item()
автоматически подстраивается под переданный тип (int, str, float)
, и mypy при этом проверяет корректность типов.
Можно ограничить TypeVar, указав, какие типы разрешены:
from typing import TypeVar
Number = TypeVar("Number", int, float)
def multiply(value: Number, factor: Number) -> Number:
return value * factor
print(multiply(10, 2)) # int
print(multiply(3.5, 2.1)) # float
print(multiply("3", 2)) # Ошибка! str не разрешён
Здесь multiply()
принимает только int и float, но не str — mypy предупредит об ошибке заранее.
Обобщённые классы позволяют избежать дублирования кода:
from typing import Generic
T = TypeVar("T")
class Box(Generic[T]):
def __init__(self, item: T):
self.item = item
def get_item(self) -> T:
return self.item
int_box = Box(42)
str_box = Box("Hello")
print(int_box.get_item()) # 42
print(str_box.get_item()) # Hello
Класс Box теперь может хранить любой тип, но при этом сохраняет строгую типизацию.
Изучить все лучшие практики программирования на Python с нуля можно в рамках специализации «Python Developer».
Пример применения
Чтобы увидеть, зачем вообще нужны аннотации типов, представим, что есть онлайн‑магазин котиков. В нём можно заказывать котиков, оформлять заказы и получать отчёты.
Без аннотаций
Допустим, мы пишем функцию для оформления заказа, но не указываем типы:
def process_order(cat, quantity, price):
total = quantity * price
return f"Заказ: {quantity}x {cat}, сумма: {total} руб."
На первый взгляд всё нормально, но представьте, что кто‑то вызовет её так:
print(process_order("Британец", "2", 5000)) # Ожидалось 10000 руб.
Результат:
Заказ: 2x Британец, сумма: 50005000 руб.
Вместо умножения 2×5000, Python сконкатенировал строки, потому что »2» — это str, а не int.
Теперь добавим аннотации типов:
def process_order(cat: str, quantity: int, price: int) -> str:
total = quantity * price
return f"Заказ: {quantity}x {cat}, сумма: {total} руб."
Теперь mypy сразу выдаст ошибку, если передать строку вместо числа:
error: Argument 2 to "process_order" has incompatible type "str"; expected "int"
Отлично, мы предотвратили потенциальную ошибку ещё до запуска кода.
TypedDict
Теперь создадим словарь, который будет хранить информацию о заказе.
Обычный словарь не защищает нас от ошибок:
order = {"cat": "Мейн-кун", "quantity": "3", "price": 7000} # quantity опять строка!
А если мы забудем один из ключей?
order = {"cat": "Сфинкс", "price": 9000} # quantity отсутствует!
Python никак не проверит, что ключи есть и что их типы правильные.
Поэтому делаем так:
from typing import TypedDict
class Order(TypedDict):
cat: str
quantity: int
price: int
order: Order = {"cat": "Сфинкс", "quantity": 3, "price": 9000} # Всё ок
order2: Order = {"cat": "Сфинкс", "price": 9000} # Ошибка: отсутствует "quantity"
Теперь mypy не позволит передавать неполный заказ или указывать неверные типы.
Protocol
Допустим, в магазине есть разные методы оплаты: картой, криптовалютой, наличными.
Без Protocol нет контроля за методами оплаты:
class CardPayment:
def pay(self, amount):
print(f"Оплата картой на сумму {amount} руб.")
class CryptoPayment:
def send_money(self, amount):
print(f"Оплата криптовалютой {amount} USDT")
Если написать функцию, принимающую любой метод оплаты, она не будет знать, какой метод вызывать:
def process_payment(payment, amount):
payment.pay(amount) # А если у объекта нет метода pay()?
Если передать CryptoPayment, то всё сломается:
process_payment(CryptoPayment(), 5000) # Ошибка! send_money() вместо pay()
С Protocol:
from typing import Protocol
class PaymentMethod(Protocol):
def pay(self, amount: int) -> None:
"""Все платежные методы должны реализовывать pay(amount: int)."""
...
class CardPayment:
def pay(self, amount: int) -> None:
print(f"Оплата картой на сумму {amount} руб.")
class CryptoPayment:
def pay(self, amount: int) -> None:
print(f"Оплата криптовалютой {amount} USDT")
def process_payment(payment: PaymentMethod, amount: int) -> None:
payment.pay(amount)
process_payment(CardPayment(), 5000) # Всё работает
process_payment(CryptoPayment(), 100) # Всё работает
Теперь любая платежная система обязана иметь метод pay (), иначе mypy не пропустит код.
Union и Literal
Допустим, есть система скидок, которая может принимать:
Процент (
float
).Фиксированную сумму (
int
).Готовые предустановленные значения (
"low"
,"medium"
,"high"
).
Без Union и Literal:
def apply_discount(discount):
if isinstance(discount, str):
if discount == "low":
return 5
elif discount == "medium":
return 10
elif discount == "high":
return 20
elif isinstance(discount, (int, float)):
return discount
else:
raise ValueError("Некорректная скидка")
Нужно вручную проверять типы и выбрасывать ошибки.
С Union и Literal всё строго:
from typing import Union, Literal
def apply_discount(discount: Union[int, float, Literal["low", "medium", "high"]]) -> float:
if discount == "low":
return 5
elif discount == "medium":
return 10
elif discount == "high":
return 20
return float(discount)
Теперь IDE подскажет, какие значения допустимы, а mypy не позволит передать что‑то не то.
Generic: универсальные классы для товаров
Допустим, есть разные категории товаров: котики, игрушки, корм.
Можно создать класс для хранения товара:
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
Но если нужно, чтобы товар мог быть разного типа (например, цифровой или физический), приходится жонглировать Any.
С Generic можно сделать строгую типизацию товаров:
from typing import Generic, TypeVar, Dict, Union
T = TypeVar("T", bound=Union[str, Dict[str, str]])
class Product(Generic[T]):
def __init__(self, name: str, price: int, details: T):
self.name = name
self.price = price
self.details = details # Например, это может быть вес, цвет или формат
cat_product = Product("Сибирский кот", 15000, {"weight": "4 кг"})
digital_product = Product("Курс по уходу за котами", 5000, "Видео")
Теперь Product может хранить любые типы данных, но при этом тип details всегда остаётся предсказуемым.
Больше про языки программирования эксперты OTUS рассказывают в рамках практических онлайн-курсов. С полным каталогом курсов можно ознакомиться по ссылке.