Keyboard Shortcuts

Close

Типизация в Python (ru)

Tags: python eosp


В этой статье рассматриваются два ключевых вопроса. Зачем использовать типизацию в языке Python, который позволяет писать без неё? И как писать типизированный код на Python правильно? Постараемся быстро познакомиться со всеми необходимыми инструментами, чтобы после прочтения вы уже могли начать осознанно писать свои программы типизированными, ведь это совсем не сложно!

# Динамика vs Статика

Итак, что же такое типизация? Новым это понятие может оказаться только для питонистов, ведь большинство классических языков таких как C, Java, Rust и многие другие исходно были созданы, как языки со статической типизацией. Но что это означает? Давайте рассмотрим небольшой пример на C:

int sum(int a, int b) {
    return a + b;
}

int main() {
    printf("%d\n", sum(10, 20));

    // printf("%d\n", sum("10", 20));
}

Такой код работает и выводит число 30. Но обратите внимание, что последняя строка закомментирована. Если мы раскомментируем её и опять попробуем скомпилировать программу, то получим примерно вот такой лог ошибки:

error: passing argument 1 of ‘sum’ makes integer from pointer without cast
   10 |     printf("%d\n", sum("10", 20));
      |                        ^~~~
      |                        |
      |                        char *
note: expected ‘int’ but argument is of type ‘char *’

Лог сообщает нам, что параметр функции, ожидая int, получил аргумент типа char * (для упрощения можем считать это эквивалентом строки). На первый взгляд ничего удивительного, для нас с вами — питонистов, в этом нет. Ведь вот такой код на Python тоже упал бы с ошибкой:

def sum(a, b):
    return a + b

print(sum("10", 20)) # TypeError

В чем же разница, спросите вы? Давайте немного подправим оба этих примера на C и на Python. Попробуем вызвать функцию sum от двух строк:

print(sum("10", "20")) # > 1020

Здесь мы не получаем никаких ошибок, потому что действует полиморфизм, а для строк операция сложения тоже реализована. Но что будет в C?

int main() {
    printf("%d\n", sum("10", "20"));
}

Такую программу не выйдет даже скомпилировать. Мы опять получим точно такой же лог ошибки, который был ранее. Обратите внимание на то, как мы определяли функцию sum в языке C. Там мы явно указали типы входных аргументов как int. Это означает, что аргумент любого другого типа нельзя передать в эту функцию. Это и называется статической типизацией. Также статическая типизация обязывает указать тип для каждой переменной и запрещает менять типы переменных после определения оных. То есть тип фиксирован, статичен.

Вторая же группа языков называется динамически типизированными. Это такие языки как Python, Lua, JavaScript и другие. В них, соответственно, тип переменной строго не фиксирован и может меняться в ходе исполнения программы.

# Преимущества типизации

Пора перейти к вопросу, зачем типизация нам нужна, если в том же Python всё и так работает отлично. Во-первых, это скорость. На низком уровне (чем бы он ни был представлен) нам в любом случае нужно знать типы переменных. А тот факт, что мы можем позволить себе, их не выставлять, лишь означает, что кто-то делает это за нас (например, виртуальная машина), а это в свою очередь требует ресурсов. Отсюда и картина, которую мы наблюдаем в рейтингах языков по скорости:

|900

Python в этом рейтинге, кстати говоря, на последнем месте. Может быть типизированный Python как раз поможет нам исправить этот печальный факт? К сожалению, нет. Поскольку Python как не был исконно, так по сей день остается, не статически типизированным языком. Аннотации типов в Python остаются опциональными, их можно не указывать. И сам интерпритатор Python в runtime не проверят их.

Тут мы переходим ко второму приемуществу, которое открывают для нас типы. И это качество кода.

«Цель типизации в Python — помочь инструментам разработки искать ошибки в кодовых базах на Python с помощью статического анализа, то есть не выполняя тестов кода.»

Лусиану Рамальо, «Python. К вершинам мастерства»

Расширяя эту мысль, мы можем уточнить, что цели типизации это:

# Как писать типизированно

Перед тем, как переходить к коду, давайте разберёмся с тремя важными понятиями: интерфейс, абстрактный класс и протокол. В чем разница? Давайте по порядку:

