$ sudo teach IT
МОДУЛЬ 10 · УРОК 3

Аннотации типов (type hints) в классах и методах

Вы уже умеете документировать код через docstring. Теперь узнаем ещё один мощный инструмент — аннотации типов. Они позволяют прямо в коде указывать, какого типа должны быть параметры и что возвращает функция. IDE и инструменты используют это для поиска ошибок ещё до запуска программы.

⏱ ~40 минут 🎓 Для новичков 🐍 Python 3

🤔 Что такое аннотации типов?

Представьте, что вы видите такую функцию впервые:

def process(data, limit):
    ...

Что такое data? Строка? Список? Словарь? Что такое limit? Число? Булево значение? Непонятно. Приходится читать весь код функции, чтобы догадаться.

Теперь сравните с этой версией:

def process(data: list[str], limit: int) -> bool:
    ...

Сразу ясно: data — список строк, limit — целое число, функция возвращает булево значение. Это и есть аннотации типов (их также называют type hints — «подсказки типов»).

⚡ Самое важное — запомните это навсегда!

Аннотации типов в Python — это только подсказки. Python НЕ проверяет их во время выполнения программы (runtime). Вы можете написать x: int = "hello" — и Python не выдаст ошибку. Аннотации существуют для людей (читаемость кода) и для инструментов (IDE, анализаторы кода).

Аннотации типов появились в Python 3.5 (PEP 484) и с каждой версией становятся удобнее. Сегодня они — стандарт в профессиональном Python-коде.

📝 Базовый синтаксис аннотаций

Синтаксис простой. Разберём его по частям:

📥

Параметр функции

name: тип

После имени параметра ставится двоеточие, затем тип.

📤

Возвращаемое значение

-> тип:

После скобок с параметрами, перед двоеточием.

🏷️

Атрибут класса

name: тип

В теле класса или в __init__.

Смотрим на живой пример с базовыми типами:

# Базовые типы Python
# int    — целые числа: 1, -5, 100
# float  — числа с плавающей точкой: 3.14, -0.5
# str    — строки: "hello", 'world'
# bool   — булево значение: True, False
# None   — отсутствие значения (функция ничего не возвращает)

def greet(name: str) -> str:
    """Возвращает приветствие для пользователя."""
    return f"Привет, {name}!"

def add(a: int, b: int) -> int:
    """Складывает два целых числа."""
    return a + b

def calculate_area(radius: float) -> float:
    """Вычисляет площадь круга по радиусу."""
    return 3.14159 * radius ** 2

def is_adult(age: int) -> bool:
    """Проверяет, является ли человек совершеннолетним."""
    return age >= 18

def print_message(message: str) -> None:
    """Выводит сообщение. Ничего не возвращает."""
    print(message)

# Использование — всё как обычно
print(greet("Алиса"))       # Привет, Алиса!
print(add(3, 5))             # 8
print(is_adult(20))          # True

💡 Про -> None: когда функция ничего не возвращает (только выполняет действие), принято явно указывать -> None. Это сигнализирует читателю: «эта функция что-то делает, но не возвращает результат».

🔬 Демонстрация: Python игнорирует аннотации в runtime

Это очень важно понять. Запустим код, который «нарушает» аннотации:

def add(a: int, b: int) -> int:
    return a + b

# «Нарушаем» аннотации — передаём строки вместо чисел
result = add("hello", " world")
print(result)       # hello world  — работает! Нет ошибки!
print(type(result)) # <class 'str'>

# Ещё пример
def greet(name: str) -> str:
    return f"Привет, {name}!"

greet(12345)   # Тоже работает! Python не ругается.
greet([1, 2])  # И это работает!

# Аннотации можно посмотреть через __annotations__
print(add.__annotations__)
# {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
# Они просто хранятся как словарь — Python их не использует сам

🤷 Зачем тогда они нужны, если Python их не проверяет?

  • IDE (PyCharm, VS Code) — подчёркивает ошибки прямо в редакторе и подсказывает автодополнение
  • mypy — отдельный инструмент, который проверяет типы до запуска программы (статический анализ)
  • Документация — коллеги сразу видят, что ожидает функция, без чтения её тела
  • Рефакторинг — при переименовании или изменении типа IDE находит все места, где нужно обновить код

📦 Модуль typing: Optional, Union и другие

Базовых типов (int, str, float) часто недостаточно. Что если параметр может быть числом или строкой? Или может быть None? Для таких случаев в Python есть модуль typing.

