• Добро пожаловать на сайт - Forumteam.bet !

    Что бы просматривать темы форума необходимо зарегестрироваться или войти в свой аккаунт.

    Группа в телеграме (подпишитесь, что бы не потерять нас) - ForumTeam Chat [Подписатся]
    Связь с администратором - @ftmadmin

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

Article Publisher

Публикатор
Команда форума
Регистрация
05.02.25
1741966788687.png

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

Сегодня рассмотрим, как 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': '[email protected]'}
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 всегда остаётся предсказуемым.
 
Сверху Снизу