Первые два понятия должны быть понятны. А на последнем давайте остановимся. В Python реализация паттерна интерфейса работает через наследование, то есть, класс, реализующий интерфейс, наследуется от этого класса-интерфейса. При этом класс, который реализует протокол, не наследуется от него и вообще может о нем не знать.

## Примитивы

Давайте наконец посмотрим на то, как же писать типизированный код. Начнем с простейшего примера функции, которая мультиплицирует переданную ей строку $n$ раз.

def multi_string(string, n):
    return string * n

print(multi_string("cat", 3)) # > catcatcat

Это простейшая функция, которая принимает на вход строку и число, а возвращает строку, полученную путём конкатенации исходной строки с самой собой $n$ раз. Давайте типизируем эту функцию!

def multi_string(string: str, n: int) -> str:
    return string * n

Синтаксис простой: типы для входных параметров мы подписываем через двоеточие, а выходной тип с помощью стрелочки. Теперь наши редакторы кода (IDE) будут подсвечивать для нас ошибку, в случае если мы неправильно передадим входные или неправильно обработаем выходные значения функции:

|700

Таким образом можно проаннотировать все классические примитивы — str, int, bytes, float, Decimal, bool.

## Объединение типов

В реальности, конечно, бывают и более сложные кейсы, когда функция может принимать и работать с разными типами, но все же не любыми. В таких случаях можно использовать объединение типов:

def normalize(data: str | bytes) -> str:
    if isinstance(data, bytes):
        return data.decode("utf-8")
    return data

В примере выше функция normalize принимает на вход либо строку, либо последовательность байт и на выход всегда возвращает строку в кодировке utf-8. Через union (|) можно перечислить любое количество типов, но важно понимать, где это уместно. Если вам хочется написать простыню из объединения десяти типов, стоит хорошенько подумать. О том как такие ситуации разрешаются, мы скажем ниже.

Объединение также можно использовать и в указании типа возвращаемого значения. Но использовать это стоит только для спецификации опциональности. Например:

def parse_int(value: str) -> int | None:
    if not value.isdigit():
        return None
    return int(value)

По сути, тут мы говорим, что результат функции опционален. Она может вернуть int, либо не вернуть его, если что-то пошло не так. Однако, аннотировать возвращаемое значение как int | str или любым другим подобным образом, где мы просто объединяем два совершенно разных типа, считается плохой практикой. Потому что в таком случае непонятно, как обрабатывать результат этой функции. В результате функции мы можем ожидать либо конкретную реализацию, про которую нам точно известны её методы и атрибуты, либо None. Отступление от этого, как правило, будет приводить к усложнению кода.

## Спецификация коллекций

Помимо примитивов мы конечно же можем аннотировать и коллекции. Но лучше не ограничиваться простым data: list, а специфицировать и содержание коллекции тоже. Это можно сделать, используя синтаксис квадратных скобок. Давайте приведём несколько примеров:

def format_user(user: tuple[str, int]) -> str:
    name, score = user
    return f"{name}: {score} points"

def average(values: list[float]) -> float:
    if len(values) == 0:
        return 0
    return sum(values) / len(values)

def total_count(counters: dict[str, int]) -> int:
    return sum(counters.values())

Это, конечно, не покрывает все возможные сценарии, которые могут возникнуть в реальном продакшн коде. В секциях TypedDict, NamedTuple, Dataclass мы расширим наш инструментарий.

## Mapping и MutableMapping

Mapping и MutableMapping — это абстрактные классы для словари-подобных структур. Mapping гарантирует только чтение (ключи, значения, итерацию), а MutableMapping говорит, что объект можно изменять.

from collections.abc import Mapping, MutableMapping

def read_config(cfg: Mapping[str, str]) -> str:
    return cfg["DATABASE_URL"]

def patch_config(cfg: MutableMapping[str, str]) -> None:
    cfg["DEBUG"] = "1"

Если функция только читает данные, указывайте Mapping. Если меняет — MutableMapping. Это маленькая, но важная подсказка для читателя и статического анализатора.

## NamedTuple

Когда нужно описать структуру с фиксированным набором полей и одновременно сохранить поведение кортежа, удобно использовать NamedTuple. Это неизменяемый тип данных, который можно индексировать, но при этом читать поля по именам.

from typing import NamedTuple

class User(NamedTuple):
    id: int
    username: str
    score: int