Optional[X]

«Либо X, либо None». Самый частый случай — параметр со значением по умолчанию None.

Union[X, Y]

«Либо X, либо Y». Когда параметр может быть одним из нескольких типов.

List[X]

Список элементов типа X. Например, List[int] — список целых чисел.

Dict[K, V]

Словарь с ключами типа K и значениями типа V.

Tuple[X, Y]

Кортеж с фиксированной структурой. Tuple[int, str] — пара (число, строка).

Any

Любой тип. Анализаторы не проверяют этот параметр. Используйте как последнее средство.

from typing import Optional, Union, List, Dict, Tuple

# Optional[str] означает: строка ИЛИ None
# Это то же самое, что Union[str, None]
def find_user(user_id: int) -> Optional[str]:
    """Ищет пользователя по ID. Возвращает имя или None если не найден."""
    users = {1: "Алиса", 2: "Боб"}
    return users.get(user_id)  # get() возвращает None если ключ не найден

print(find_user(1))   # Алиса
print(find_user(99))  # None

# Union — параметр может быть int или float
def double(value: Union[int, float]) -> Union[int, float]:
    """Удваивает число (работает и с int, и с float)."""
    return value * 2

print(double(5))    # 10
print(double(2.5))  # 5.0

# List[str] — список строк
def format_names(names: List[str]) -> str:
    """Объединяет список имён через запятую."""
    return ", ".join(names)

print(format_names(["Алиса", "Боб", "Карл"]))  # Алиса, Боб, Карл

# Dict[str, int] — словарь где ключи — строки, значения — числа
def total_score(scores: Dict[str, int]) -> int:
    """Суммирует все очки в словаре."""
    return sum(scores.values())

print(total_score({"Алиса": 10, "Боб": 15}))  # 25

# Tuple[str, int] — кортеж (имя, возраст)
def describe_person(person: Tuple[str, int]) -> str:
    name, age = person
    return f"{name}, {age} лет"

print(describe_person(("Алиса", 30)))  # Алиса, 30 лет

🏗️ Аннотации типов в классах

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

from typing import Optional, List

class Student:
    # Аннотации атрибутов класса — на уровне класса (без значений)
    # Это «объявление» атрибутов — IDE знает, что они будут
    name: str
    age: int
    grade: float
    email: Optional[str]  # email может быть не указан

    def __init__(self, name: str, age: int, grade: float,
                 email: Optional[str] = None) -> None:
        # Аннотации атрибутов через self — IDE подхватывает тип
        self.name = name
        self.age = age
        self.grade = grade
        self.email = email

    def introduce(self) -> str:
        """Возвращает строку-представление студента."""
        return f"Студент {self.name}, {self.age} лет, оценка: {self.grade}"

    def is_excellent(self) -> bool:
        """Проверяет, является ли студент отличником (оценка >= 4.5)."""
        return self.grade >= 4.5

    def update_email(self, new_email: str) -> None:
        """Обновляет адрес электронной почты студента."""
        self.email = new_email

    @staticmethod
    def validate_grade(grade: float) -> bool:
        """Проверяет, что оценка в допустимом диапазоне (0.0 — 5.0)."""
        return 0.0 <= grade <= 5.0


class Group:
    name: str
    students: List[Student]  # список объектов типа Student

    def __init__(self, name: str) -> None:
        self.name = name
        self.students = []

    def add_student(self, student: Student) -> None:
        """Добавляет студента в группу."""
        self.students.append(student)

    def get_average_grade(self) -> float:
        """Возвращает среднюю оценку по группе."""
        if not self.students:
            return 0.0
        total = sum(s.grade for s in self.students)
        return total / len(self.students)

    def find_by_name(self, name: str) -> Optional[Student]:
        """Ищет студента по имени. Возвращает None если не найден."""
        for student in self.students:
            if student.name == name:
                return student
        return None


# Использование
alice = Student("Алиса", 20, 4.8, "alice@example.com")
bob = Student("Боб", 22, 3.7)  # email не указан — None по умолчанию

print(alice.introduce())      # Студент Алиса, 20 лет, оценка: 4.8
print(alice.is_excellent())   # True
print(bob.is_excellent())     # False

group = Group("Группа 101")
group.add_student(alice)
group.add_student(bob)
print(group.get_average_grade())  # 4.25
print(group.find_by_name("Алиса"))  # объект Student
print(group.find_by_name("Карл"))   # None

