Перейти к содержанию
Educora
Продвинутый22 мин18 / 42

Аннотации типов и dataclasses

Типы прямо в коде: `list[int]`, `int | None`, `Callable`, `Literal`, обобщённые типы и `Protocol`, проверка через mypy, а также `@dataclass`, `field`, `frozen`, `order` и `slots`.

Проверь себя
В этом уроке ты узнаешь
  • Аннотировать функции и переменные и объяснять, что аннотации не проверяются во время выполнения
  • Описывать сложные типы с помощью Callable, Literal, обобщённых функций и Protocol
  • Писать класс данных с @dataclass, field(default_factory=...) и __post_init__
  • Уместно выбирать параметры frozen, order и slots

Посмотри на def price(total, discount): discount — это 0,1 или 10? Может ли total быть None? В маленьком скрипте ты это помнишь, а в проекте на 50 000 строк, над которым работают пять программистов, — нет. Аннотации типов записывают ответ прямо в код: def price(total: float, discount: float) -> float. Редакторы используют их для автодополнения, а анализаторы вроде mypy находят ошибки ещё до запуска программы. Dataclasses построены на тех же аннотациях и пишут скучные части класса за тебя.

Аннотации функций и переменных

Аннотация пишется после двоеточия, а тип возвращаемого значения — после стрелки ->. Начиная с Python 3.9 встроенные коллекции можно параметризовать напрямую: list[int], dict[str, float], tuple[int, int], set[str]. Начиная с Python 3.10 объединение типов записывается через |: int | None означает целое число или None. Аннотировать можно и переменные: count: int = 0.

Python
def average(scores: list[float], precision: int = 1) -> float:
    return round(sum(scores) / len(scores), precision)

print(average([4.5, 5, 3.5]))
print(average.__annotations__)
print(average((1, 2)))
▸ Ожидаемый результат
4.3
{'scores': list[float], 'precision': <class 'int'>, 'return': <class 'float'>}
1.5

Последняя строка важна: кортеж (1, 2) был принят, хотя в аннотации указано list[float]. Python не проверяет аннотации во время выполнения. Они хранятся в __annotations__ и читаются инструментами. Чтобы действительно находить ошибки, запусти статический анализатор типов:

Terminal
$ mypy shop.py
shop.py:12: error: Argument 1 to "average" has incompatible type "str"; expected "list[float]"  [arg-type]
Found 1 error in 1 file (checked 1 source file)
mypy устанавливается командой pip install mypy и анализирует файл, не запуская его.

Инструменты модуля typing

  • Callable[[int], int] — функция, которая принимает int и возвращает int.
  • Literal['r', 'w'] — допустимы только именно эти значения.
  • type Vector = list[float] (Python 3.12+) — псевдоним типа: короткое имя для длинного типа.
  • def first[T](items: list[T]) -> T (Python 3.12+) — обобщённая функция: T — переменная типа, поэтому для list[str] результат будет str. В старых версиях это пишут через T = TypeVar('T').
  • Any — отключает проверку; используй его как можно реже.
Python
from typing import Callable, Literal

type Vector = list[float]
Mode = Literal['r', 'w']

def scale(v: Vector, k: float) -> Vector:
    return [k * x for x in v]

def first[T](items: list[T], default: T | None = None) -> T | None:
    return items[0] if items else default

def apply(func: Callable[[int], int], value: int) -> int:
    return func(value)

def describe(mode: Mode) -> str:
    return 'read' if mode == 'r' else 'write'

print(scale([1.0, 2.5], 2))
print(first(['Aysel', 'Murad']), first([], default='nobody'))
print(apply(lambda n: n * n, 7))
print(describe('w'))
▸ Ожидаемый результат
[2.0, 5.0]
Aysel nobody
49
write

Философия Python — утиная типизация (duck typing): если у объекта есть нужный метод, он подходит. typing.Protocol переносит эту идею в систему типов. Протокол описывает, что объект умеет делать; любой класс с нужными методами автоматически ему соответствует, не наследуясь от него. Это называется структурной типизацией:

Python
from typing import Protocol

class HasArea(Protocol):
    def area(self) -> float: ...

class Square:
    def __init__(self, side: float) -> None:
        self.side = side

    def area(self) -> float:
        return self.side ** 2

class Circle:
    def __init__(self, r: float) -> None:
        self.r = r

    def area(self) -> float:
        return 3.14159 * self.r ** 2

def total_area(shapes: list[HasArea]) -> float:
    return sum(s.area() for s in shapes)

print(round(total_area([Square(2), Circle(1)]), 2))
▸ Ожидаемый результат
7.14

Square и Circle ничего не знают о HasArea, но анализатор типов принимает оба класса в list[HasArea], потому что у каждого есть метод area() -> float. Класс, в котором забыли area, будет отмечен как ошибка ещё до запуска программы.

dataclasses: классы без шаблонного кода

Классу, который в основном хранит данные, нужны __init__, копирующий аргументы в атрибуты, понятный __repr__ и __eq__, сравнивающий поле за полем. Писать их вручную долго, и легко ошибиться. Декоратор **@dataclass читает аннотированные атрибуты класса (поля**) и генерирует эти методы за тебя:

Python
from dataclasses import dataclass, field