def print_user(user: User) -> str:
    return f"{user.username} ({user.id}) = {user.score}"

NamedTuple хорошо подходит для компактных структур данных, которые не должны изменяться после создания. Если вам нужны изменяемые поля и более богатое поведение, лучше выбрать dataclass или обычный класс.

## TypedDict

Если же ваша структура — это обычный словарь, но вы хотите типизировать ожидаемые ключи и значения, используйте TypedDict. Такой тип описывает форму словаря и работает только на уровне статического анализа.

from typing import TypedDict

class User(TypedDict):
    id: int
    username: str
    email: str | None

def send_email(user: User) -> None:
    ...

Это особенно полезно, когда данные приходят из JSON или другого динамического источника, но вы хотите строгий контракт по ключам. Если часть ключей опциональна, опишите их явно, чтобы не терять проверку на уровне типов.

## Dataclass

dataclass — это удобный способ описать “контейнер данных” с инициализатором, сравнениями и читаемым repr. Такой класс изменяем, если не указать обратное, и отлично подходит для домена или DTO.

from dataclasses import dataclass

@dataclass
class User:
    id: int
    username: str
    email: str | None = None

def normalize(user: User) -> User:
    user.username = user.username.lower()
    return user

dataclass хорош, когда нужна структура, которая может изменяться, и когда важна ясная модель данных. Если объект должен быть неизменяемым, используйте @dataclass(frozen=True).

## Enum

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

from enum import Enum

class Status(Enum):
    NEW = "new"
    DONE = "done"
    FAILED = "failed"

def is_done(status: Status) -> bool:
    return status is Status.DONE

Такой подход удобен для статусов, ролей, флагов, режимов работы, то есть любого “перечислимого” доменного значения.

## Кастомные классы

Конечно же система типов позволяет специфицировать не только примитивы, но и наши собственные или библиотечные классы. Например:

class User:
    id: int
    username: str
    email: str
    friends: list[User]

def hand_shake(user1: User, user2: User) -> None:
    user1.friends.append(user2)
    user2.friends.append(user1)

## Абстрактные классы

В Python у нас существует прекрасный модуль collections.abc. Там уже описан ряд абстрактных классов, которые в 90% случаев закроют все наши потребности. Они полезны, когда вы хотите описывать поведение, а не конкретную реализацию. Итак, что же там представлено? А вот что:

collections.abc.ABCMeta
collections.abc.AsyncGenerator
collections.abc.AsyncIterable
collections.abc.AsyncIterator
collections.abc.Awaitable
collections.abc.Buffer
collections.abc.ByteString
collections.abc.Callable
collections.abc.Collection
collections.abc.Container
collections.abc.Coroutine
collections.abc.EllipsisType
collections.abc.FunctionType
collections.abc.Generator
collections.abc.GenericAlias
collections.abc.Hashable
collections.abc.ItemsView
collections.abc.Iterable
collections.abc.Iterator
collections.abc.KeysView
collections.abc.Mapping
collections.abc.MappingView
collections.abc.MutableMapping
collections.abc.MutableSequence
collections.abc.MutableSet
collections.abc.Reversible
collections.abc.Sequence
collections.abc.Set
collections.abc.Sized
collections.abc.ValuesView

Как мы видим тут большое количество абстрактных классов. Большинство из них созданы для описания какого-то свойства коллекции. Аннотируя с их помощью код, мы можем выражать более широкие полиморфные границы наших функций. Например:

from collections.abc import Iterable

def total(values: Iterable[int]) -> int:
    return sum(values)

total([1, 2, 3])
total((1, 2, 3))
total({1, 2, 3})

Иногда можно встретить такие же импорты из модуля typing: from typing import Iterable, Sequence. Но в реальности это просто реэкспорт реализаций из collections.abc. Поэтому сейчас лучше импортировать абстрактные классы напрямую из collections.abc.

## Sequence и Iterable

Эти два типа часто путают. Iterable гарантирует только то, что объект можно перебирать в цикле. Никаких индексов, длины или упорядоченности тут не обещается. Sequence же, кроме возможности итерации, гарантирует наличие индексации и длины, то есть __getitem__ и __len__. Из этого вытекает разница в том, что можно безопасно делать с объектом.

from collections.abc import Iterable, Sequence