🎯 Совет: аннотируйте метод __init__ с возвращаемым типом -> None, так как конструктор технически ничего не возвращает. Аннотируйте self не нужно — Python и инструменты знают его тип автоматически.

🚀 Современный синтаксис: Python 3.9+ и 3.10+

До Python 3.9 для аннотирования контейнеров (списков, словарей и т.д.) нужно было импортировать специальные типы из модуля typing. С Python 3.9 это больше не нужно — можно использовать встроенные типы напрямую:

# Старый стиль (Python 3.5 — 3.8) — нужен импорт
from typing import List, Dict, Tuple, Set, Optional, Union

def old_style(names: List[str], scores: Dict[str, int]) -> Tuple[str, int]:
    ...

# Новый стиль (Python 3.9+) — импорт не нужен!
def new_style(names: list[str], scores: dict[str, int]) -> tuple[str, int]:
    ...

# Другие встроенные типы тоже работают:
def process(data: set[int], items: frozenset[str]) -> list[bool]:
    ...

Python 3.10 принёс ещё одно улучшение — оператор | вместо Union и Optional:

# Старый стиль (Python 3.5 — 3.9)
from typing import Optional, Union

def find_user(user_id: int) -> Optional[str]:  # str или None
    ...

def parse(value: Union[int, float, str]) -> str:  # int или float или str
    ...

# Новый стиль (Python 3.10+) — оператор | вместо Optional и Union
def find_user(user_id: int) -> str | None:  # str или None — без Optional!
    ...

def parse(value: int | float | str) -> str:  # через | — без Union!
    ...

# Пример класса с современным синтаксисом
class Library:
    books: list[str]
    catalog: dict[str, int]  # название -> количество экземпляров

    def __init__(self) -> None:
        self.books = []
        self.catalog = {}

    def search(self, query: str) -> list[str] | None:
        """Ищет книги по запросу. Возвращает список или None."""
        results = [b for b in self.books if query.lower() in b.lower()]
        return results if results else None

📊 Таблица: старый vs новый синтаксис

Смысл Python 3.5–3.8 Python 3.9+ Python 3.10+
Список строк List[str] list[str] list[str]
Словарь str→int Dict[str, int] dict[str, int] dict[str, int]
str или None Optional[str] Optional[str] str | None
int или str Union[int, str] Union[int, str] int | str

🔄 Forward references — когда класс ещё не определён

Иногда в аннотации нужно использовать класс, который ещё не был определён в коде. Например, метод класса Node возвращает объект Node, но класс ещё не дочитан Python до конца. Это вызовет ошибку NameError:

# ОШИБКА! Python читает код сверху вниз.
# Когда он доходит до аннотации -> Node,
# класс Node ещё не определён полностью.
class Node:
    def __init__(self, value: int) -> None:
        self.value = value
        self.next: Node = None  # NameError: name 'Node' is not defined

Решение — взять имя класса в кавычки. Тогда Python не пытается найти имя прямо сейчас — он запомнит строку и инструменты разберут её позже:

# РЕШЕНИЕ 1: Имя класса в кавычках (forward reference)
class Node:
    def __init__(self, value: int) -> None:
        self.value = value
        self.next: "Node | None" = None  # Строка — Python не проверяет сразу

    def set_next(self, node: "Node") -> None:
        """Устанавливает следующий узел в цепочке."""
        self.next = node


# РЕШЕНИЕ 2: from __future__ import annotations (Python 3.7+)
# Делает ВСЕ аннотации «ленивыми» — они не вычисляются сразу
from __future__ import annotations

class TreeNode:
    def __init__(self, value: int) -> None:
        self.value = value
        self.left: TreeNode | None = None   # Теперь работает без кавычек!
        self.right: TreeNode | None = None  # Потому что импорт выше

    def insert(self, child: TreeNode) -> None:
        """Добавляет дочерний узел."""
        if self.left is None:
            self.left = child
        else:
            self.right = child


# Использование
root = TreeNode(10)
left = TreeNode(5)
right = TreeNode(15)
root.insert(left)
root.insert(right)
print(root.left.value)   # 5
print(root.right.value)  # 15

💡 Когда возникают forward references?

  • Метод класса принимает или возвращает объект того же класса (рекурсивные структуры: дерево, связный список)
  • Два класса ссылаются друг на друга — тогда один из них ещё не определён при чтении аннотации другого

🔍 mypy — статический анализатор типов

