33. Дата-классы для простых структурированных данных
В главе 30 мы научились создавать классы, чтобы определять собственные типы. Мы писали методы __init__, чтобы инициализировать экземпляры, методы __repr__, чтобы отображать их, и методы __eq__, чтобы сравнивать их. Хотя этот подход работает отлично, он требует написания большого количества повторяющегося кода, особенно когда класс в основном существует для хранения данных.
Дата-классы (data classes) в Python предоставляют более чистый и лаконичный способ создавать классы, которые в основном являются контейнерами для данных. Используя декоратор @dataclass, Python автоматически генерирует распространённые методы, такие как __init__, __repr__ и __eq__, на основе атрибутов класса, которые вы определяете. Это уменьшает шаблонный код и делает ваши намерения более понятными.
33.1) Что такое дата-классы и когда их использовать
Дата-класс (data class) — это класс, предназначенный прежде всего для хранения значений данных. Вместо того чтобы вручную писать методы инициализации и сравнения, вы определяете атрибуты, которые должен иметь ваш класс, и Python автоматически генерирует необходимые методы.
Почему дата-классы важны
Рассмотрим обычный класс для представления книги:
class Book:
def __init__(self, title, author, year):
self.title = title
self.author = author
self.year = year
def __repr__(self):
return f"Book(title={self.title!r}, author={self.author!r}, year={self.year})"
def __eq__(self, other):
if not isinstance(other, Book):
return False
return (self.title == other.title and
self.author == other.author and
self.year == other.year)
book1 = Book("1984", "George Orwell", 1949)
print(book1) # Output: Book(title='1984', author='George Orwell', year=1949)
book2 = Book("1984", "George Orwell", 1949)
print(book1 == book2) # Output: TrueЭто работает, но обратите внимание, сколько кода мы написали, чтобы просто хранить три значения. Методы __init__, __repr__ и __eq__ следуют предсказуемым шаблонам — они просто обрабатывают определённые нами атрибуты.
Дата-классы устраняют это повторение. Они особенно полезны, когда:
- Ваш класс в первую очередь хранит данные, а не реализует сложное поведение
- Вам нужны стандартные методы, такие как инициализация, строковое представление и сравнение на равенство
- Вам нужен более понятный и поддерживаемый код с меньшим количеством шаблонного кода
- Вы создаёте объекты конфигурации, объекты передачи данных или простые записи
Дата-классы не заменяют обычные классы — они дополняют их. Используйте обычные классы, когда вам нужна пользовательская логика инициализации, сложные методы или иерархии наследования. Используйте дата-классы, когда вам в основном нужен структурированный контейнер для связанных данных.
Связь между дата-классами и обычными классами
Дата-классы всё ещё являются обычными классами Python. Они поддерживают все возможности, которые мы изучили в главах 30–32: методы, свойства, наследование и специальные методы. Декоратор @dataclass просто автоматизирует создание распространённых методов, избавляя вас от написания повторяющегося кода.
33.2) Создание дата-классов с помощью @dataclass
Чтобы создать дата-класс, вы импортируете декоратор dataclass из модуля dataclasses и применяете его к определению класса. Внутри класса вы определяете атрибуты класса с аннотациями типов, которые указывают, какие данные должен хранить класс.
Базовый синтаксис дата-класса
from dataclasses import dataclass
@dataclass
class Student:
name: str
student_id: int
gpa: float
# Create instances
alice = Student("Alice Johnson", 12345, 3.8)
bob = Student("Bob Smith", 12346, 3.5)
print(alice) # Output: Student(name='Alice Johnson', student_id=12345, gpa=3.8)
print(bob) # Output: Student(name='Bob Smith', student_id=12346, gpa=3.5)Разберём, что делает декоратор @dataclass:
-
@dataclass: применение этого декоратора заставляет Python автоматически написать для вас методы__init__,__repr__и__eq__ -
Автоматический
__init__: Python создаёт метод инициализации, который принимает эти три параметра в порядке их определения и присваивает их атрибутам экземпляра -
Автоматический
__repr__: Python создаёт строковое представление, показывающее имя класса и значения всех атрибутов -
Автоматический
__eq__: Python создаёт метод сравнения на равенство, который сравнивает все атрибуты -
Преобразует аннотации типов в атрибуты экземпляра: в обычном классе запись
name: strв теле класса создаёт атрибут класса. Но декоратор@dataclassменяет это поведение — он использует эти аннотации типов, чтобы вместо этого определить атрибуты экземпляра. Каждый экземпляр получает собственные атрибутыname,student_idиgpa.
Ключевое отличие от обычных классов:
# Regular class - these are class attributes (shared by all instances)
class RegularStudent:
name: str
student_id: int
# Data class - these become instance attributes (each instance has its own)
@dataclass
class DataStudent:
name: str
student_id: intПонимание аннотаций типов в дата-классах
В дата-классах аннотации типов определяют атрибуты и документируют ожидаемые типы:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
in_stock: bool
# Using the correct types as documented
laptop = Product("Laptop", 999.99, True)
print(laptop) # Output: Product(name='Laptop', price=999.99, in_stock=True)
# Python doesn't enforce types - this runs without error
macbook = Product("Macbook", "expensive", True)
print(macbook) # Output: Product(name='Macbook', price='expensive', in_stock=True)
# But using wrong types will cause problems later:
discounted = laptop.price * 0.9 # Works: 899.991
discounted = macbook.price * 0.9 # TypeError: can't multiply sequence by non-int of type 'float'
tax = laptop.price + 50 # Works: 1049.99
tax = macbook.price + 50 # TypeError: can only concatenate str (not "int") to strPython не будет мешать вам передавать неверные типы при создании экземпляра дата-класса. Аннотации типов в основном служат документацией — они сообщают другим программистам (и инструментам проверки типов, таким как mypy), какие типы вы ожидаете, но Python не применяет их во время выполнения. Это согласуется с философией динамической типизации Python.
Однако следование аннотациям типов делает ваш код более предсказуемым и облегчает отладку. Когда вы используете неправильные типы, ошибки проявятся позже, когда вы попытаетесь использовать данные, из-за чего баги сложнее отследить. Инструменты проверки типов могут обнаружить эти несоответствия до запуска кода, помогая находить проблемы заранее.
Доступ к атрибутам и их изменение
Экземпляры дата-классов работают точно так же, как экземпляры обычных классов. Вы получаете доступ к атрибутам и изменяете их с помощью точечной нотации:
from dataclasses import dataclass
@dataclass
class Employee:
name: str
position: str
salary: float
emp = Employee("Sarah Chen", "Software Engineer", 95000.0)
# Access attributes
print(emp.name) # Output: Sarah Chen
print(emp.position) # Output: Software Engineer
# Modify attributes
emp.salary = 100000.0
emp.position = "Senior Software Engineer"
print(emp) # Output: Employee(name='Sarah Chen', position='Senior Software Engineer', salary=100000.0)Дата-классы по умолчанию изменяемые — вы можете менять их атрибуты после создания. Это отличается от кортежей или именованных кортежей, которые неизменяемы. Если вам нужна неизменяемость, вы можете настроить дата-класс с frozen=True (мы рассмотрим это в разделе 33.4).
33.3) Сгенерированные методы: __init__, __repr__ и __eq__
Декоратор @dataclass автоматически генерирует три важнейших метода. Понимание того, что делают эти методы, помогает эффективно использовать дата-классы и понимать, когда нужно их настраивать.
Сгенерированный метод __init__
Метод __init__ инициализирует новый экземпляр переданными значениями. Python генерирует его на основе порядка определения ваших атрибутов:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
# The generated __init__ accepts width and height in that order
rect = Rectangle(10.5, 5.0)
print(rect.width) # Output: 10.5
print(rect.height) # Output: 5.0
# You can also use keyword arguments
rect2 = Rectangle(height=8.0, width=12.0)
print(rect2.width) # Output: 12.0
print(rect2.height) # Output: 8.0Сгенерированный __init__ эквивалентен следующему коду:
def __init__(self, width: float, height: float):
self.width = width
self.height = heightЭта автоматическая генерация избавляет вас от написания повторяющегося кода инициализации, особенно для классов с большим количеством атрибутов.
Сгенерированный метод __repr__
Метод __repr__ предоставляет строковое представление экземпляра, показывающее значения всех атрибутов. Это крайне полезно для отладки и логирования:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
label: str
point = Point(3.5, 7.2, "A")
print(point) # Output: Point(x=3.5, y=7.2, label='A')
print(repr(point)) # Output: Point(x=3.5, y=7.2, label='A')Сгенерированный __repr__ следует соглашению показывать имя класса и все атрибуты в формате, который мог бы быть использован для воссоздания объекта. Это гораздо полезнее, чем представление по умолчанию, которое вы получили бы без __repr__: <__main__.Point object at 0x...>.
Сгенерированный метод __eq__
Метод __eq__ позволяет сравнивать экземпляры на равенство. Два экземпляра дата-класса считаются равными, если равны все их соответствующие атрибуты:
from dataclasses import dataclass
@dataclass
class Color:
red: int
green: int
blue: int
color1 = Color(255, 0, 0)
color2 = Color(255, 0, 0)
color3 = Color(0, 255, 0)
print(color1 == color2) # Output: True (same RGB values)
print(color1 == color3) # Output: False (different RGB values)
print(color1 is color2) # Output: False (different objects in memory)Это автоматическое сравнение на равенство основано на равенстве по значению, а не на идентичности. Несмотря на то что color1 и color2 — разные объекты в памяти (что показывает is), они считаются равными, потому что их атрибуты совпадают.
Сгенерированный метод __eq__ сравнивает атрибуты в порядке их определения:
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
year: int
book1 = Book("1984", "George Orwell", 1949)
book2 = Book("1984", "George Orwell", 1949)
book3 = Book("Animal Farm", "George Orwell", 1945)
print(book1 == book2) # Output: True (all attributes match)
print(book1 == book3) # Output: False (title and year differ)
# Comparison with non-Book objects returns False
print(book1 == "1984") # Output: False
print(book1 == None) # Output: FalseСравнение сгенерированных методов с ручной реализацией
Чтобы оценить, что дают дата-классы, сравним версию дата-класса с ручной реализацией:
from dataclasses import dataclass
# Data class version (concise)
@dataclass
class PersonData:
first_name: str
last_name: str
age: int
# Equivalent manual version (verbose)
class PersonManual:
def __init__(self, first_name: str, last_name: str, age: int):
self.first_name = first_name
self.last_name = last_name
self.age = age
def __repr__(self):
return f"PersonManual(first_name={self.first_name!r}, last_name={self.last_name!r}, age={self.age})"
def __eq__(self, other):
if not isinstance(other, PersonManual):
return False
return (self.first_name == other.first_name and
self.last_name == other.last_name and
self.age == other.age)
# Both work identically
p1 = PersonData("Alice", "Johnson", 30)
p2 = PersonManual("Alice", "Johnson", 30)
print(p1) # Output: PersonData(first_name='Alice', last_name='Johnson', age=30)
print(p2) # Output: PersonManual(first_name='Alice', last_name='Johnson', age=30)Версия с дата-классом достигает той же функциональности с существенно меньшим количеством кода. Такое сокращение шаблонного кода делает ваш код проще для чтения, поддержки и изменения.
Добавление пользовательских методов в дата-классы
Дата-классы могут иметь пользовательские методы точно так же, как обычные классы. Декоратор @dataclass генерирует только методы инициализации, представления и сравнения на равенство — вы свободны добавлять любую другую функциональность:
from dataclasses import dataclass
@dataclass
class Temperature:
celsius: float
def to_fahrenheit(self):
"""Convert temperature to Fahrenheit."""
return (self.celsius * 9/5) + 32
def to_kelvin(self):
"""Convert temperature to Kelvin."""
return self.celsius + 273.15
def is_freezing(self):
"""Check if temperature is at or below freezing point."""
return self.celsius <= 0
temp = Temperature(25.0)
print(temp) # Output: Temperature(celsius=25.0)
print(f"{temp.celsius}°C = {temp.to_fahrenheit()}°F") # Output: 25.0°C = 77.0°F
print(f"Kelvin: {temp.to_kelvin()}") # Output: Kelvin: 298.15
print(f"Freezing: {temp.is_freezing()}") # Output: Freezing: False
cold_temp = Temperature(-5.0)
print(f"Freezing: {cold_temp.is_freezing()}") # Output: Freezing: TrueДата-классы берут на себя повторяющиеся части (инициализацию, представление и сравнение), при этом позволяя вам добавлять пользовательские методы для ваших конкретных нужд, как показано выше на методах преобразования температуры.
33.4) Значения по умолчанию и параметры полей
Дата-классы поддерживают значения по умолчанию для атрибутов, позволяя создавать экземпляры без указания каждого параметра. Вы также можете использовать функцию field(), чтобы настраивать расширенное поведение, например исключать атрибуты из сравнения или управлять тем, как они отображаются в строковом представлении.
Задание значений по умолчанию
Вы можете назначать значения по умолчанию атрибутам прямо в определении класса. Атрибуты со значениями по умолчанию должны идти после атрибутов без значений по умолчанию:
from dataclasses import dataclass
@dataclass
class User:
username: str
email: str
is_active: bool = True # Значение по умолчанию
role: str = "user" # Значение по умолчанию
# Create instances with and without defaults
user1 = User("alice", "alice@example.com")
print(user1) # Output: User(username='alice', email='alice@example.com', is_active=True, role='user')
user2 = User("bob", "bob@example.com", False, "admin")
print(user2) # Output: User(username='bob', email='bob@example.com', is_active=False, role='admin')
# Use keyword arguments to override specific defaults
user3 = User("charlie", "charlie@example.com", role="moderator")
print(user3) # Output: User(username='charlie', email='charlie@example.com', is_active=True, role='moderator')Правило порядка (атрибуты без значений по умолчанию перед атрибутами со значениями по умолчанию) предотвращает неоднозначность в сгенерированном методе __init__. Это то же требование, что и для параметров функций со значениями по умолчанию, которое мы изучали в главе 20.
Изменяемые значения по умолчанию и почему они запрещены
Дата-классы защищают вас от распространённой ошибки с изменяемыми значениями по умолчанию. Если вы попытаетесь использовать изменяемый объект, например список или словарь, напрямую в качестве значения по умолчанию, вы получите ошибку:
from dataclasses import dataclass
# This will raise an error
@dataclass
class ShoppingCart:
customer: str
items: list = [] # ValueError: mutable default <class 'list'> for field items is not allowed: use default_factoryЭта ошибка предотвращает ту же проблему, которую мы видели с аргументами функций по умолчанию в главе 20, когда все экземпляры разделяли бы один и тот же изменяемый объект.
Использование field() с default_factory для изменяемых значений по умолчанию
Решение — использовать функцию field() с default_factory, которая создаёт новое значение по умолчанию для каждого экземпляра:
from dataclasses import dataclass, field
@dataclass
class ShoppingCart:
customer: str
items: list = field(default_factory=list) # Правильно: новый список для каждого экземпляра
# Now each instance gets its own list
cart1 = ShoppingCart("Alice")
cart1.items.append("Book")
print(cart1.items) # Output: ['Book']
cart2 = ShoppingCart("Bob")
print(cart2.items) # Output: [] - Bob has an empty list
cart2.items.append("Laptop")
print(cart1.items) # Output: ['Book'] - Alice's cart unchanged
print(cart2.items) # Output: ['Laptop'] - Bob's cart independentПараметр default_factory принимает функцию (например, list, dict или set), которая будет вызвана для создания нового значения по умолчанию каждый раз, когда вы создаёте экземпляр, не предоставляя этот атрибут. Например, default_factory=list означает, что Python вызовет list(), чтобы создать новый пустой список для каждого экземпляра.
Исключение полей из сравнения
Иногда вы хотите исключить некоторые атрибуты из сравнений на равенство. Для этого используйте field(compare=False):
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class LogEntry:
message: str
level: str
timestamp: datetime = field(compare=False) # Не сравнивать временные метки
# Create two log entries with the same message but different times
entry1 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 30))
entry2 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 35))
# They're equal because timestamp is excluded from comparison
print(entry1 == entry2) # Output: True
# But they have different timestamps
print(entry1.timestamp) # Output: 2024-01-15 10:30:00
print(entry2.timestamp) # Output: 2024-01-15 10:35:00Это полезно, когда у вас есть поля метаданных (например, временные метки, идентификаторы или внутренние счётчики), которые не должны влиять на то, считаются ли два экземпляра равными.
Исключение полей из представления
Вы также можете исключать поля из строкового представления с помощью field(repr=False):
from dataclasses import dataclass, field
@dataclass
class Account:
username: str
email: str
password: str = field(repr=False) # Не показывать пароль в repr
account = Account("alice", "alice@example.com", "secret123")
print(account) # Output: Account(username='alice', email='alice@example.com')
# Password is not shown, but it's still stored
print(account.password) # Output: secret123Это особенно полезно для чувствительных данных, таких как пароли, API-ключи или большие структуры данных, которые загромождали бы представление.
Как сделать дата-классы неизменяемыми с frozen=True
По умолчанию экземпляры дата-классов изменяемы — вы можете менять их атрибуты после создания. Если вы хотите неизменяемые экземпляры (как кортежи), используйте frozen=True:
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
point = Point(3.0, 4.0)
print(point) # Output: Point(x=3.0, y=4.0)
# Attempting to modify raises an error
try:
point.x = 5.0
except AttributeError as e:
print(f"Error: {e}") # Output: Error: cannot assign to field 'x'«Замороженные» дата-классы полезны, когда вы хотите обеспечить целостность данных или использовать экземпляры в качестве ключей словаря (поскольку ключи словаря должны быть неизменяемыми). Когда дата-класс заморожен, Python также генерирует метод __hash__, делая экземпляры хешируемыми:
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
# Frozen instances can be dictionary keys
locations = {
Coordinate(40.7128, -74.0060): "New York",
Coordinate(51.5074, -0.1278): "London",
Coordinate(35.6762, 139.6503): "Tokyo"
}
nyc = Coordinate(40.7128, -74.0060)
print(locations[nyc]) # Output: New York33.5) Пользовательская инициализация с __post_init__
Иногда вам нужно выполнить дополнительную настройку после того, как отработает сгенерированный метод __init__. Метод __post_init__ вызывается автоматически после инициализации, позволяя вам валидировать данные, вычислять производные атрибуты или выполнять другие задачи настройки.
Базовое использование __post_init__
Метод __post_init__ вызывается после того, как все атрибуты были установлены сгенерированным __init__:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
area: float = 0.0 # Будет вычислено в __post_init__
def __post_init__(self):
"""Calculate area after initialization."""
self.area = self.width * self.height
rect = Rectangle(5.0, 3.0)
print(rect) # Output: Rectangle(width=5.0, height=3.0, area=15.0)
print(f"Area: {rect.area}") # Output: Area: 15.0Метод __post_init__ имеет доступ ко всем атрибутам экземпляра, которые были установлены во время инициализации. Это полезно для вычисления производных значений, которые зависят от нескольких атрибутов.
Валидация данных в post_init
Распространённый сценарий использования __post_init__ — проверять, что предоставленные данные соответствуют определённым требованиям:
from dataclasses import dataclass
@dataclass
class BankAccount:
account_number: str
balance: float
def __post_init__(self):
"""Validate account data."""
if self.balance < 0:
raise ValueError("Balance cannot be negative")
# Valid account
account1 = BankAccount("ACC001", 1000.0)
print(account1) # Output: BankAccount(account_number='ACC001', balance=1000.0)
# Invalid account - negative balance
try:
account2 = BankAccount("ACC002", -500.0)
except ValueError as e:
print(f"Error: {e}") # Output: Error: Balance cannot be negativeЭта валидация гарантирует, что экземпляры всегда находятся в корректном состоянии. Если данные не соответствуют требованиям, экземпляр никогда не создаётся, предотвращая существование некорректных объектов в вашей программе.
Использование post_init с field(init=False)
Иногда вам нужен атрибут, который вычисляется в __post_init__, но не должен быть параметром в __init__. Для этого используйте field(init=False):
from dataclasses import dataclass, field
import math
@dataclass
class Circle:
radius: float
area: float = field(init=False) # Не параметр в __init__
circumference: float = field(init=False)
def __post_init__(self):
"""Compute area and circumference from radius."""
self.area = math.pi * self.radius ** 2
self.circumference = 2 * math.pi * self.radius
# Only radius is required during initialization
circle = Circle(5.0)
print(circle) # Output: Circle(radius=5.0, area=78.53981633974483, circumference=31.41592653589793)
print(f"Area: {circle.area:.2f}") # Output: Area: 78.54
print(f"Circumference: {circle.circumference:.2f}") # Output: Circumference: 31.42Этот шаблон полезен, когда у вас есть атрибуты, которые всегда вычисляются из других атрибутов и никогда не должны задаваться напрямую во время инициализации.
Дата-классы представляют собой современную возможность Python, которая сокращает шаблонный код, сохраняя при этом всю мощь классов. Они особенно ценны для создания чистого, читаемого кода при работе со структурированными данными. По мере того как вы продолжаете изучать Python, вы обнаружите, что дата-классы становятся естественным выбором для многих ориентированных на данные задач программирования, дополняя обычные классы, которые вы изучили в главах 30–32.