Аннотации типов в 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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/888974/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *