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

Значимым компонентом любого проекта в программировании являются данные, с которыми неизбежно взаимодействуют все программы. Например, при разработке веб-сайта вы должны представить тексты и изображения в удобном для пользователя виде. Если же в вашем проекте для прогноза тенденций в области финансов используется МО, то не обойтись без подготовки данных для обучения ваших моделей. 

Будучи многофункциональным языком программирования, Python располагает универсальным набором инструментов, поддерживающих управление данными в проектах. В их число входит такой полезный и простой в освоении метод, как классы данных. В этой статье вы узнаете об основных особенностях этих классов и научитесь эффективно задействовать их в своей работе. 

1. Определение класса данных 

Функциональность класса данных определяется в модуле dataclasses, который входит в состав стандартной библиотеки Python. Его важнейшей частью является декоратор dataclass. Следующий код иллюстрирует применение этого декоратора с пользовательским классом, который мы определили для управления счетами в ресторане.

from dataclasses import dataclass

@dataclass
class Bill:
    table_number: int
    meal_amount: float
    served_by: str
    tip_amount: float = 0.0

Говоря точнее, с помощью декоратора dataclass мы преобразуем определяемый пользовательский класс и устанавливаем в нем атрибуты, иначе называемые полями. 

У вас может возникнуть вопрос о целесообразности применения dataclass. Аргументом в его пользу послужит тот факт, что он помогает избавиться от таких стандартных методов, как __init__ и __repr__. Другими словами, благодаря декоратору нам не приходится реализовывать эти методы, поскольку он это делает за нас, как показано ниже в примере инициализации и представления:

>>> bill0 = Bill(table_number=5, meal_amount=50.5, served_by="John", tip_amount=7.5)
>>> print(f"Today's first bill: {bill0!r}")
Today's first bill: Bill(table_number=5, meal_amount=50.5, served_by='John', tip_amount=7.5)

Как видим, несмотря на отсутствие явного определения, методы инициализации и представления реализуются идиоматически.

2. Аннотации типов и значения полей по умолчанию 

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

Итак, поля класса данных должны содержать аннотации типов. В противном случае ваш код не будет выполняться. Внутренне декоратор dataclass генерирует поля на основе аннотаций, которые можно получить с помощью специального метода __annotations__

>>> Bill.__annotations__
{'table_number': <class 'int'>, 'meal_amount': <class 'float'>, 'served_by': <class 'str'>, 'tip_amount': <class 'float'>}

Также следует обратить внимание на то, что определяя поля для класса данных, вы можете установить для них значения по умолчанию. Как вы могли заметить, поле tip_amount содержит подобное значение  —  0.0. При этом важно помнить, что если значения по умолчанию определены не для всех полей, то поля, имеющие эти значения, должны следовать за полями без таковых. Иначе вы можете столкнуться со следующей ошибкой: 

@dataclass
... class SpecialBill:
...     table_number: int = 888
...     meal_amount: float
...     served_by: str
...     tip_amount: float = 0.0
... 
Traceback (most recent call last):
  ...some traceback info omitted
TypeError: non-default argument 'meal_amount' follows default argument

3. Проверка равенства/неравенства 

Помимо методов инициализации и представления декоратор dataclass также обеспечивает выполнение операций сравнения. Как известно, в случае с обычным пользовательским классом мы не можем полноценно сравнивать экземпляры без определения соответствующего поведения. Рассмотрим следующий пользовательский класс без dataclass:

>>> old_bill0 = OldBill(table_number=3, meal_amount=20.5, served_by="Dan", tip_amount=5)
... old_bill1 = OldBill(table_number=3, meal_amount=20.5, served_by="Dan", tip_amount=5)
... print("Comparison Between Regular Instances:", old_bill0 == old_bill1)
... 
Comparison Between Regular Instances: False
>>> bill0 = Bill(table_number=5, meal_amount=50.5, served_by="John", tip_amount=7.5)
... bill1 = Bill(table_number=5, meal_amount=50.5, served_by="John", tip_amount=7.5)
... print("Comparison Between Data Class Instances:", bill0 == bill1)
... 
Comparison Between Data Class Instances: True

