Одна из важных характеристик любого языка программирования — типизация. Она бывает статической и динамической. Типизацию можно представить как систему правил, которая определяет, какие типы данных допустимы в программе, как они сочетаются друг с другом и когда происходит их проверка.
Python относится к языкам программирования с динамической типизацией. Это значит, что в одной переменной можно хранить разные типы данных:
value = 42 # сначала value — целое число value = "hello" # теперь value — строка value = [1, 2, 3] # а потом — список
В примере выше мы объявили переменную какое число, переприсвоили ей строку, а затем список — и интерпретатор Python не выдал ошибок. Это удобно, когда учишься или пишешь небольшие скрипты. Однако в реальных проектах такая свобода часто приводит к трудноуловимым ошибкам — например, если функция ожидает получить число, а получает строку.
Один из способов навести порядок в коде — явно указывать типы данных. В Python для этого есть целая экосистема: встроенные типы, расширенные аннотации через модуль typing, обобщенные типы (Generics), структурная типизация через Protocol и mypy как инструмент для проверки типов до запуска кода. Именно в таком порядке мы все это будем рассматривать.
Статья рассчитана на начинающих разработчиков, которые что-то слышали про типизацию и хотят в общих чертах разобраться, как она устроена в Python и зачем вообще нужна. Мы все будем объяснять на простых фрагментах кода.
Встроенные типы
В Python есть встроенный набор типов данных, которые используются в большинстве программ. Их не нужно импортировать — вот основные из них:
- int — целые числа (42, 7);
- float — числа с плавающей точкой (3.14, 0.5);
- str — строки («привет»);
- bool — логические значения (True или False);
- list — список элементов ([1, 2, 3]);
- dict — словарь с парами ключ-значение ({«name»: «Алексей»});
- tuple — неизменяемая последовательность ((1, 2, 3));
- set — множество уникальных элементов ({1, 2, 3}).
Именно эти типы данных разработчики чаще всего прописывают в аннотациях — подсказках, которые пишутся в коде рядом с переменной или функцией. Для этого после имени переменной нужно поставить двоеточие и указать тип:
age: int = 25 name: str = "Анастасия"
Для коллекций list, dict, tuple и set вы можете дополнительно указать, какие типы данных они содержат:
numbers: list[int] = [1, 2, 3]
user: dict[str, int] = {"age": 25}
При работе с функциями аннотируют аргументы вместе с возвращаемым значением. Возвращаемый тип указывают через -> после круглых скобок:
def add_prices(price: int, delivery_fee: int) -> int: return price + delivery_fee # возвращает сумму (целое число)
Если функция ничего не возвращает, то это тоже можно явно указать в аннотации. Для этого нужно использовать None как возвращаемый тип:
def print_greeting(name: str) -> None:
print(f"Привет, {name}!")
result = print_greeting("Анастасия")
print(result) # None
Однако важно понимать, что интерпретатор не проверяет аннотации типов во время выполнения программы. Например, вы можете написать age: int = «привет», и код запустится без ошибок. Аннотации — это лишь подсказки для разработчиков и анализаторов кода, которые не влияют на поведение Python.
Модуль typing
Встроенных типов хватает для простых случаев аннотирования. Однако иногда переменная может содержать сразу несколько возможных типов, ее значение может быть пустым или заранее неизвестным. Для таких ситуаций в Python предусмотрен модуль typing, который входит в стандартную библиотеку.
Несколько допустимых типов
Если переменная может принимать значения разных типов, их нужно перечислить через вертикальную черту |:
def find_user(user_id: int | str) -> str:
return f"Ищем пользователя: {user_id}"
Аннотация int | str означает, что аргумент user_id может быть целым числом или строкой. Также вы можете встретить запись Union[int, str] из модуля typing — это старый синтаксис, который применялся до Python 3.10.
Отсутствующее значение
Если переменная или аргумент функции может оказаться пустым, в качестве одного из возможных типов нужно добавить значение None через |:
def get_full_name(first: str, last: str, middle: str | None = None) -> str:
if middle:
return f"{last} {first} {middle}"
return f"{last} {first}"
Функция get_full_name принимает три аргумента: обязательные first и last, а также отчество middle. Аннотация str | None означает строка или None — то есть аргумент может быть строкой или вовсе отсутствовать.
Неизвестный тип
Для таких случаев есть аннотация Any, которая означает любой тип:
from typing import Any def log(value: Any) -> None: print(value)
В примере мы импортировали тип Any из модуля typing и использовали его в аннотации функции log. Это означает, что теперь функция может принять значение абсолютно любого типа — число, строку, список или что угодно еще.
Формально мы все записали правильно. Однако само по себе добавление Any сводит на нет весь смысл аннотаций, поскольку отключает проверку типов в данном месте программы — анализаторы перестают указывать на ошибки, связанные с типами. Поэтому мы рекомендуем использовать Any только как временное решение — например, когда вы еще не определились с конкретным типом данных или работаете с внешней библиотекой, где типы не описаны.
Обобщенные типы (Generics)
Допустим, вы пишете функцию, которая возвращает первый элемент списка. Здесь возникает вопрос с аннотациями: если вы укажете какой-то конкретный тип, например int, то формально функция будет работать только с целыми числами. Но что, если вам нужно использовать эту же функцию со строками, объектами или другими типами? Тогда придется писать отдельные функции для каждого типа или использовать Any и потерять пользу от аннотирования.
Именно для таких случаев предусмотрены обобщенные типы — Generics. Они позволяют написать функцию один раз, не привязываясь к конкретному типу данных, и при этом сохранить строгую типизацию. То есть в каждом конкретном случае анализатор кода будет знать, с каким типом вы работаете.
Представьте обобщенный тип как коробку с прозрачными стенками. Вы заранее не знаете, что в ней лежит. Но когда в ней будет что-то конкретное, это сразу видно — и дальше коробка работает только с этим типом содержимого.
В Python роль такой коробки выполняет переменная типа TypeVar из модуля typing. Напишем функцию, которая возвращает первый элемент из списка:
from typing import TypeVar
ItemType = TypeVar("ItemType")
def first(items: list[ItemType]) -> ItemType:
return items[0]
Если мы передадим в функцию first([1, 2, 3]), анализатор увидит список целых чисел и поймет, что ItemType в этом случае соответствует int. Значит, функция вернет int. А если вызвать first([«a», «b»]), то ItemType будет соответствовать str, и возвращаемое значение тоже окажется строкой. Одна функция работает с любым типом, но анализатор всегда точно знает, что именно она вернет.
В Python 3.12 появился более короткий синтаксис для Generics. Он позволяет объявлять переменную типа в сигнатуре функции, без предварительного создания TypeVar. Для наглядности перепишем предыдущий пример:
def first[ItemType](items: list[ItemType]) -> ItemType: return items[0]
Переменная типа [ItemType] после имени функции — это сокращенная замена TypeVar. Аннотация list[ItemType] -> ItemType означает, что на вход функция получает список элементов типа ItemType, а на выход — один элемент того же типа. Результат получается тем же, просто вы напишите чуть меньше кода.
Структурная типизация через Protocol
До этого мы типизирован числа, строки и списки. Однако в реальном коде функции часто принимают объекты — и обычно нам важно не то, какого они класса, а то, что они умеют делать. Этот подход называется структурной или утиной типизацией, и его суть в следующем: если объект крякает и ходит вперевалку — значит, это утка. При этом нам совершенно неважно, какого он класса и от кого наследуется. Главное — что у него есть нужные нам методы.
Напишем функцию render, которая отрисовывает переданный объект:
def render(shape): shape.draw()
В этом примере функция ничего не знает о типе shape. Вы можете передать ей круг, квадрат, треугольник или что угодно еще — все это будет отрисовано на экране, если у объекта есть метод draw(). Это удобно, однако анализатор кода не знает, какой тип ожидает функция, и поэтому не сможет подсказать вам об ошибке. Чтобы такого избежать, в модуле typing есть класс Protocol.
С помощью Protocol мы можем описать, какие методы и с какими сигнатурами должны быть у объекта. После этого анализатор автоматически сможет проверить, соответствует ли переданный объект нашему описанию:
from typing import Protocol class Drawable(Protocol): def draw(self) -> None: ...
Мы создали протокол Drawable и указали, что у любого объекта, который ему соответствует, должен быть метод draw() без аргументов и возвращаемого значения. Многоточие … вместо тела — это стандартный способ показать, что нам важна только сигнатура, а не реализация метода.
Добавим протокол в аннотацию функции:
def render(shape: Drawable) -> None: shape.draw()
Теперь анализатор знает, что в функцию render можно передать любой объект с методом draw(). Для проверки создадим несколько классов:
class Circle:
def draw(self) -> None:
print("Рисую круг")
class Square:
def draw(self) -> None:
print("Рисую квадрат")
render(Circle()) # OK — у Circle есть метод draw()
render(Square()) # OK — у Square есть метод draw()
render("привет") # ошибка mypy — у str нет метода draw()
Обратите внимание: классы Circle и Square ничего не знают о протоколе Drawable и не наследуются от него. Им достаточно иметь метод draw() — и анализатор автоматически считает их совместимыми. В этом суть структурной типизации: важно не происхождение класса, а наличие нужных методов.
Проверка типов с mypy
В начале статьи мы упоминали, что Python не проверяет аннотации сам. Для этого есть отдельный инструмент — статический анализатор mypy. Именно он читает весь ваш код и ищет несоответствия типов еще до запуска программы.
Установить его можно через менеджер пакетов pip:
pip install mypy
После этого достаточно передать mypy имя файла:
mypy name.py
Создадим файл mypy_demo.py и напишем функцию с ошибкой в аргументе:
# mypy_demo.py
def calculate_total(price: float, tax: float) -> float:
return price * (1 + tax)
calculate_total(1000, "20%") # передаем строку "20%" вместо числа
Если вы запустите в терминале mypy, то увидите такую ошибку:
mypy_demo.py:6: error: Argument 2 to "calculate_total" has incompatible type "str"; expected "float" [arg-type] Found 1 error in 1 file (checked 1 source file)
Мы еще не запустили программу, а mypy уже обнаружил проблему: в данном случае второй аргумент должен быть числом (float), а мы передали строку (str). На небольших проектах это может показаться излишним, но когда код разрастается до сотен файлов и над ним работает команда — статический анализ становится незаменимым инструментом для поддержки качества кода.

Полезные статьи и ссылки по теме
- Документация по модулю typing со всеми встроенными типами и примерами
- Документация по анализатору mypy
- PEP 484 — предложение, с которого началась типизация в Python
- Строки в Python: функции и методы str от базовых к продвинутым
- Что такое списки в Python и как с ними работать
- Числа с плавающей точкой: что это такое и как с ними работать