@dataclass
class Student:
    name: str
    grade: int
    scores: list[int] = field(default_factory=list)

    def average(self) -> float:
        return sum(self.scores) / len(self.scores) if self.scores else 0.0

a = Student('Aysel', 9, [5, 4, 5])
b = Student('Aysel', 9, [5, 4, 5])
print(a)
print(a == b, a is b)
print(round(a.average(), 2))
print(Student('Murad', 8))
▸ Ожидаемый результат
Student(name='Aysel', grade=9, scores=[5, 4, 5])
True False
4.67
Student(name='Murad', grade=8, scores=[])

a == b равно True, потому что сгенерированный __eq__ сравнивает поля, а a is b — False: это два разных объекта. Поле scores у Мурада получило новый пустой список благодаря field(default_factory=list): фабрика вызывается отдельно для каждого нового объекта.

Python
from dataclasses import dataclass

try:
    @dataclass
    class Basket:
        items: list[str] = []
except ValueError as e:
    print(e)
▸ Ожидаемый результат
mutable default <class 'list'> for field items is not allowed: use default_factory

frozen, order, slots и __post_init__

Параметры декоратора включают дополнительные возможности. frozen=True делает объекты неизменяемыми (а значит, хешируемыми: они могут быть элементами множеств и ключами словарей), order=True генерирует <, <=, >, >=, которые сравнивают поля по порядку, как кортежи, а slots=True (Python 3.10+) хранит атрибуты не в словаре, а в __slots__ — это экономит память и ускоряет доступ. Метод __post_init__ выполняется сразу после сгенерированного __init__ — это место для проверок:

Python
from dataclasses import dataclass, replace, asdict

@dataclass(frozen=True, order=True, slots=True)
class Version:
    major: int
    minor: int = 0
    patch: int = 0

    def __post_init__(self):
        if self.major < 0:
            raise ValueError('major must be >= 0')

v1 = Version(0, 1)
v2 = replace(v1, minor=2)
print(v1 < v2)
print(sorted([Version(1), v2, v1]))
print(asdict(v2))
print({v1, Version(0, 1, 0)})
try:
    v1.major = 1
except AttributeError as e:
    print(type(e).__name__)
▸ Ожидаемый результат
True
[Version(major=0, minor=1, patch=0), Version(major=0, minor=2, patch=0), Version(major=1, minor=0, patch=0)]
{'major': 0, 'minor': 2, 'patch': 0}
{Version(major=0, minor=1, patch=0)}
FrozenInstanceError

Разберём вывод. sorted работает благодаря order=True: версии сравниваются как кортежи (major, minor, patch). В множестве остался один элемент, потому что у равных неизменяемых объектов и хеши равны. Попытка присваивания вызвала FrozenInstanceError — подкласс AttributeError: после создания объект изменить нельзя.

ПараметрЧто даёт
frozen=Trueнеизменяемые объекты; присваивание вызывает FrozenInstanceError; хеширование
order=Trueсравнение по полям < <= > >=, объекты можно сортировать
slots=True__slots__: меньше памяти, быстрый доступ, новые атрибуты добавить нельзя
kw_only=Trueвсе поля передаются только по имени
field(default_factory=...)новое значение по умолчанию для каждого объекта
field(repr=False, compare=False)исключает поле из repr или сравнений
Задание

Допиши dataclass Cart: добавь поле items — по умолчанию пустой словарь (у каждой корзины свой), пусть add сохраняет товар с его ценой, а total возвращает сумму цен, округлённую до 2 знаков.

Задание · Python
from dataclasses import dataclass, field

@dataclass
class Cart:
    owner: str
    # add a field `items`: dict[str, float], an empty dict by default

    def add(self, name: str, price: float) -> None:
        ...

    def total(self) -> float:
        ...

a = Cart('Aysel')
b = Cart('Murad')
a.add('tea', 3.5)
a.add('bread', 0.8)
print(a)
print(b)
print(a.total())
▸ Ожидаемый результат
Cart(owner='Aysel', items={'tea': 3.5, 'bread': 0.8})
Cart(owner='Murad', items={})
4.3
Задание

Бегунов нужно отсортировать по времени (самый быстрый первым), а при равном времени — по имени. Не используя аргумент key, измени класс так, чтобы sorted() сортировал именно так.

Задание · Python
from dataclasses import dataclass

@dataclass(order=True, frozen=True)
class Runner:
    name: str
    time: float

results = [Runner('Murad', 12.4), Runner('Leyla', 11.9), Runner('Aysel', 12.4)]
for r in sorted(results):
    print(r.name, r.time)
▸ Ожидаемый результат
Leyla 11.9
Aysel 12.4
Murad 12.4

Главное

  • Аннотации (x: int, -> str, list[int], int | None) документируют типы; Python не проверяет их во время выполнения.
  • mypy или pyright проверяют аннотации статически, до запуска программы.
  • Обобщённые типы (def first[T]), Callable, Literal и Protocol описывают сложные контракты; Protocol — это структурная типизация.
  • @dataclass генерирует __init__, __repr__ и __eq__ по аннотированным полям; изменяемым значениям по умолчанию нужен field(default_factory=...).
  • frozen, order, slots и __post_init__ добавляют неизменяемость, упорядочивание, экономию памяти и проверки.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Что произойдёт во время выполнения, если передать строку в функцию с параметром x: int?