Мы уже знаем, что Python сам не проверяет типы. Но есть специальный инструмент — mypy, — который делает именно это. Он читает ваш код, смотрит на аннотации и сообщает об ошибках до запуска программы.

Представьте mypy как «компилятор типов» — он проверяет ваш код так, как это делает компилятор в C++ или Java, но без реального запуска.

⚙️ Установка и запуск mypy

# Установка через pip
pip install mypy

# Проверка одного файла
mypy my_file.py

# Проверка всего проекта
mypy .

# Строгий режим — проверяет больше вещей
mypy --strict my_file.py
# Файл: example.py

def add(a: int, b: int) -> int:
    return a + b

# Ошибки, которые найдёт mypy:
result = add("hello", "world")   # Ошибка! ожидается int, передана str
result = add(1, 2, 3)            # Ошибка! слишком много аргументов

name: str = 123                  # Ошибка! ожидается str, присвоен int

def greet(name: str) -> int:     # Аннотация говорит -> int
    return "Привет, " + name     # Ошибка! возвращается str, а не int


# Запускаем mypy:
# $ mypy example.py
#
# example.py:5: error: Argument 1 to "add" has incompatible type "str"; expected "int"
# example.py:6: error: Argument 2 to "add" has incompatible type "str"; expected "int"
# example.py:7: error: Too many arguments for "add"
# example.py:9: error: Incompatible types in assignment (expression has type "int", variable has type "str")
# example.py:13: error: Incompatible return value type (got "str", expected "int")
# Found 5 errors in 1 file

✅ Что проверяет mypy?

  • Передаются ли аргументы правильных типов в функции
  • Совпадает ли тип возвращаемого значения с аннотацией
  • Правильно ли используются атрибуты объектов
  • Обрабатывается ли None там, где возвращается Optional

🔧 mypy и None-безопасность

from typing import Optional

def find_user(user_id: int) -> Optional[str]:
    users = {1: "Алиса"}
    return users.get(user_id)

# mypy найдёт ошибку: find_user может вернуть None,
# но мы используем результат без проверки
name = find_user(1)
print(name.upper())   # Ошибка mypy! name может быть None

# Правильно: сначала проверить на None
name = find_user(1)
if name is not None:
    print(name.upper())  # Теперь mypy доволен

🎛️ Callable и Any — аннотации функций и «отключение» проверки

Иногда параметром функции является другая функция. Для таких случаев есть тип Callable. А когда тип совсем непредсказуем — есть Any.

from typing import Callable, Any

# Callable[[типы_аргументов], тип_возврата]
# Callable[[int, int], int] — функция, принимающая два int и возвращающая int
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    """Применяет функцию func к двум числам."""
    return func(a, b)

def multiply(x: int, y: int) -> int:
    return x * y

print(apply(multiply, 3, 4))  # 12
print(apply(lambda x, y: x + y, 10, 5))  # 15

# Callable без указания сигнатуры — просто «любая функция»
def run(callback: Callable) -> None:
    callback()

# Any — отключает проверку типов для этого параметра
# Используйте как последнее средство!
def process_unknown(data: Any) -> Any:
    """Обрабатывает данные неизвестного типа."""
    return data  # mypy не будет проверять data

⚠️ Про Any: использование Any «отключает» проверку типов для этого параметра — mypy перестаёт его анализировать. Злоупотребление Any делает аннотации бесполезными. Применяйте только когда действительно не знаете тип (например, при работе с данными от внешнего API).

✅ Итоги урока

  • Аннотации типов — это подсказки о типах параметров и возвращаемых значений. Синтаксис: param: тип и -> тип.
  • Python НЕ проверяет аннотации в runtime — они существуют для людей, IDE и инструментов (mypy).
  • Базовые типы: int, str, float, bool, None — используются напрямую.
  • Модуль typing даёт Optional, Union, List, Dict, Tuple, Callable, Any.
  • Python 3.9+: можно писать list[str] вместо List[str] — импорт из typing не нужен.
  • Python 3.10+: оператор | вместо Union и Optional: str | None вместо Optional[str].
  • Forward reference: если класс ещё не определён — берём его имя в кавычки: "ClassName".
  • mypy — статический анализатор, который проверяет типы до запуска. Установка: pip install mypy, запуск: mypy файл.py.

👉 В следующем уроке мы изучим dataclasses — специальный декоратор Python, который автоматически генерирует __init__, __repr__, __eq__ и другие методы на основе аннотаций типов. Аннотации типов там будут использоваться «по-настоящему» — dataclasses используют их для создания кода!