Как показано выше, в обычном классе два экземпляра с одинаковыми значениями всех атрибутов рассматриваются как неравные, поскольку по умолчанию сравниваются их идентичности. В данном случае эти два экземпляра являются разными объектами, в связи с чем и считаются неравными.  

Однако в классе данных при таком же способе сравнения равенство подтверждается True. Объясняется это тем, что декоратор dataclass также автоматически сгенерирует специальный метод __eq__. Дело в том, что при выполнении сравнения для выявления равенства каждый из этих экземпляров рассматривается как кортеж, содержащий поля в установленном порядке. И поскольку два экземпляра класса данных имеют поля с одинаковыми значениями, то они считаются равными. 

Теперь обратимся к проверкам неравенства, например > и <. С декоратором dataclass у нас появляется возможность их выполнения путем установки параметра order, как показано ниже в строке 1: 

>>> @dataclass(order=True)
... class ComparableBill:
...     meal_amount: float
...     tip_amount: float
... 
... 
... bill1 = ComparableBill(meal_amount=50.5, tip_amount=7.5)
... bill2 = ComparableBill(meal_amount=50.5, tip_amount=8.0)
... bill3 = ComparableBill(meal_amount=60, tip_amount=10)
... print("Is bill1 less than bill2?", bill1 < bill2)
... print("Is bill2 less than bill3?", bill2 < bill3)
... 
Is bill1 less than bill2? True
Is bill2 less than bill3? True

Аналогично проверкам равенства экземпляры класса данных сравниваются так, как будто являются кортежами своих полей, при этом сравнение производится лексикографически. С целью подтверждения этого в вышеприведенный код включены только два поля, и, как видно, результаты сравнения основываются на порядке внутри кортежа. 

4. Проблема изменяемости 

По умолчанию поля классов данных можно менять, а значит, их экземпляры являются изменяемыми. Поэтому классы данных иногда называют изменяемыми именованными кортежами. Посмотрим, чем отличаются простые именованные кортежи от классов данных в плане своей изменяемости: 

>>> from collections import namedtuple
>>> NamedTupleBill = namedtuple("NamedBill", "meal_amount tip_amount")
>>> 
>>> @dataclass
... class DataClassBill:
...     meal_amount: float
...     tip_amount: float
... 
>>> namedtuple_bill = NamedTupleBill(20, 5)
>>> dataclass_bill = DataClassBill(20, 5)
>>> namedtuple_bill.tip_amount = 6
Traceback (most recent call last):
  File "<input>", line 1, in <module>
AttributeError: can't set attribute
>>> dataclass_bill.tip_amount = 6

Как следует из примера выше, мы не можем установить атрибут именованного кортежа. Зато это можно сделать применительно к экземпляру класса данных, что подчеркивает преимущества поддержки изменяемости таких классов. 

Однако изменяемость данных не всегда кстати, и в ряде случаев хотелось бы обойтись без нее. Для этого, применяя декоратор dataclass, установите для параметра frozen значение True. Ниже рассмотрим простой пример “замороженного” экземпляра класса данных: 

>>> @dataclass(frozen=True)
... class ImmutableBill:
...     meal_amount: float
...     tip_amount: float
... 
>>> immutable_bill = ImmutableBill(20, 5)
>>> immutable_bill.tip_amount = 7
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<string>", line 4, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'tip_amount'

5. Проблемы наследования 

По своей сути, класс данных обладает такой же расширяемостью, что и обычный пользовательский класс. Следовательно, при необходимости на его основе мы можем создать его подкласс.

>>> @dataclass
... class BaseBill:
...     meal_amount: float
... 
>>> 
>>> @dataclass
... class TippedBill(BaseBill):
...     tip_amount: float
... 
>>> tipped_bill = TippedBill(meal_amount=20, tip_amount=5)

Как следует из данного фрагмента кода, декоратор dataclass задействует поля базового класса для создания подкласса. Другими словами, инициализация подкласса свидетельствует о том, что он автоматически получает сигнатуру полей базового класса и самого подкласса. Обратите внимание, что при наличии нескольких уровней потомков очередность основана на порядке определения  —  сначала идут поля базового класса, за которыми следуют поля подкласса и так далее.