def sum_any(values: Iterable[int]) -> int:
    total = 0
    for v in values:
        total += v
    return total

def head(values: Sequence[int]) -> int:
    return values[0]

Функция sum_any примет и список, и кортеж, и генератор. Функция head уже не сможет принять генератор, потому что у него нет индексации, и мы не можем написать values[0]. Поэтому, когда вам важно только итерироваться — используйте Iterable, а если вы опираетесь на индексацию или длину — Sequence.

## Callable

Callable используется, когда функция принимает другую функцию. Это особенно важно для коллбеков, обработчиков событий и функций высшего порядка.

from collections.abc import Callable

def apply(values: list[int], fn: Callable[[int], int]) -> list[int]:
    return [fn(v) for v in values]

Если сигнатура заранее неизвестна, можно использовать Callable[..., ReturnType], но это стоит делать как крайний вариант.

## Дженерики (generics)

Generics — это параметризованные обобщённые типы. Проще говоря, это типы, которые сами принимают типы. Это важно, когда вы хотите сохранить связь между входом и выходом, а не потерять её в Any. Например, функция first возвращает элемент того же типа, что и внутри переданной коллекции:

from collections.abc import Sequence
from typing import TypeVar

T = TypeVar("T")

def first(items: Sequence[T]) -> T:
    return items[0]

Без дженериков нам пришлось бы писать Sequence[Any] и терять тип результата. А с дженериками анализатор знает, что если мы передали Sequence[str], то вернётся str. Это особенно важно для коллекций, фабрик и репозиториев, где один и тот же код обрабатывает разные типы.

Также полезно знать, что у TypeVar есть параметр bound, который позволяет ограничить те типы, которые могут попадать в дженерик:

from collections.abc import Hashable, Iterable
from typing import TypeVar

HashableT = TypeVar("HashableT", bound=Hashable)
def mode(data: Iterable[HashableT]) -> HashableT:
    ...

## Literal

Literal позволяет зафиксировать конкретные допустимые значения, а не просто базовый тип. Это полезно, когда у параметра есть закрытый набор режимов, статусов или ключей.

from typing import Literal

def export_report(format: Literal["csv", "json"]) -> bytes:
    ...

Сигнатура выше сразу задаёт контракт: третьего формата тут нет. Это делает API понятнее и позволяет анализатору ловить опечатки вроде "jsno" до запуска.

## Статические анализаторы

И наконец пришло время познакомиться с тем, кто “оживляет” типизацию в Python: со статическими анализаторами. Они читают аннотации, сопоставляют их с кодом и подсвечивают ошибки до runtime.

Установить и запускать их можно через uv:

uv tool install mypy
uv tool install pyright
uv tool install pyrefly
uv tool install ty
uvx mypy .
uvx pyright .
uvx pyrefly check
uvx ty check

Ниже бизнес-пример с намеренными ошибками типизации:

from dataclasses import dataclass
from typing import NewType

UserId = NewType("UserId", int)

@dataclass
class User:
    id: UserId
    email: str
    is_active: bool

def discount(total: int, percent: int) -> int:
    return total - total * (percent / 100)

def send_invoice(user: User, amount: int) -> str:
    if not user.is_active:
        return None
    return f"invoice for {user.email}: {amount}"

def main() -> None:
    user = User(id=42, email=123, is_active="yes")
    total = discount("1000", 10)
    send_invoice(user, "500")

Проверка pyright даст сообщения примерно такого вида:

error: Type "float" is not assignable to return type "int"
error: Type "None" is not assignable to return type "str"
error: "Literal[42]" is not assignable to "UserId"
error: "Literal[123]" is not assignable to "str"
error: "Literal['yes']" is not assignable to "bool"
error: "Literal['1000']" is not assignable to "int"
error: "Literal['500']" is not assignable to "int"

Что важно в этом выводе:

## Stub файлы (.pyi)

Иногда хочется типизировать код, к которому нельзя или неудобно вносить изменения. Например, это сгенерированный код, код сторонней библиотеки или даже ваш собственный модуль, где вы не хотите мешать типы с реализацией. Для этого существуют stub файлы — файлы с расширением .pyi.

Файл .pyi содержит только типовые сигнатуры и не содержит реализации. Статические анализаторы ищут их рядом с кодом или в отдельных пакетах types-*. Пример:

