# Глава 33. Аннотации типов
Аннотации типов — это возможность указывать типы при объявлении переменных, полей класса, параметров и возвращаемых значений функций... Типизация в питоне динамическая: интерпретатор получает информацию о типах не во время компиляции, а лишь во время выполнения. Так что аннотации типов — не более чем подсказки для разработчиков, IDE и статических анализаторов кода, таких как mypy.
Несмотря на это аннотации типов все чаще становятся неотъемлемой частью код-стайла в крупных проектах. Ведь они повышают читабельность кода и помогают уберечься от досадных ошибок.
## Как работают аннотации типов
Аннотации не дают никакой гарантии, что в переменную будет записано значение указанного типа. Зато во многих случаях это обнаружит статический анализатор и сгенерирует предупреждение. Не удивительно, что во многих проектах анализ кода встроен в [CI](https://en.wikipedia.org/wiki/Continuous_integration) и запускается автоматически.
Поэтому хоть аннотации типов являются встроенным функционалом питона и для их использования не требуется никаких сторонних библиотек и утилит, рассматривать мы их будем в связке с [mypy.](https://mypy-lang.org/) Только обязательное использование статического анализатора позволит в полной мере раскрыть пользу от внедрения в проект статической типизации.
Связывание переменной с типом строится на простых правилах:
- Типы переменных и параметров указываются после двоеточия `:`. Например, запись `res = calc()` превращается в `res: float = calc()`.
- Типы возвращаемых значений указываются после стрелочки `->`, например `-> str`. Если функция ничего не возвращает, это прописывается явно с помощью `-> None`.
- В качестве типа объекта можно указать его базовый класс. Тогда переменной можно присвоить его наследников, но использовать для них только функционал, определенный в базовом классе. Например, нельзя вызвать метод, отсутствующий в базовом классе.
Анализатор mypy можно установить через менеджер пакетов pip:
```shell
python3 -m pip install mypy
```
Сохраним в скрипт `example.py` простой пример использования аннотаций типов:
```python
def format(val: float) -> str:
return f"{val=:.2f}"
print(format(2.009))
```
Проверим файл `example.py` через mypy. Для этого запустим `mypy` из консоли:
```
$ mypy example.py
Success: no issues found in 1 source file
```
А теперь вместо ожидаемого значения типа `float` передадим в функцию строку:
```python
def format(val: float) -> str:
return f"{val=:.2f}"
print(format("value"))
```
При несовпадении типов mypy сгенерирует ошибку:
```
example.py:4: error: Argument 1 to "format" has incompatible type "str"; expected "float" [arg-type]
Found 1 error in 1 file (checked 1 source file)
```
Если при вызове mypy задать флаг `--strict`, то будут включены все возможные опциональные проверки. Они гарантируют, что если в процессе выполнения кода возможно какое-то несовпадение типов, mypy о них сообщит. При этом будьте готовы к ложным срабатываниям.
Даже если тип переменной не указан, с помощью анализа [AST](https://en.wikipedia.org/wiki/Abstract_syntax_tree) во многих случаях mypy способен его вывести самостоятельно. Так, в этом тривиальном примере аннотация считается избыточной:
```python
x: int = 1
l: list[int] = [1, 2]
```
Но если мы заводим **пустую** коллекцию, то без явного указания типа `mypy` не сможет определить, какие элементы в нее попадут:
```python {.example_for_playground}
d: dict[str, float] = {}
s: set[str] = set()
```
## Аннотации для встроенных типов
Аннотация для встроенного типа — это просто имя типа:
```python {.example_for_playground}
x: bool = True
val: int = 2
a: float = 5.01
s: str = "ABC"
```
В аннотации коллекций после типа коллекции в скобках можно указать тип элементов:
```python {.example_for_playground}
l: list[str] = ["A", "B"]
s: set[int] = {105, -9}
d: dict[int, bool] = {}
```
Не является ошибкой и укзание типа коллекции без типа содержащихся в ней элементов:
```python {.example_for_playground}
l: list = ["A", "B"]
```
Добавьте аннотации типов вместо их описания в комментариях. Исправьте код, который нарушает аннотации. {.task_text}
```python {.task_source #python_chapter_0330_task_0010 .run_static_type_checker}
class Storage():
def __init__(self, message_size_limit):
# message_size_limit must be int
self._message_size_limit = message_size_limit
# mapping of message id to message
self._storage = {}
def save_message(self, message_id, message):
# message_id must be int
self._storage[message_id] = message
def get_message(self, message_id):
return self._storage[message_id]
s = Storage("9")
s.save_message("8", "message")
s.get_message(8)
```
Не забудьте указывать типы для возвращаемых методами значений. {.task_hint}
```python {.task_answer}
class Storage():
def __init__(self, message_size_limit: int) -> None:
self._message_size_limit = message_size_limit
# mapping of message id to message
self._storage: dict[int, str] = {}
def save_message(self, message_id: int, message: str) -> None:
self._storage[message_id] = message
def get_message(self, message_id: int) -> str:
return self._storage[message_id]
s = Storage(9)
s.save_message(8, "message")
s.get_message(8)
```
В аннотации кортежа перечисляются типы всех его элементов:
```python
def f(t: tuple[int, float, str]) -> None:
...
```
Однако в некоторых случаях длина кортежа неизвестна. Например, если кортеж подается на вход функции. Тогда вместо поэлементного перечисления типов используется многоточие `...`. В данном примере функция принимает кортеж произвольной длины, заполненный целыми числами:
```python
def f(t: tuple[int, ...]) -> None:
...
```
Добавьте аннотации типов в функцию. {.task_text}
```python {.task_source #python_chapter_0330_task_0020 .run_static_type_checker}
def word_count(lines):
result = {}
for line in lines:
for word in line.split():
result[word] = result.get(word, 0) + 1
return result
```
Не забудьте указать тип для переменной `result`. {.task_hint}
```python {.task_answer}
def word_count(lines: list[str]) -> dict[str, int]:
result: dict[str, int] = {}
for line in lines:
for word in line.split():
result[word] = result.get(word, 0) + 1
return result
```
Как быть, если одной переменной могут быть присвоены значения разных типов? Например, если список содержит целые числа и строки. Тогда возможные типы перечисляются через символ `|`:
```python
l: list[int | str]
```
Частный случай — переменная, которая может быть `None`:
```python
x: list[int | None]
```
## Модуль typing
Модуль `typing` содержит множество подсказок о типах, среди которых:
- `Optional`: тип переменной, которая может принимать значение `None`. Например, `Optional[int]`. Может использоваться вместо синтаксиса `T | None`.
- `Any`: произвольный тип.
- `Literal`: перечисление допустимых значений для переменной. Например, `Literal["retry", "abort"]`.
- `Protocol`: [протокол,](/courses/python/chapters/python_chapter_0170#block-protocols) то есть класс, описывающий некоторый интерфейс в традициях утиной типизации.
- `NoReturn`: способ указания результата функции, если функция никогда не возвращает управление.
`Optional` используется для переменных, которые могут становиться `None`:
```python
from typing import Optional
x: Optional[str] = "val" if some_check() else None
```
Для обозначения объектов произвольного типа предназначено определение `Any`:
```python
from typing import Any
obj: Any = some_magic()
```
Конечно, вместо `Any` можно было бы указать тип `object`, потому что он базовый вообще для всех объектов. Но, во-первых, `Any` более явно выражает намерение подчеркнуть, что тип объекта неизвестен или не важен. Во-вторых, если переменной задать тип `object`, то и работать с ней можно только как с экземпляром `object`. Иначе статические анализаторы выдадут ошибку типов.
Определение `Literal` нужно для проверки соответствия значения переменной одному из фиксированных литералов.
```python
from typing import Literal
ENDPOINTS = Literal["/search", "/suggest"]
def get_endpoint_rps(endpoint: ENDPOINTS) -> dict[ENDPOINTS, int]:
return {endpoint: 3000}
print(get_endpoint_rps("/search"))
```
Разумеется, в качестве литералов можно перечислять не только строки:
```python
def validate_simple(data: Any) -> Literal[True]:
return True
```
Использование `Protocol` мы [подробно рассматривали](/courses/python/chapters/python_chapter_0170#block-protocols) в главе про полиморфизм, поэтому здесь останавливаться на нем не будем.
Определение `NoReturn` указывается для возвращаемого значения, если функция никогда не возвращает управление. Например, она в вечном цикле обрабатывает соединения либо вызывает `sys.exit()`.
```python
from typing import NoReturn
def f() -> NoReturn:
while True:
...
```
Добавьте аннотации типов в код. {.task_text}
```python {.task_source #python_chapter_0330_task_0030 .run_static_type_checker}
import sys
def get_tuple(arg = None):
if arg is None:
return 1, 2
if arg == 0:
return 2, 3
return 3, 2
def get_false():
# Always returns false
return False
def shutdown():
sys.exit(0)
def format(a, b, c):
# 'a' is int
# 'b' is bool
# we don't know 'c' type
return f"{a=} {b=} {c=}"
```
Вам пригодятся аннотации `Any`, `Literal`, `NoReturn`, `Optional` из модуля `typing`. {.task_hint}
```python {.task_answer}
import sys
from typing import Any, Literal, NoReturn, Optional
def get_tuple(arg: Optional[int] = None) -> tuple[int, int]:
if arg is None:
return 1, 2
if arg == 0:
return 2, 3
return 3, 2
def get_false() -> Literal[False]:
# Always returns false
return False
def shutdown() -> NoReturn:
sys.exit(0)
def format(a: int, b: bool, c: Any) -> str:
# 'a' is int
# 'b' is bool
# we don't know 'c' type
return f"{a=} {b=} {c=}"
```
## Модуль collections.abc
В модуле `collections.abc` содержатся [определения](https://docs.python.org/3/library/collections.abc.html#collections-abstract-base-classes) для обобщенной (generic) аннотации типов.
Наиболее распространенные из них:
- `Callable`: функция или другой вызываемый объект, у которого определен dunder-метод `__call__()`.
- `Mapping`: объект, хранящий пары ключ-значение, у которого есть метод `__getitem__()`.
- `MutableMapping`: изменяемый объект для хранения пар ключ-значение. У него должен быть определен метод `__setitem__()`.
- `Sequence`: последовательность элементов с доступом по индексу. Должна поддерживать методы `__len__()` и `__getitem__()`.
- `Iterable`: [итерабельный объект,](/courses/python/chapters/python_chapter_0230/) то есть любой объект, по которому можно пройтись циклом `for`.
- `Iterator`: [итератор,](/courses/python/chapters/python_chapter_0230/) то есть объект, поддерживающий протокол итератора (методы `__iter__()` и `__next__()`).
Пример аннотации для функции, которая принимает функцию и итерабельный объект и вызывает для них [встроенную функцию](/courses/python/chapters/python_chapter_0280#block-filter) `filter()`:
```python {.example_for_playground}
from collections.abc import Callable, Iterable, Iterator
def filter_vals(
check_data: Callable[[int], bool], data: Iterable[int]
) -> Iterator[int]:
return filter(check_data, data)
print(list(filter_vals(lambda x: x > 0, [-1, 3, -2, 8, 9])))
```
```
[3, 8, 9]
```
Здесь для функции мы использовали аннотацию `Callable[[int], bool]`, то есть указали, что функция принимает единственный параметр типа `int` и возвращает тип `bool`. Если сигнатура функции не важна, можно писать просто `Callable`.
Модуль `collections.abc` позволяет типизировать обобщенный код. Например, вы написали функцию, которая принимает последовательность и считает по ее элементам какую-то статистику. Функция корректно отработает и для строки, и для списка, и для кортежа. Поэтому для параметра функции подойдет тип `Sequence`, а для результирующей статистики например тип `Mapping`.
Добавьте аннотации типов в код. {.task_text}
```python {.task_source #python_chapter_0330_task_0040 .run_static_type_checker}
def format(d):
return (f"{k}-{v}" for k, v in d.items())
def modify(d):
for k, v in d.items():
if v is not None and v < 0:
print("Modifying key:", k)
d[k] = None
```
Вам пригодятся аннотации `Mapping`, `MutableMapping` и `Iterable` из модуля `collections.abc` и `Any` из `typing`. {.task_hint}
```python {.task_answer}
from collections.abc import Mapping, MutableMapping, Iterable
from typing import Any
def format(d: Mapping[Any, Any]) -> Iterable[str]:
return (f"{k}-{v}" for k, v in d.items())
def modify(d: MutableMapping[int, int | None]) -> None:
for k, v in d.items():
if v is not None and v < 0:
print("Modifying key:", k)
d[k] = None
```
## Игнорирование типов в mypy
Комментарий с текстом `# type: ignore` используется, чтобы подавить ошибки mypy для конкретных строк. Хорошим тоном считается после него оставить комментарий, поясняющий, почему в данном месте следует опустить проверку типов.
```python
x = some_magic() # type: ignore # some_magic() won't return None here because ...
```
## Классы данных
Начиная с питона 3.7 программистам стало проще заводить классы со множеством публичных полей: появился декоратор `@dataclass` из модуля `dataclasses`. Рассмотрим на примере, какую проблему он решает.
Так выглядит типичное объявление класса с открытыми полями:
```python {.example_for_playground}
class UserLocation:
def __init__(self, lat, lon, ts, device_id):
self.lat = lat
self.lon = lon
self.ts = ts
self.device_id = device_id
loc = UserLocation(56.2, 34.0, 1725702413, "8d38823b97b66e9")
print(loc)
```
```
<__main__.UserLocation object at 0x7f06923ae5d0>
```
Имена `lat`, `lon`, `ts`, `device_id` повторяются в сигнатуре инициализатора в и его теле. Так как мы не определили ни один [из методов для строкового представления объекта,](/courses/python/chapters/python_chapter_0180/#block-str-repr) вывод в консоли не информативен. Но даже такой минималистичный пример выглядит многословно.
Декоратор `@dataclass` берет на себя генерирование кода инициализатора и нескольких других dunder-методов, среди которых `__eq__()` и `__repr__()`.
Превратим класс `UserLocation` в класс данных:
```python {.example_for_playground}
from dataclasses import dataclass
@dataclass
class UserLocation:
lat: float
lon: float
ts: int
device_id: str
loc = UserLocation(56.2, 34.0, 1725702413, "8d38823b97b66e9")
print(loc)
```
```
UserLocation(lat=56.2, lon=34.0, ts=1725702413, device_id='8d38823b97b66e9')
```
Код стал компактнее, а консольный вывод — полезнее (причем без усилий с нашей стороны). В определении полей класса участвуют аннотации типов. Для полей класса данных они **обязательны.**
У полей класса данных могут быть значения по умолчанию:
```python {.example_for_playground}
from dataclasses import dataclass
@dataclass
class Message:
msg_id: int
text: str = ""
print(Message(545))
```
```
Message(msg_id=545, text='')
```
Помимо определения полей класс данных может содержать любые вспомогательные методы. Их определение ничем не отличается от определения в обычном классе.
Потренироваться с заведением класса данных вы сможете в практическом задании после этой главы.
## Резюмируем
- Аннотации типов нужны для повышения читабельности кода, для подсказок от IDE и проверки статическими анализаторами. Они никак не влияют на рантайм. Интерпретатор их пропускает. В этом правиле есть исключение: аннотации типов в объявлении полей класса данных обязательны.
- Аннотация для переменной, поля или параметра функции указывается через двоеточие: `x : int`. Аннотация для возвращаемого значения — после стрелочки: `def f() -> None`.
- Модуль `typing` содержит подсказки о типах, например `Any`, `Optional`, `NoReturn`.
- Модуль `collections.abc` содержит подсказки для типов коллекций.
- В модуле `dataclasses` есть декоратор `@dataclass` для создания классов данных.
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!