Python · Синтаксис · Проміжний

dataclasses

10 завдань

Навчіться використовувати датакласи Python для визначення чистих контейнерів даних без шаблонного коду з автоматичною генерацією __init__, __repr__ і __eq__.

@dataclass: автоматичний шаблонний код

#
До датакласів створення простого контейнера даних у Python потребувало ручного написання `__init__`, `__repr__` та `__eq__` — багато повторюваного коду. Декоратор `@dataclass` (Python 3.7+) генерує все це автоматично. ```python from dataclasses import dataclass, field ``` ## Проблема без датакласів ```python # Старий спосіб: багато шаблонного коду class Point: def __init__(self, x, y): self.x = x self.y = y def __repr__(self): return f"Point(x={self.x}, y={self.y})" def __eq__(self, other): return isinstance(other, Point) and self.x == other.x and self.y == other.y ``` ## Рішення з dataclass ```python @dataclass class Point: x: float y: float p1 = Point(1.0, 2.0) p2 = Point(1.0, 2.0) print(p1) # Point(x=1.0, y=2.0) — __repr__ авто-генерується print(p1 == p2) # True — __eq__ авто-генерується print(p1.x) # 1.0 ``` Декоратор генерує `__init__`, `__repr__` та `__eq__` на основі анотацій класу. ## Значення за замовчуванням ```python @dataclass class Config: host: str = "localhost" port: int = 8080 debug: bool = False c = Config() # всі за замовчуванням print(c) # Config(host='localhost', port=8080, debug=False) c2 = Config(host="0.0.0.0", port=443) print(c2) # Config(host='0.0.0.0', port=443, debug=False) ``` **Правило**: поля зі значеннями за замовчуванням мають іти після полів без них (те ж правило, що і для параметрів функції). ## field() для змінних значень за замовчуванням Не можна використовувати `default=[]` безпосередньо — Python спільно використовував би той самий список для всіх екземплярів. Натомість використовуйте `field(default_factory=list)`: ```python @dataclass class Student: name: str grades: list = field(default_factory=list) # кожен екземпляр отримує свій список active: bool = True s1 = Student("Alice") s2 = Student("Bob") s1.grades.append(95) print(s1.grades) # [95] print(s2.grades) # [] — незалежний! ``` ## __post_init__: обчислювані поля та валідація ```python @dataclass class Rectangle: width: float height: float area: float = field(init=False) # виключено з __init__ def __post_init__(self): if self.width <= 0 or self.height <= 0: raise ValueError("Ширина і висота мають бути додатними") self.area = self.width * self.height r = Rectangle(4.0, 5.0) print(r) # Rectangle(width=4.0, height=5.0, area=20.0) print(r.area) # 20.0 ``` `__post_init__` виконується автоматично після `__init__`.

Frozen, Order та спадкування

#
## frozen=True: незмінні датакласи ```python from dataclasses import dataclass @dataclass(frozen=True) class Color: r: int g: int b: int red = Color(255, 0, 0) print(red) # Color(r=255, g=0, b=0) # Заморожені екземпляри хешабельні — можна як ключ словника або у множині! palette = {red, Color(0, 255, 0), Color(0, 0, 255)} config = {Color(255, 0, 0): "red", Color(0, 0, 255): "blue"} # Спроба змінити кидає FrozenInstanceError: red.r = 100 # FrozenInstanceError: cannot assign to field 'r' ``` ## order=True: підтримка порівняння ```python @dataclass(order=True) class Version: major: int minor: int patch: int v1 = Version(1, 2, 3) v2 = Version(1, 3, 0) v3 = Version(2, 0, 0) print(v1 < v2) # True — порівнює поле за полем print(v2 < v3) # True versions = [v3, v1, v2] print(sorted(versions)) # [Version(major=1, minor=2, patch=3), Version(major=1, minor=3, patch=0), Version(major=2, minor=0, patch=0)] ``` ## Спадкування ```python @dataclass class Animal: name: str sound: str @dataclass class Dog(Animal): breed: str sound: str = "woof" # перевизначення за замовчуванням d = Dog(name="Rex", breed="Labrador") print(d) # Dog(name='Rex', sound='woof', breed='Labrador') print(d.sound) # woof ``` ## Вкладені датакласи ```python @dataclass class Address: street: str city: str country: str = "Ukraine" @dataclass class Person: name: str age: int address: Address p = Person( name="Alice", age=30, address=Address("вул. Хрещатик", "Київ"), ) print(p) # Person(name='Alice', age=30, address=Address(street='вул. Хрещатик', city='Київ', country='Ukraine')) print(p.address.city) # Київ ``` ## Конвертація у dict і tuple ```python from dataclasses import dataclass, asdict, astuple @dataclass class Point: x: float y: float p = Point(1.5, 2.5) d = asdict(p) # {"x": 1.5, "y": 2.5} t = astuple(p) # (1.5, 2.5) # Зручно для серіалізації в JSON import json json_str = json.dumps(asdict(p)) print(json_str) # {"x": 1.5, "y": 2.5} ```

