Python · Синтаксис · Продвинутый уровень
Аннотации типов и dataclasses
Статические аннотации, документирующие намерения и поддерживающие инструменты IDE. Охватывает `Optional`, `Union`, `TypedDict`, `Protocol` и `dataclasses`.
Краткое введение в тему и пояснения перед упражнениями (упражнения ниже):
TypeVar, Generic, Callable и @overload детально
#dataclasses, Protocol, проверка в runtime, краткий справочник
#Упражнения:
Аннотировать сигнатуры функций
#Добавьте полные аннотации типов к следующим функциям: `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
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
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())) # Рисуем квадрат
Замороженный датакласс: 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
Датакласс с валидацией через __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)
Аннотации типов 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
Датакласс с 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) # []
Обобщённый 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
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
Конвертация 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