Python · Синтаксис · Продвинутый уровень

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

10 задач

Статические аннотации, документирующие намерения и поддерживающие инструменты IDE. Охватывает `Optional`, `Union`, `TypedDict`, `Protocol` и `dataclasses`.

Аннотации типов: основы, Union/Optional, новый синтаксис

#
**Что такое аннотации типов и зачем они** Python динамически типизирован — можно присвоить любое значение любой переменной. Аннотации типов это не меняют. Это подсказки, которые читают инструменты (`mypy`, `pyright`, IDE) чтобы находить ошибки *до* запуска кода. ```python def greet(name: str) -> str: return 'Hello, ' + name greet(42) # mypy: Argument 1 has incompatible type 'int'; expected 'str' # Python всё равно выполнит это — mypy лишь предупреждает ``` Аннотации хранятся в `__annotations__` и по умолчанию стираются при выполнении. **Эволюция синтаксиса** До Python 3.9 встроенные коллекции нельзя было использовать напрямую как обобщённые типы: ```python # Python 3.8 и старше (по-прежнему валидно везде) from typing import List, Dict, Optional, Union def process(items: List[str]) -> Dict[str, int]: return {item: len(item) for item in items} # Python 3.9+ — встроенные типы напрямую def process(items: list[str]) -> dict[str, int]: return {item: len(item) for item in items} ``` **`Optional` и `Union`** `Optional[X]` означает `X` или `None`. Сокращение от `Union[X, None]`. Python 3.10 добавил синтаксис `|`: ```python from typing import Optional, Union # Все четыре эквивалентны: def f1(x: Optional[str]) -> None: ... def f2(x: Union[str, None]) -> None: ... def f3(x: str | None) -> None: ... # 3.10+ # Union с несколькими типами: def accept(value: int | str | float) -> str: return str(value) ``` **`Any` vs `object`** ```python from typing import Any def takes_any(x: Any) -> None: x.whatever() # mypy разрешает — Any отключает проверку def takes_object(x: object) -> None: x.whatever() # mypy ОШИБКА — object не имеет .whatever() ``` `Any` — аварийный выход отключающий проверку типов полностью. `object` — корень иерархии классов с очень малым числом гарантированных атрибутов. **`Final` и `ClassVar`** ```python from typing import Final, ClassVar MAX_SIZE: Final = 100 # не может быть переопределено class Config: VERSION: ClassVar[str] = '1.0' # принадлежит классу, не экземплярам ```

TypeVar, Generic, Callable и @overload детально

#
**`TypeVar` — переменные типов для обобщённых функций** `TypeVar` позволяет написать функцию сохраняющую связь между типами входа и выхода без привязки к конкретному типу: ```python from typing import TypeVar T = TypeVar('T') def first(items: list[T]) -> T: return items[0] x: int = first([1, 2, 3]) # T выводится как int y: str = first(['a', 'b']) # T выводится как str z: int = first(['a', 'b']) # mypy ошибка: str, не int ``` **Ограниченный `TypeVar`** ```python from typing import TypeVar class Animal: def speak(self) -> str: ... A = TypeVar('A', bound=Animal) def make_speak(animal: A) -> A: animal.speak() return animal # возвращает тот же подтип, не просто Animal ``` **`Generic` — типизированные классы-контейнеры** ```python from typing import TypeVar, Generic T = TypeVar('T') class Stack(Generic[T]): def __init__(self) -> None: self._items: list[T] = [] def push(self, item: T) -> None: self._items.append(item) def pop(self) -> T: return self._items.pop() s: Stack[int] = Stack() s.push(1) # OK s.push('oops') # mypy ошибка: ожидался int, получен str ``` **`Callable` — типизация функций-аргументов** ```python from typing import Callable # Callable[[типы_аргументов], тип_возврата] def apply(func: Callable[[int, int], int], a: int, b: int) -> int: return func(a, b) # Callable с произвольными аргументами: def run(callback: Callable[..., None]) -> None: callback() ``` **`@overload` — несколько сигнатур для одной функции** ```python from typing import overload @overload def process(x: int) -> int: ... @overload def process(x: str) -> str: ... def process(x): # реальная реализация — без аннотаций if isinstance(x, int): return x * 2 return x.upper() a: int = process(5) # mypy знает что это int b: str = process('hi') # mypy знает что это str ```

dataclasses, Protocol, проверка в runtime, краткий справочник