Довідник: dataclasses

#
## Параметри @dataclass ```python @dataclass( init=True, # генерувати __init__ repr=True, # генерувати __repr__ eq=True, # генерувати __eq__ та __ne__ order=False, # генерувати __lt__, __le__, __gt__, __ge__ unsafe_hash=False, # генерувати __hash__ навіть якщо eq=True frozen=False, # зробити екземпляри незмінними slots=False, # використовувати __slots__ (Python 3.10+) kw_only=False, # всі поля лише keyword у __init__ (Python 3.10+) ) class MyClass: ... ``` ## Параметри field() ```python from dataclasses import field @dataclass class Example: name: str # обов'язкове поле score: float = 0.0 # просте значення за замовчуванням tags: list = field(default_factory=list) # змінне значення за замовчуванням _id: int = field(init=False, repr=False) # виключено з init і repr label: str = field(default="n/a", compare=False) # виключено з eq ``` | Параметр | За замовч. | Значення | |---|---|---| | `default` | MISSING | Значення за замовчуванням | | `default_factory` | MISSING | Виклик що повертає значення за замовч. | | `init` | True | Включати в `__init__` | | `repr` | True | Включати в `__repr__` | | `hash` | None | Включати в `__hash__` | | `compare` | True | Включати в `__eq__` і упорядкування | | `metadata` | None | Довільний словник метаданих | ## Допоміжні функції | Функція | Що робить | |---|---| | `asdict(obj)` | Конвертувати у вкладений словник | | `astuple(obj)` | Конвертувати у вкладений кортеж | | `fields(class_or_instance)` | Повернути кортеж об'єктів Field | | `replace(obj, **changes)` | Повернути новий екземпляр з деякими змінами | ## dataclass vs звичайний клас vs namedtuple | Функція | звичайний клас | namedtuple | dataclass | |---|---|---|---| | Авто `__init__` | Ні | Так | Так | | Авто `__repr__` | Ні | Так | Так | | Авто `__eq__` | Ні | Так | Так | | Мутабельний | Так | Ні | Так (за замовч.) | | Хешабельний | Ні | Так | З frozen=True | | Упорядкування | Вручну | Так | З order=True | | Методи | Так | Обмежено | Так |
01

Базовий датаклас

#