Принимая во внимание очередность этих полей в подклассе и требование, согласно которому поля класса данных со значениями по умолчанию должны предшествовать полям без них, логично предположить, что при наличии у базового класса полей с предопределяемыми значениями добавленные поля подклассов тоже должны иметь такие значения. В противном случае вы можете увидеть ошибку, которая уже встречалась нам ранее: 

@dataclass
... class BaseBill:
...     meal_amount: float = 20
... 
@dataclass
... class TippedBill(BaseBill):
...     tip_amount: float
... 
Traceback (most recent call last):
  ... some traceback info omitted
TypeError: non-default argument 'tip_amount' follows default argument

6. Изменяемые поля со значениями по умолчанию 

В предшествующих примерах все поля являются изменяемыми типами данных, например float (число с плавающей точкой) и string (строка). Что же стоит предпринять при необходимости использовать изменяемые данные в качестве полей класса данных? Рассмотрим следующий фрагмент кода, содержащий ряд возможных ошибок:  

>>> class IncorrectBill:
...     def __init__(self, costs_by_dish=[]):
...         self.costs_by_dish = costs_by_dish
... 
>>> bill0 = IncorrectBill()
>>> bill1 = IncorrectBill()
>>> bill0.costs_by_dish.append(5)
>>> bill1.costs_by_dish.append(7)
>>> print("Bill 0 costs:", bill0.costs_by_dish)
Bill 0 costs: [5, 7]
>>> print("Bill 1 costs:", bill1.costs_by_dish)
Bill 1 costs: [5, 7]
>>> bill0.costs_by_dish is bill1.costs_by_dish
True

Данный код показывает, что в случае применения в функции предустановленного изменяемого значения, фактически вы не можете установить такое значение с помощью изменяемого экземпляра, поскольку этот объект будет совместно использоваться всеми вызывающими компонентами, в которых данный параметр не определяется. В вышеуказанном примере оба экземпляра задействуют одинаковый объект списка, что приводит к нежелательным побочным эффектам.

В контексте классов данных актуален вопрос о том, как мы можем определить значение по умолчанию для изменяемых полей. Следующий пример демонстрирует, как не следует делать, поскольку в этом случае будет выброшено исключение ValueError:

@dataclass
class IncorrectBill:
    costs_by_dish: list = []ValueError: mutable default <class 'list'> for field costs_by_dish is not allowed: use default_factory

Сообщение об ошибке подсказывает решение, подразумевающее применение default_factory при определении поля. Вот пример: 

>>> from dataclasses import field
... @dataclass
... class CorrectBill:
...     costs_by_dish: list = field(default_factory=list)
... 
>>> bill0 = CorrectBill()
>>> bill0.costs_by_dish.append(5)
>>> bill1 = CorrectBill()
>>> bill1.costs_by_dish.append(7)
>>> print("Bill 0 costs:", bill0.costs_by_dish)
Bill 0 costs: [5]
>>> print("Bill 1 costs:", bill1.costs_by_dish)
Bill 1 costs: [7]
>>> bill0.costs_by_dish is bill1.costs_by_dish
False
  • Из модуля dataclasses импортируем функцию field.
  • В функции field определяем list в качестве параметра default_factory. По сути, этот параметр устанавливает функцию default_factory, т. е. функцию с нулевым параметром, использующуюся при создании экземпляра. В данном случае функция list является методом конструктора для объекта списка, создающим этот список при вызове list().
  • Как видим, оба экземпляра располагают разными объектами списков для атрибута costs_by_dish, на что мы и рассчитывали. 

Заключение 

В данной статье был проведен обзор 6 самых основных особенностей классов данных в Python. В сущности, они весьма эффективны для хранения данных, а декоратор dataclass выполняет за нас все рутинные процедуры. По сравнению с именованными кортежами классы данных отличаются большей гибкостью благодаря изменяемости.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Yong Cui: 6 Things to Know to Get Started With Python Data Classes

Предыдущая статьяКастомизируем дефолтную заставку во Flutter
Следующая статьяКак в два счета сделать сайт редактируемым извне с помощью данных Google Sheets