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