Створіть датаклас `Book` з полями: `title: str`, `author: str`, `year: int`, `rating: float = 0.0`. Потім створіть два екземпляри: `Book("1984", "Orwell", 1949, 9.5)` і `Book("Dune", "Herbert", 1965)`. Виведіть обидва і перевірте що `==` працює (створіть два однакових об'єкти Book і перевірте що вони рівні).

from dataclasses import dataclass


@dataclass
class Book:
    pass


b1 = Book("1984", "Orwell", 1949, 9.5)
b2 = Book("Dune", "Herbert", 1965)
print(b1)
print(b2)

b3 = Book("1984", "Orwell", 1949, 9.5)
print(b1 == b3)   # True
Рішення
from dataclasses import dataclass


@dataclass
class Book:
    title: str
    author: str
    year: int
    rating: float = 0.0


b1 = Book("1984", "Orwell", 1949, 9.5)
b2 = Book("Dune", "Herbert", 1965)
print(b1)
print(b2)

b3 = Book("1984", "Orwell", 1949, 9.5)
print(b1 == b3)   # True
02

Змінний default через field()

#

Створіть датаклас `Team` з полями `name: str` і `members: list`, де `members` за замовчуванням — порожній список. Покажіть що кожен екземпляр отримує свій незалежний список: створіть дві команди, додайте учасника до першої і перевірте що друга залишається порожньою. Використовуйте `field(default_factory=list)`.

from dataclasses import dataclass, field


@dataclass
class Team:
    name: str
    members: list = ???   # виправте це


t1 = Team("Alpha")
t2 = Team("Beta")
t1.members.append("Alice")
print(t1.members)   # ["Alice"]
print(t2.members)   # []  <- має бути порожнім
Рішення
from dataclasses import dataclass, field


@dataclass
class Team:
    name: str
    members: list = field(default_factory=list)


t1 = Team("Alpha")
t2 = Team("Beta")
t1.members.append("Alice")
print(t1.members)   # ["Alice"]
print(t2.members)   # []
03

Валідація у __post_init__

#

Створіть датаклас `Temperature` з одним полем `celsius: float`. У `__post_init__` перевірте що температура вища за абсолютний нуль (-273.15°C) і кидайте `ValueError` з повідомленням `"Temperature below absolute zero"` якщо ні. Протестуйте обидва випадки: валідний і невалідний.

from dataclasses import dataclass


@dataclass
class Temperature:
    celsius: float

    def __post_init__(self):
        pass   # валідація тут


print(Temperature(100))
print(Temperature(-273))
Temperature(-300)   # raises ValueError
Рішення
from dataclasses import dataclass


@dataclass
class Temperature:
    celsius: float

    def __post_init__(self):
        if self.celsius < -273.15:
            raise ValueError("Temperature below absolute zero")


print(Temperature(100))
print(Temperature(-273))
try:
    Temperature(-300)
except ValueError as e:
    print(e)
04

Обчислюване поле

#

Створіть датаклас `Circle` з полем `radius: float` і обчислюваним полем `area: float`, яке НЕ є частиною `__init__` (використайте `field(init=False)`). Обчисліть `area` у `__post_init__` як `math.pi * radius ** 2`. Виведіть Circle і покажіть що `area` включено у repr.

from dataclasses import dataclass, field
import math


@dataclass
class Circle:
    radius: float
    area: float = field(init=False)

    def __post_init__(self):
        pass   # обчисліть area тут


c = Circle(5)
print(c)
print(c.area)
Рішення
from dataclasses import dataclass, field
import math


@dataclass
class Circle:
    radius: float
    area: float = field(init=False)

    def __post_init__(self):
        self.area = math.pi * self.radius ** 2


c = Circle(5)
print(c)
print(c.area)
05

Frozen датаклас як ключ словника

#

Створіть заморожений датаклас `Coordinate` з полями `lat: float` і `lon: float`. Продемонструйте що: 1. Неможливо змінити екземпляр `Coordinate` (покажіть помилку) 2. Екземпляри `Coordinate` можна використовувати як ключі словника — створіть словник що відображає координати на назви місць.

from dataclasses import dataclass


@dataclass(frozen=???)
class Coordinate:
    lat: float
    lon: float


c = Coordinate(50.45, 30.52)

try:
    c.lat = 0
except Exception as e:
    print(e)

locations = {c: "Kyiv"}
print(locations[Coordinate(50.45, 30.52)])   # Kyiv
Рішення
from dataclasses import dataclass


@dataclass(frozen=True)
class Coordinate:
    lat: float
    lon: float


c = Coordinate(50.45, 30.52)

try:
    c.lat = 0
except Exception as e:
    print(type(e).__name__, e)

locations = {c: "Kyiv"}
print(locations[Coordinate(50.45, 30.52)])   # Kyiv
06

Датаклас з упорядкуванням

#

Створіть датаклас `Student` з `order=True` і полями: `grade: float`, `name: str`. Створіть список з 5 студентів з різними оцінками і іменами, відсортуйте його (первинне сортування за `grade`) і виведіть результат. Зверніть увагу: упорядкування порівнює поля у порядку оголошення — поставте `grade` першим.

from dataclasses import dataclass


@dataclass(order=???)
class Student:
    grade: float
    name: str


students = [
    Student(3.5, "Alice"),
    Student(4.0, "Bob"),
    Student(3.5, "Charlie"),
    Student(2.8, "Dave"),
    Student(4.0, "Eve"),
]
print(sorted(students))
Рішення
from dataclasses import dataclass


@dataclass(order=True)
class Student:
    grade: float
    name: str


students = [
    Student(3.5, "Alice"),
    Student(4.0, "Bob"),
    Student(3.5, "Charlie"),
    Student(2.8, "Dave"),
    Student(4.0, "Eve"),
]
for s in sorted(students):
    print(s)
07

Вкладені датакласи

#

Створіть два датакласи: `Address` (поля: `street: str`, `city: str`, `country: str = "Ukraine"`) і `Person` (поля: `name: str`, `age: int`, `address: Address`). Створіть екземпляр `Person` з вкладеним `Address`, виведіть його і отримайте доступ до вкладеного міста. Потім використайте `asdict()` для конвертації Person у словник.

from dataclasses import dataclass, asdict


@dataclass
class Address:
    street: str
    city: str
    country: str = "Ukraine"


@dataclass
class Person:
    name: str
    age: int
    address: Address


p = Person("Alice", 30, Address("Main St", "Kyiv"))
print(p)
print(p.address.city)
print(asdict(p))
Рішення
from dataclasses import dataclass, asdict


@dataclass
class Address:
    street: str
    city: str
    country: str = "Ukraine"


@dataclass
class Person:
    name: str
    age: int
    address: Address


p = Person("Alice", 30, Address("Main St", "Kyiv"))
print(p)
print(p.address.city)   # Kyiv
print(asdict(p))
08

replace() для створення варіантів

#

Створіть заморожений датаклас `Config` з полями `host: str = "localhost"`, `port: int = 8080`, `debug: bool = False`. Використайте `dataclasses.replace()` для створення production-конфігурації з development-конфігурації шляхом зміни `host` і `debug`. Покажіть що оригінал не змінився, а новий екземпляр містить оновлені значення.

from dataclasses import dataclass, replace


@dataclass(frozen=True)
class Config:
    host: str = "localhost"
    port: int = 8080
    debug: bool = False


dev = Config(debug=True)
prod = replace(dev, ???)   # змініть host і debug
print(dev)
print(prod)
print(dev is prod)   # False
Рішення
from dataclasses import dataclass, replace


@dataclass(frozen=True)
class Config:
    host: str = "localhost"
    port: int = 8080
    debug: bool = False


dev = Config(debug=True)
prod = replace(dev, host="prod.example.com", debug=False)
print(dev)    # Config(host="localhost", port=8080, debug=True)
print(prod)   # Config(host="prod.example.com", port=8080, debug=False)
print(dev is prod)   # False
09

Датаклас у JSON

#

Створіть датаклас `Product` з полями `name: str`, `price: float`, `in_stock: bool = True` і `tags: list = field(default_factory=list)`. Створіть 3 екземпляри продуктів, конвертуйте кожен у словник за допомогою `asdict()`, а потім серіалізуйте список словників у рядок JSON за допомогою `json.dumps()`. Виведіть результат.

from dataclasses import dataclass, field, asdict
import json


@dataclass
class Product:
    name: str
    price: float
    in_stock: bool = True
    tags: list = field(default_factory=list)


products = [
    Product("Apple", 0.99, tags=["fruit", "fresh"]),
    Product("Laptop", 999.0, tags=["electronics"]),
    Product("Pen", 1.5, in_stock=False),
]
# Конвертуйте у JSON і виведіть
Рішення
from dataclasses import dataclass, field, asdict
import json


@dataclass
class Product:
    name: str
    price: float
    in_stock: bool = True
    tags: list = field(default_factory=list)


products = [
    Product("Apple", 0.99, tags=["fruit", "fresh"]),
    Product("Laptop", 999.0, tags=["electronics"]),
    Product("Pen", 1.5, in_stock=False),
]
data = [asdict(p) for p in products]
print(json.dumps(data, indent=2))
10

Система інвентаря

#

Розробіть невелику систему інвентаря з датакласами: - `Category` (frozen, поля: `name: str`, `tax_rate: float = 0.0`) - `Item` (поля: `name: str`, `price: float`, `quantity: int`, `category: Category`) - `Inventory` (поле: `items: list = field(default_factory=list)`, метод `add_item(item)`, метод `total_value()` що повертає суму price*quantity для всіх товарів) Створіть кілька товарів і виведіть загальну вартість інвентаря.

from dataclasses import dataclass, field


@dataclass(frozen=True)
class Category:
    name: str
    tax_rate: float = 0.0


@dataclass
class Item:
    name: str
    price: float
    quantity: int
    category: Category


@dataclass
class Inventory:
    items: list = field(default_factory=list)

    def add_item(self, item):
        pass

    def total_value(self):
        pass


inv = Inventory()
food = Category("Food", 0.05)
tech = Category("Tech", 0.2)
inv.add_item(Item("Apple", 1.0, 100, food))
inv.add_item(Item("Laptop", 800.0, 5, tech))
print(inv.total_value())   # 4100.0
Рішення
from dataclasses import dataclass, field


@dataclass(frozen=True)
class Category:
    name: str
    tax_rate: float = 0.0


@dataclass
class Item:
    name: str
    price: float
    quantity: int
    category: Category


@dataclass
class Inventory:
    items: list = field(default_factory=list)

    def add_item(self, item):
        self.items.append(item)

    def total_value(self):
        return sum(item.price * item.quantity for item in self.items)


inv = Inventory()
food = Category("Food", 0.05)
tech = Category("Tech", 0.2)
inv.add_item(Item("Apple", 1.0, 100, food))
inv.add_item(Item("Laptop", 800.0, 5, tech))
print(inv.total_value())   # 4100.0