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

5d97948a36c1e32c8bcd7224523b8c85.jpg

Привет, Хабр!

Сегодня рассмотрим, как 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

Допустим, есть система скидок, которая может принимать:

  1. Процент (float).

  2. Фиксированную сумму (int).

  3. Готовые предустановленные значения ("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 рассказывают в рамках практических онлайн-курсов. С полным каталогом курсов можно ознакомиться по ссылке.

© Habrahabr.ru