#
**`dataclasses` с аннотациями типов** `@dataclass` использует ваши аннотации полей для автогенерации `__init__`, `__repr__`, `__eq__`. Типы что вы там пишете — реальные подсказки которые проверяет mypy: ```python from dataclasses import dataclass, field from typing import ClassVar @dataclass class Point: x: float y: float label: str = '' # значение по умолчанию tags: list[str] = field(default_factory=list) # мутабельный default _count: ClassVar[int] = 0 # не поле экземпляра p = Point(1.0, 2.0, label='origin') ``` `field(default_factory=list)` обязателен для мутабельных значений по умолчанию — никогда не используйте `tags: list[str] = []` напрямую, иначе все экземпляры разделяют один список. `__post_init__` запускается после сгенерированного `__init__` — используйте для валидации: ```python @dataclass class PositivePoint: x: float y: float def __post_init__(self): if self.x < 0 or self.y < 0: raise ValueError('координаты должны быть неотрицательными') ``` **`Protocol` — структурная типизация (duck typing + безопасность типов)** `Protocol` описывает форму — методы и атрибуты что что-то должно иметь — без требования явного наследования: ```python from typing import Protocol class Drawable(Protocol): def draw(self) -> None: ... class Circle: def draw(self) -> None: # реализует Drawable без наследования print('drawing circle') def render(shape: Drawable) -> None: shape.draw() render(Circle()) # OK — Circle структурно соответствует Drawable render(42) # mypy ошибка: у int нет .draw() ``` **Краткий справочник** | Аннотация | Значение | |---|---| | `x: int` | x является int | | `x: int \| None` | x является int или None (3.10+) | | `x: Optional[int]` | то же, до 3.10 | | `x: list[int]` | список int (3.9+) | | `x: dict[str, int]` | словарь с str ключами, int значениями | | `x: tuple[int, ...]` | кортеж произвольной длины из int | | `x: Any` | без проверки | | `x: Final` | не может быть переопределено | | `x: ClassVar[int]` | атрибут класса |
01

Аннотировать сигнатуры функций

#

Добавьте полные аннотации типов к следующим функциям: `greet(name)` возвращает str; `add(a, b)` принимает два int и возвращает int; `find_first(items, value)` принимает список int и int, возвращает `int | None` (индекс если найдено, иначе None).

def greet(name):
    return f'Привет, {name}!'

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

def find_first(items, value):
    for i, v in enumerate(items):
        if v == value:
            return i
    return None


print(greet('Алиса'))           # Привет, Алиса!
print(add(2, 3))                # 5
print(find_first([1,2,3], 2))   # 1
print(find_first([1,2,3], 9))   # None
Решение
def greet(name: str) -> str:
    return f'Привет, {name}!'

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

def find_first(items: list[int], value: int) -> int | None:
    for i, v in enumerate(items):
        if v == value:
            return i
    return None


print(greet('Алиса'))           # Привет, Алиса!
print(add(2, 3))                # 5
print(find_first([1,2,3], 2))   # 1
print(find_first([1,2,3], 9))   # None
02

TypedDict для API-ответа

#

Определите `TypedDict` под названием `UserResponse` с ключами: `id` (int), `name` (str), `email` (str), `is_active` (bool). Затем напишите функцию `parse_user(data: dict) -> UserResponse`, возвращающую данные с типом `UserResponse`.

from typing import TypedDict

# Определите UserResponse TypedDict здесь


def parse_user(data: dict) -> 'UserResponse':
    pass


raw = {'id': 1, 'name': 'Алиса', 'email': '[email protected]', 'is_active': True}
user = parse_user(raw)
print(user['name'])      # Алиса
print(user['is_active']) # True
Решение
from typing import TypedDict

class UserResponse(TypedDict):
    id: int
    name: str
    email: str
    is_active: bool


def parse_user(data: dict) -> UserResponse:
    return UserResponse(
        id=data['id'],
        name=data['name'],
        email=data['email'],
        is_active=data['is_active'],
    )


raw = {'id': 1, 'name': 'Алиса', 'email': '[email protected]', 'is_active': True}
user = parse_user(raw)
print(user['name'])      # Алиса
print(user['is_active']) # True
03

Protocol для структурной типизации

#

Определите `Protocol` под названием `Drawable` с методом `draw() -> str`. Напишите функцию `render(item: Drawable) -> str`, вызывающую `item.draw()`. Создайте два класса `Circle` и `Square` (без наследования) с методом `draw()`. Убедитесь, что оба работают с `render()`.

from typing import Protocol

class Drawable(Protocol):
    pass  # добавьте сигнатуру метода draw()

def render(item: Drawable) -> str:
    pass


class Circle:
    def draw(self) -> str:
        return 'Рисуем круг'

class Square:
    def draw(self) -> str:
        return 'Рисуем квадрат'

