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