Python & AI Tutorials Logo
Программирование Python

33. Дата-классы для простых структурированных данных

В главе 30 мы научились создавать классы, чтобы определять собственные типы. Мы писали методы __init__, чтобы инициализировать экземпляры, методы __repr__, чтобы отображать их, и методы __eq__, чтобы сравнивать их. Хотя этот подход работает отлично, он требует написания большого количества повторяющегося кода, особенно когда класс в основном существует для хранения данных.

Дата-классы (data classes) в Python предоставляют более чистый и лаконичный способ создавать классы, которые в основном являются контейнерами для данных. Используя декоратор @dataclass, Python автоматически генерирует распространённые методы, такие как __init__, __repr__ и __eq__, на основе атрибутов класса, которые вы определяете. Это уменьшает шаблонный код и делает ваши намерения более понятными.

33.1) Что такое дата-классы и когда их использовать

Дата-класс (data class) — это класс, предназначенный прежде всего для хранения значений данных. Вместо того чтобы вручную писать методы инициализации и сравнения, вы определяете атрибуты, которые должен иметь ваш класс, и Python автоматически генерирует необходимые методы.

Почему дата-классы важны

Рассмотрим обычный класс для представления книги:

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 просто автоматизирует создание распространённых методов, избавляя вас от написания повторяющегося кода.

Обычный класс

Ручной init

Ручной repr

Ручной eq

Пользовательские методы

Дата-класс

Декоратор @dataclass

Автосгенерированный init

Автосгенерированный repr

Автосгенерированный eq

Пользовательские методы

33.2) Создание дата-классов с помощью @dataclass

Чтобы создать дата-класс, вы импортируете декоратор dataclass из модуля dataclasses и применяете его к определению класса. Внутри класса вы определяете атрибуты класса с аннотациями типов, которые указывают, какие данные должен хранить класс.

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

python
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:

  1. @dataclass: применение этого декоратора заставляет Python автоматически написать для вас методы __init__, __repr__ и __eq__

  2. Автоматический __init__: Python создаёт метод инициализации, который принимает эти три параметра в порядке их определения и присваивает их атрибутам экземпляра

  3. Автоматический __repr__: Python создаёт строковое представление, показывающее имя класса и значения всех атрибутов

  4. Автоматический __eq__: Python создаёт метод сравнения на равенство, который сравнивает все атрибуты

  5. Преобразует аннотации типов в атрибуты экземпляра: в обычном классе запись name: str в теле класса создаёт атрибут класса. Но декоратор @dataclass меняет это поведение — он использует эти аннотации типов, чтобы вместо этого определить атрибуты экземпляра. Каждый экземпляр получает собственные атрибуты name, student_id и gpa.

Ключевое отличие от обычных классов:

python
# 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

Понимание аннотаций типов в дата-классах

В дата-классах аннотации типов определяют атрибуты и документируют ожидаемые типы:

python
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 str

Python не будет мешать вам передавать неверные типы при создании экземпляра дата-класса. Аннотации типов в основном служат документацией — они сообщают другим программистам (и инструментам проверки типов, таким как mypy), какие типы вы ожидаете, но Python не применяет их во время выполнения. Это согласуется с философией динамической типизации 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 генерирует его на основе порядка определения ваших атрибутов:

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__ эквивалентен следующему коду:

python
def __init__(self, width: float, height: float):
    self.width = width
    self.height = height

Эта автоматическая генерация избавляет вас от написания повторяющегося кода инициализации, особенно для классов с большим количеством атрибутов.

Сгенерированный метод __repr__

Метод __repr__ предоставляет строковое представление экземпляра, показывающее значения всех атрибутов. Это крайне полезно для отладки и логирования:

python
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__ позволяет сравнивать экземпляры на равенство. Два экземпляра дата-класса считаются равными, если равны все их соответствующие атрибуты:

python
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__ сравнивает атрибуты в порядке их определения:

python
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

Сравнение сгенерированных методов с ручной реализацией

Чтобы оценить, что дают дата-классы, сравним версию дата-класса с ручной реализацией:

python
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 генерирует только методы инициализации, представления и сравнения на равенство — вы свободны добавлять любую другую функциональность:

python
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(), чтобы настраивать расширенное поведение, например исключать атрибуты из сравнения или управлять тем, как они отображаются в строковом представлении.

Задание значений по умолчанию

Вы можете назначать значения по умолчанию атрибутам прямо в определении класса. Атрибуты со значениями по умолчанию должны идти после атрибутов без значений по умолчанию:

python
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.

Изменяемые значения по умолчанию и почему они запрещены

Дата-классы защищают вас от распространённой ошибки с изменяемыми значениями по умолчанию. Если вы попытаетесь использовать изменяемый объект, например список или словарь, напрямую в качестве значения по умолчанию, вы получите ошибку:

python
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, которая создаёт новое значение по умолчанию для каждого экземпляра:

python
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):

python
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):

python
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:

python
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__, делая экземпляры хешируемыми:

python
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 York

33.5) Пользовательская инициализация с __post_init__

Иногда вам нужно выполнить дополнительную настройку после того, как отработает сгенерированный метод __init__. Метод __post_init__ вызывается автоматически после инициализации, позволяя вам валидировать данные, вычислять производные атрибуты или выполнять другие задачи настройки.

Базовое использование __post_init__

Метод __post_init__ вызывается после того, как все атрибуты были установлены сгенерированным __init__:

python
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__ — проверять, что предоставленные данные соответствуют определённым требованиям:

python
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):

python
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.


© 2025. Primesoft Co., Ltd.
support@primesoft.ai