[Перевод] 9 причин использовать dataclasses в Python
Начиная с версии 3.7 в Python представлены dataclasses
(см. PEP 557), новый функционал, определяющий классы, содержащие и инкапсулирующие данные.
Недавно я начал использовать этот модуль в нескольких Data Science-проектах, и мне понравилось. Навскидку этому есть две причины:
Меньше шаблонного кода;
Лучшая читабельность и более простая поддержка кода.
В этом материале я обобщил свои первые впечатления. Я буду опираться на них, чтобы рассказать о dataclasses
и о том, какие проблемы они решают и о 9 приятных механиках, которые они предоставляют.
Иногда я буду сравнивать классы, написанные с помощью dataclasses
, с собственными реализациями и выявлять различия.
Меньше слов, больше кода. Поехали!
P.S.: Я не буду рассказывать все о dataclasses, но тот функционал, который мы рассмотрим, должен ввести вас в курс дела. Тем не менее, если вы хотите получить более полное представление, обратитесь к ссылкам в разделе «Источники» в конце статьи.
0 — Dataclasses: общая картина
Dataclasses
, как ясно следует из названия — это классы, предназначенные для хранения данных. Основная идея заключается в том, что мы иногда определяем классы, которые действуют только как контейнеры данных, и когда так случается, мы тратим значительное количество времени на написание шаблонного кода с кучей аргументов, уродливым методом __init__
и множеством переопределенных функций.
Dataclasses
решают эту проблему, предоставляя из коробки ряд дополнительных полезных методов. Более того, поскольку dataclasses
относительно новые для экосистемы Python, в них применяются современные практики, такие как аннотации типов.
Dataclasses
остаются классами. Поэтому в них вы можете реализовать любые кастомные методы точно также, как в обычном классе. А теперь посмотрим на них в действии.
1 — Меньше кода для определения класса
Когда мы определяем класс для хранения некоторых атрибутов, выглядит это примерно так:
class Person():
def __init__(self, first_name, last_name, age, job):
self.first_name = first_name
self.last_name = last_name
self.age = age
self.job = job
Вот стандартный синтаксис Python.
Когда вы используете dataclasses
, вам сначала нужно импортировать dataclass
, а затем использовать его как декоратор перед определяемым классом.
Вот так выглядит предыдущий код с использованием dataclasses
:
from dataclasses import dataclass
@dataclass
class Person:
first_name: str
last_name: str
age: int
job: str
В синтаксисе нужно обратить внимание на несколько моментов:
Получилось меньше шаблонного кода: мы определяем каждый атрибут один раз и не повторяемся.
Мы используем аннотацию типов для каждого атрибута. Хотя она и не позволяет проверять типы принудительно, но помогает вашему текстовому редактору обеспечивать лучшую компоновку, если вы используете средство проверки типов, как mypy, например.
Ваш код все равно будет работать, даже если вы используете другие типы, но редактор кода предупредит о несоответствиях.
Dataclasses
не просто позволяют вам писать более компактный код. Декоратор dataclass
на самом деле является генератором кода, который автоматически добавит недостающие методы. Если мы используем модуль inspect
, чтобы проверить, какие методы были добавлены в класс Person
, то увидим методы init
, eq
и repr
: они отвечают за установку значений атрибутов, проверку на равенство и представление объектов в удобном текстовом формате.
Если бы нам понадобилось поддерживать сортировку в классе Person
(см. совет 9), у нас появились бы следующие методы:
__ge__
: больше или равно__gt__
: больше, чем__le__
: меньше или равно__lt__
меньше, чем
2 — Поддержка значений по умолчанию
Вы можете добавить значения по умолчанию для каждого атрибута, сохранив аннотацию.
from dataclasses import dataclass
@dataclass
class Person:
first_name: str = "Ahmed"
last_name: str = "Besbes"
age: int = 30
job: str = "Data Scientist"
Помните о том, что поля без значений по умолчанию не могут стоять после полей со значениями по умолчанию. Например, следующий код работать не будет:
from dataclasses import dataclass
@dataclass
class Person:
first_name: str = "Ahmed"
last_name: str = "Besbes"
age: int = 30
job: str = "Data Scientist"
hobbies: str
3 — Кастомное представление объектов
Благодаря методу repr
, добавленному dataclasses
, экземпляры имеют приятное, удобочитаемое представление при выводе на экран.
Да и отладка так становится проще.
@dataclass
class Person:
first_name: str = "Ahmed"
last_name: str = "Besbes"
age: int = 30
job: str = "Data Scientist"
ahmed = Person()
print(ahmed)
# Person(first_name='Ahmed', last_name='Besbes', age=30, job='Data Scientist')
Представление можно переопределить для вывода любого кастомного сообщения, которое вам может понадобиться.
@dataclass
class Person:
first_name: str = "Ahmed"
last_name: str = "Besbes"
age: int = 30
job: str = "Data Scientist"
def __repr__(self):
return f"{self.first_name} {self.last_name} ({self.age})"
ahmed = Person()
print(ahmed)
# Ahmed Besbes (30)
4 — Упрощенная конвертация в кортеж или словарь
Экземпляры можно легко сериализовать в словари или кортежи. Механика оказывается полезной, когда ваш код взаимодействует с другими программами, которые ожидают именно эти типы.
from dataclass import astuple, asdict
ahmed = Person()
print(astuple(ahmed)
# ('Ahmed', 'Besbes', 30, 'Data Scientist')
print(asdict(ahmed)
# {'first_name': 'Ahmed',
# 'last_name': 'Besbes',
# 'age': 30,
# 'job': 'Data Scientist'}
5 — Замороженные экземпляры/неизменяемые объекты
С помощью dataclasses
можно создавать объекты, доступные только для чтения. Все, что нужно сделать — установить значение frozen
в True
внутри декоратора @dataclass перед нужным классом.
@dataclass(frozen=True)
class Person:
first_name: str = "Ahmed"
last_name: str = "Besbes"
age: int = 30
job: str = "Data Scientist"
Как только вы так сделаете, вы запретите кому-либо изменять значения атрибутов после создания экземпляра класса.
Если вы попробуете установить для атрибута замороженного экземпляра новое значение, получить ошибку FrozenInstanceError
.
6 — Не нужно писать методы сравнения
Когда вы определяете класс с использованием стандартного синтаксиса Python и прописываете равенство между двумя экземплярами, имеющими одинаковые значения атрибутов, вы получаете следующую картину:
class Person():
def __init__(self, first_name, last_name, age, job):
self.first_name = first_name
self.last_name = last_name
self.age = age
self.job = job
first_person = Person("Ahmed", "Besbes", 30, "Data scientist")
second_person = Person("Ahmed", "Besbes", 30, "Data scientist")
print(first_person == second_person)
# False ❌
Два объекта не равны и это нормально, поскольку класс Person
фактически не реализует метод проверки равенства. Чтобы прописать равенство, вам придется самостоятельно реализовать метод __eq__
. Выглядеть он может так:
def __eq__(self, other):
if other.__class__ is not self.__class__:
return NotImplemented
return (self.first_name,
self.last_name,
self.age,
self.job) == (other.first_name,
other.last_name,
other.age,
other.job)
Метод сначала проверяет, что два объекта являются экземплярами одного и того же класса, а затем проверяет равенство между кортежами атрибутов.
Теперь, если вы захотите добавить новые атрибуты в класс, вам придется снова обновлять метод eq. Аналогично с методами __ge__
, __gt__
, __le__
и __lt__
, если они используются.
Очень похоже на лишний код, не так ли? К счастью, dataclasses
и тут нам помогут.
@dataclass
class Person:
first_name: str = "Ahmed"
last_name: str = "Besbes"
age: int = 30
job: str = "Data Scientist"
first_person = Person()
second_person = Person()
print(first_person == second_person)
# True ✅
7 — Настраиваемое поведение атрибута с функцией field
Иногда вам может потребоваться атрибут, который определяется изнутри, а не при создании экземпляра класса. Такое бывает, если атрибут имеет значение, зависящее от ранее заданных атрибутов.
Тут вам на помощь приходит функция field
из dataclasses
.
С помощью этой функции и установки аргументов itsinit
и repr
в False
при создании нового поля с именем full_name
, мы все равно сможем создать экземпляр класса Person
без установки атрибута full_name
.
from dataclasses import dataclass, field
@dataclass
class Person:
first_name: str = "Ahmed"
last_name: str = "Besbes"
age: int = 30
job: str = "Data Scientist"
full_name: str = field(init=False, repr=False)
Этого атрибута еще нет в экземпляре. Если мы попробуем получить к нему доступ, то получим AttributeError
.
Как установить значение full_name
и при этом оставить его снаружи конструктора класса? Для этого нам придется использовать метод __post_init__
.
8 — Метод post_init
В dataclasses есть специальный метод __post_init__
.
Как следует из названия, этот метод вызывается сразу после метода __init__
.
Возвращаясь к предыдущему примеру, мы видим, как этот метод можно вызвать для инициализации внутреннего атрибута, который зависит от ранее заданных атрибутов.
@dataclass
class Person:
first_name: str = "Ahmed"
last_name: str = "Besbes"
age: int = 30
job: str = "Data Scientist"
full_name: str = field(init=False, repr=True)
def __post_init__(self):
self.full_name = self.first_name + " " + self.last_name
ahmed = Person()
print(ahmed)
# Person(first_name='Ahmed', last_name='Besbes', age=30, job='Data Scientist', full_name='Ahmed Besbes')
ahmed.full_name
#'Ahmed Besbes'
Обратите внимание, что аргументу repr
внутри функции field
присвоено значение True
, чтобы он был виден при выводе объекта. В предыдущем примере мы не смогли установить для этого аргумента значение True
, поскольку атрибут full_name
еще не был создан.
9 — Сравнение объектов и их сортировка
Одной из полезных механик, которую необходимо реализовать при работе с объектами, содержащими данные, является возможность сравнивать их и сортировать в любом желаемом порядке.
По умолчанию dataclasses реализуют __eq__
. Чтобы работали другие виды сравнения (__lt__
(меньше), __le__
(меньше или равно), __gt__
(больше) и __ge__
(больше или равно)), мы должны установить аргумент order
в значение True
в декораторе @dataclass.
@dataclasses(order=True)
Реализация этих методов подразумевает следующее: берется определенное поле и сравнивается с другими, в том порядке, в котором они определены, до тех пор, пока не будет найдено неравное значение.
Давайте вернемся к классу Person
. Допустим, мы хотим сравнить экземпляры этого класса по атрибуту возраста (что имеет смысл, не так ли?).
Для этого нам нужно будет добавить поле, которое мы назовем sort_index
, и установим его значение равным значению age
.
И мы сделаем это, вызвав метод __post_init__
, который мы видели в предыдущем примере.
from dataclasses import dataclass, field
@dataclass(order=True)
class Person:
first_name: str = "Ahmed"
last_name: str = "Besbes"
age: int = 30
job: str = "Data Scientist"
sort_index: int = field(init=False, repr=False)
def __post_init__(self):
self.sort_index = self.age
p1 = Person(age=30)
p2 = Person(age=20)
print(p1 > p2)
# True
Теперь экземпляры класса Person
можно отсортировать по возрасту.
Подведем итог
Dataclasses
предоставляют множество механик, которые позволяют легко работать с классами — контейнерами данных.
В частности, этот модуль помогает:
Писать меньше шаблонного кода;
Представлять объекты в удобочитаемом формате;
Реализовывать собственный порядок сравнения;
Предоставлять быстрый доступ к атрибутам и проверять их;
Использовать специальные методы, такие как
__post_init__
для выполнения инициализации атрибутов, которые зависят от значений других атрибутов;Определять внутренние поля и т.д.
Источники:
Пока я изучал dataclasses
, я просмотрел множество ресурсов (статьи в блогах, видео на YouTube, PEP, официальную документацию Python).
Вот мой личный список самых интересных постов и видео, которые я нашел:
Материал подготовлен для будущих учащихся по программе специализации Python Developer; также приглашаем всех заинтересованных на открытый урок »Декораторы в Python». На уроке познакомимся с Декораторами, узнаем, что они из себя представляют и как работают, а также научимся создавать их самостоятельно.