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