print(render(Circle()))  # Рисуем круг
print(render(Square()))  # Рисуем квадрат
Решение
from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str: ...

def render(item: Drawable) -> str:
    return item.draw()


class Circle:
    def draw(self) -> str:
        return 'Рисуем круг'

class Square:
    def draw(self) -> str:
        return 'Рисуем квадрат'

print(render(Circle()))  # Рисуем круг
print(render(Square()))  # Рисуем квадрат
04

Замороженный датакласс: Point

#

Создайте датакласс `frozen=True` `Point` с полями `x: float` и `y: float`. Добавьте метод `distance_to(other: 'Point') -> float`, вычисляющий евклидово расстояние. Убедитесь, что попытка `p.x = 5` выбрасывает `FrozenInstanceError`.

from dataclasses import dataclass
import math

# Определите Point как frozen dataclass


p1 = Point(0, 0)
p2 = Point(3, 4)
print(p1.distance_to(p2))  # 5.0
print(p1 == Point(0, 0))   # True
try:
    p1.x = 10              # FrozenInstanceError
except Exception as e:
    print(type(e).__name__) # FrozenInstanceError
Решение
from dataclasses import dataclass
import math

@dataclass(frozen=True)
class Point:
    x: float
    y: float

    def distance_to(self, other: 'Point') -> float:
        return math.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)


p1 = Point(0, 0)
p2 = Point(3, 4)
print(p1.distance_to(p2))  # 5.0
print(p1 == Point(0, 0))   # True
try:
    p1.x = 10
except Exception as e:
    print(type(e).__name__) # FrozenInstanceError
05

Датакласс с валидацией через __post_init__

#

Создайте датакласс `BankAccount` с полями `owner: str` и `balance: float`. Используйте `__post_init__`, чтобы выбросить `ValueError`, если `balance` отрицательный. Добавьте метод `deposit(amount: float)`, увеличивающий баланс, и `withdraw(amount: float)`, выбрасывающий `ValueError`, если сумма превышает баланс.

from dataclasses import dataclass

@dataclass
class BankAccount:
    owner: str
    balance: float

    def __post_init__(self):
        pass

    def deposit(self, amount: float) -> None:
        pass

    def withdraw(self, amount: float) -> None:
        pass


acc = BankAccount('Алиса', 100.0)
acc.deposit(50)
print(acc.balance)  # 150.0
acc.withdraw(30)
print(acc.balance)  # 120.0
try:
    BankAccount('Боб', -50)  # ValueError
except ValueError as e:
    print(e)
Решение
from dataclasses import dataclass

@dataclass
class BankAccount:
    owner: str
    balance: float

    def __post_init__(self):
        if self.balance < 0:
            raise ValueError(f'Баланс не может быть отрицательным: {self.balance}')

    def deposit(self, amount: float) -> None:
        self.balance += amount

    def withdraw(self, amount: float) -> None:
        if amount > self.balance:
            raise ValueError(f'Недостаточно средств: {self.balance} < {amount}')
        self.balance -= amount


acc = BankAccount('Алиса', 100.0)
acc.deposit(50)
print(acc.balance)  # 150.0
acc.withdraw(30)
print(acc.balance)  # 120.0
try:
    BankAccount('Боб', -50)
except ValueError as e:
    print(e)
06

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

#

Напишите функцию `apply_twice(func: ..., value: int) -> int`, применяющую `func` к `value` дважды. Аннотируйте `func` правильным типом `Callable` (функция, принимающая int и возвращающая int). Также напишите `compose(f, g)`, возвращающую новую функцию `h(x)` = `f(g(x))` с корректными аннотациями Callable.

from typing import Callable

def apply_twice(func, value: int) -> int:
    pass

def compose(f, g):
    pass


double = lambda x: x * 2
add_one = lambda x: x + 1

print(apply_twice(double, 3))            # 12
print(apply_twice(add_one, 5))           # 7
double_then_add = compose(add_one, double)
print(double_then_add(4))                # 9  (4*2 + 1)
Решение
from typing import Callable

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

def compose(
    f: Callable[[int], int],
    g: Callable[[int], int],
) -> Callable[[int], int]:
    return lambda x: f(g(x))


double = lambda x: x * 2
add_one = lambda x: x + 1

print(apply_twice(double, 3))            # 12
print(apply_twice(add_one, 5))           # 7
double_then_add = compose(add_one, double)
print(double_then_add(4))                # 9
07

Датакласс с field() и default_factory

#

Создайте датакласс `Playlist` с полями: `name: str` (обязательное), `songs: list[str]` по умолчанию пустой список, и `max_length: int` по умолчанию 100. Используйте `field(default_factory=list)` для `songs` — не голое `[]`. Добавьте метод `add_song(song: str)`.