calc.py:

def add(a, b):
    return a + b

calc.pyi:

def add(a: int, b: int) -> int: ...

Так вы можете поддерживать типизацию отдельно от реализации, а иногда даже без доступа к исходникам.

## TypeAlias

Когда тип становится сложным, его лучше вынести в алиас, чтобы сигнатуры были читаемыми. Это особенно полезно для бизнес-терминов вроде UserId, Currency, Payload. Для этого используют TypeAlias:

from typing import TypeAlias

UserId: TypeAlias = int
Payload: TypeAlias = dict[str, str | int | float]

Теперь можно писать:

def send(user_id: UserId, payload: Payload) -> None:
    ...

В Python 3.12+ для того же есть синтаксис type:

type UserId = int
type Payload = dict[str, str | int | float]

Это всё тот же алиас, просто короче и читабельнее.

## TypeAlias vs NewType

TypeAlias — это просто синоним типа, он не создаёт новый тип. NewType же создаёт новый тип на уровне статической проверки, хотя на runtime это всего лишь функция-обёртка. Это полезно, когда у вас есть логически разные значения одного базового типа, например UserId и OrderId.

from typing import NewType, TypeAlias

UserId: TypeAlias = int
OrderId = NewType("OrderId", int)

def get_user(user_id: UserId) -> None:
    ...

def get_order(order_id: OrderId) -> None:
    ...

UserId и int считаются одним и тем же типом, а вот OrderId уже не совместим с int без явного приведения.

# Как правильно использовать типизацию

Сразу хочу сформировать у вас верную предпосылку относительно типизации. Точно так же как и у тестов, задача у типов падать, не проходить, крашиться и бесить этим нас. Если они этого не делают, их можно просто выкинуть. Это означает, что чем менее снисходительна к нам система типизации, тем лучше для нас. Потому что это заставляет нас думать о том, как писать надёжный и безопасный код. Строгость системы типизации (как и у тестов) помогает выявлять ошибки, но для этого они должны не проходить.

И да, писать действительно корректно типизированный код сложно. Это отдельный навык, который развивается так же, как архитектура или тестирование. Поэтому нормально начинать с малого и постепенно усиливать строгость.

## Возвращение None

Также обратите внимание на случай, когда функция ничего не возвращает:

def print_weather(weather: Weather):
    print("Weather:")
    for date, data in weather.by_days().items():
        print(date)
        print(f"\t{data.temperature}")
        print(f"\t{data.humidity}")
        print(f"\t{data.wind_speed}")
        print("========")

tmp = print_weather(Weather()) + 1

Как известно в Python, если в функции не стоит return, значит она ничего не возвращает. Об этом знают и статические анализаторы. Например, pyright для этого кода, где намеренно в последней строчке допущена ошибка, даст следующее предупреждение:

error:
    Operator "+" not supported for types "None" and "Literal[1]"

По этому сообщению становится ясно, что pyright “понимает”, что возвращаемый тип функции None. Так значит можно их не подписывать? Нет, не значит. Во-первых, тут можно просто сослаться на PEP 20, “явное лучше неявного”. Во-вторых, стоит учесть специфику developer experience (DX) при разработке на Python. Все, кто пишут на Python, знают, что типизация опциональна. И это создаёт двусмысленность: когда я смотрю на сигнатуру функции, где не специфицировано возвращаемое значение, я не понимаю, это автор кода просто не стал её типизировать и на самом деле она возвращает не None, либо там действительно возвращается None. Разрешается эта двусмысленность только погружением в детали реализации функции, что усложняет работу с кодом.

## Вход и выход у функции

Продолжим тему возвращаемых значений. Не стоит прибегать к использованию абстрактных классов из модуля collections.abc, а также реализованных самостоятельно, для указания возвращаемого значения из функции. Они предназначены для входных значений, чтобы сохранить полиморфность функции в максимально широких границах. Возвращаемое значение должно быть специфицировано конкретной реализацией, чтобы было ясно, как обрабатывать возвращаемый результат.

Important

Функция должна более ясно говорить о том, какой конкретный тип она возвращает, чем принимает.




Если вы хотите углубиться в тему, ниже — отличные материалы, которые помогут настроить инструменты и разобраться в деталях типовой системы Python.