from dataclasses import dataclass, field

@dataclass
class Playlist:
    name: str
    # songs должен по умолчанию быть пустым списком — field(default_factory=list)
    # max_length должен по умолчанию быть 100

    def add_song(self, song: str) -> None:
        pass


p1 = Playlist('Rock')
p2 = Playlist('Pop')
p1.add_song('Bohemian Rhapsody')
print(p1.songs)  # ['Bohemian Rhapsody']
print(p2.songs)  # []  -- p2 имеет свой список
Решение
from dataclasses import dataclass, field

@dataclass
class Playlist:
    name: str
    songs: list[str] = field(default_factory=list)
    max_length: int = 100

    def add_song(self, song: str) -> None:
        self.songs.append(song)


p1 = Playlist('Rock')
p2 = Playlist('Pop')
p1.add_song('Bohemian Rhapsody')
print(p1.songs)  # ['Bohemian Rhapsody']
print(p2.songs)  # []
08

Обобщённый Stack с TypeVar

#

Используйте `TypeVar` для создания обобщённого класса `Stack[T]`, работающего с любым типом. Реализуйте `push(item: T)`, `pop() -> T`, `peek() -> T` и `is_empty() -> bool`. `pop` и `peek` должны выбрасывать `IndexError` на пустом стеке.

from typing import TypeVar, Generic

T = TypeVar('T')

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        pass

    def pop(self) -> T:
        pass

    def peek(self) -> T:
        pass

    def is_empty(self) -> bool:
        pass


s: Stack[int] = Stack()
s.push(1)
s.push(2)
print(s.peek())     # 2
print(s.pop())      # 2
print(s.pop())      # 1
print(s.is_empty()) # True
Решение
from typing import TypeVar, Generic

T = TypeVar('T')

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        if not self._items:
            raise IndexError('pop из пустого стека')
        return self._items.pop()

    def peek(self) -> T:
        if not self._items:
            raise IndexError('peek на пустой стек')
        return self._items[-1]

    def is_empty(self) -> bool:
        return len(self._items) == 0


s: Stack[int] = Stack()
s.push(1)
s.push(2)
print(s.peek())     # 2
print(s.pop())      # 2
print(s.pop())      # 1
print(s.is_empty()) # True
09

Literal для ограничения допустимых значений

#

Напишите функцию `set_log_level(level: ...)`, принимающую только одно из `'DEBUG'`, `'INFO'`, `'WARNING'`, `'ERROR'` с помощью `Literal`. Она должна печатать `'Log level set to: <level>'`. Также аннотируйте переменную `direction` как `Literal['N', 'S', 'E', 'W']`.

from typing import Literal

LogLevel = Literal['DEBUG', 'INFO', 'WARNING', 'ERROR']

def set_log_level(level: LogLevel) -> None:
    pass


set_log_level('DEBUG')    # Log level set to: DEBUG
set_log_level('ERROR')    # Log level set to: ERROR
# set_log_level('TRACE')  # ошибка типа (не в Literal)

direction: Literal['N', 'S', 'E', 'W'] = 'N'
print(direction)
Решение
from typing import Literal

LogLevel = Literal['DEBUG', 'INFO', 'WARNING', 'ERROR']

def set_log_level(level: LogLevel) -> None:
    print(f'Log level set to: {level}')


set_log_level('DEBUG')
set_log_level('ERROR')

direction: Literal['N', 'S', 'E', 'W'] = 'N'
print(direction)  # N
10

Конвертация namedtuple в dataclass

#

Конвертируйте следующий `namedtuple` в `@dataclass` с теми же полями. Затем добавьте свойство `full_name`, возвращающее `f'{first} {last}'`, и свойство `is_adult`, возвращающее `True`, если `age >= 18`.

from collections import namedtuple

# Оригинальный namedtuple (преобразуйте в dataclass):
# Person = namedtuple('Person', ['first', 'last', 'age'])

from dataclasses import dataclass

# Ваш dataclass здесь:


p = Person('Алиса', 'Смит', 30)
print(p.full_name)  # Алиса Смит
print(p.is_adult)   # True
print(p.age)        # 30
Решение
from dataclasses import dataclass

@dataclass
class Person:
    first: str
    last: str
    age: int

    @property
    def full_name(self) -> str:
        return f'{self.first} {self.last}'

    @property
    def is_adult(self) -> bool:
        return self.age >= 18


p = Person('Алиса', 'Смит', 30)
print(p.full_name)  # Алиса Смит
print(p.is_adult)   # True
print(p.age)        # 30