Как использовать классы данных Python в 2023 году
В Python класс данных - это класс, предназначенный только для хранения значений данных. Они ничем не отличаются от обычных классов, но обычно не имеют других методов. Обычно они используются для хранения информации, которая будет передаваться между различными частями программы или системы.
Однако при создании классов, которые будут работать только как контейнеры данных, многократное написание метода __init__
может привести к большому объему работы и потенциальным ошибкам.
Модуль dataclasses
, появившийся в Python 3.7, предоставляет возможность создавать классы данных более простым способом, без необходимости писать методы.
В этой статье мы рассмотрим, как использовать преимущества этого модуля для быстрого создания новых классов, которые уже содержат не только __init__
, но и несколько других уже реализованных методов, что избавляет нас от необходимости реализовывать их вручную. Кроме того, мы можем сделать это всего в нескольких строках кода.
Мы ожидаем, что у вас есть некоторый промежуточный опыт работы на языке python, включая понимание того, как создавать классы и объектно-ориентированное программирование в целом.
Использование модуля dataclasses
В качестве начального примера предположим, что мы реализуем класс для хранения данных о некоторой группе людей. Для каждого человека мы будем иметь такие атрибуты, как имя, возраст, рост и адрес электронной почты. Вот как выглядит обычный класс:
class Person():
def __init__(self, name, age, height, email):
self.name = name
self.age = age
self.height = height
self.email = email
Однако если мы используем модуль dataclasses
, то нам необходимо импортировать dataclass
, чтобы использовать его в качестве декоратора в создаваемом классе. При этом нам уже не нужно писать функцию __init__
, достаточно указать атрибуты класса и их типы. Вот тот же самый класс Person
, реализованный таким образом:
from dataclasses import dataclass
@dataclass
class Person():
name: str
age: int
height: float
email: str
Мы также можем задать значения по умолчанию для атрибутов класса:
@dataclass
class Person():
name: str = 'Joe'
age: int = 30
height: float = 1.85
email: str = 'joe@dataquest.io'
print(Person())
Person(name='Joe', age=30, height=1.85, email='joe@dataquest.io')
Напоминаем, что Python не принимает атрибут не по умолчанию после default ни в классах, ни в функциях, так что это приведет к ошибке:
@dataclass
class Person():
name: str = 'Joe'
age: int = 30
height: float = 1.85
email: str
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
~\AppData\Local\Temp/ipykernel_5540/741473360.py in <module>
1 @dataclass
----> 2 class Person():
3 name: str = 'Joe'
4 age: int = 30
5 height: float = 1.85
~\anaconda3\lib\dataclasses.py in dataclass(cls, init, repr, eq, order, unsafe_hash, frozen)
1019
1020 # We're called as @dataclass without parens.
-> 1021 return wrap(cls)
1022
1023
~\anaconda3\lib\dataclasses.py in wrap(cls)
1011
1012 def wrap(cls):
-> 1013 return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)
1014
1015 # See if we're being called as @dataclass or @dataclass().
~\anaconda3\lib\dataclasses.py in _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)
925 if f._field_type in (_FIELD, _FIELD_INITVAR)]
926 _set_new_attribute(cls, '__init__',
--> 927 _init_fn(flds,
928 frozen,
929 has_post_init,
~\anaconda3\lib\dataclasses.py in _init_fn(fields, frozen, has_post_init, self_name, globals)
502 seen_default = True
503 elif seen_default:
--> 504 raise TypeError(f'non-default argument {f.name!r} '
505 'follows default argument')
506
TypeError: non-default argument 'email' follows default argument
После того как класс определен, легко инстанцировать новый объект и получить доступ к его атрибутам, как и в случае с обычным классом:
person = Person('Joe', 25, 1.85, 'joe@dataquest.io')
print(person.name)
Joe
До сих пор мы использовали обычные типы данных, такие как string, integer и float; мы также можем комбинировать dataclass
с модулями typing
для создания атрибутов любого типа в классе. Например, добавим атрибут house_coordinates
к классу Person
:
from typing import Tuple
@dataclass
class Person():
name: str
age: int
height: float
email: str
house_coordinates: Tuple
print(Person('Joe', 25, 1.85, 'joe@dataquest.io', (40.748441, -73.985664)))
Person(name='Joe', age=25, height=1.85, email='joe@dataquest.io', house_coordinates=(40.748441, -73.985664))
Следуя той же логике, мы можем создать класс данных для хранения нескольких экземпляров класса Person
:
from typing import List
@dataclass
class People():
people: List[Person]
Обратите внимание, что атрибут people
в классе People
определяется как список экземпляров класса Person
. Например, мы можем инстанцировать объект People
следующим образом:
joe = Person('Joe', 25, 1.85, 'joe@dataquest.io', (40.748441, -73.985664))
mary = Person('Mary', 43, 1.67, 'mary@dataquest.io', (-73.985664, 40.748441))
print(People([joe, mary]))
People(people=[Person(name='Joe', age=25, height=1.85, email='joe@dataquest.io', house_coordinates=(40.748441, -73.985664)), Person(name='Mary', age=43, height=1.67, email='mary@dataquest.io', house_coordinates=(-73.985664, 40.748441))])
Это позволяет нам определить атрибут как любой тип, но также и комбинацию типов данных.
Представление и сравнение
Как мы уже говорили, dataclass
реализует не только метод __init__
, но и некоторые другие, в том числе и метод __repr__
. В обычном классе мы используем этот метод для отображения представления объекта в классе.
Например, при вызове объекта мы определим метод, как в приведенном ниже примере:
class Person():
def __init__(self, name, age, height, email):
self.name = name
self.age = age
self.height = height
self.email = email
def __repr__(self):
return (f'{self.__class__.__name__}(name={self.name}, age={self.age}, height={self.height}, email={self.email})')
person = Person('Joe', 25, 1.85, 'joe@dataquest.io')
print(person)
Person(name=Joe, age=25, height=1.85, email=joe@dataquest.io)
При использовании dataclass
, однако, ничего этого писать не нужно:
@dataclass
class Person():
name: str
age: int
height: float
email: str
person = Person('Joe', 25, 1.85, 'joe@dataquest.io')
print(person)
Person(name='Joe', age=25, height=1.85, email='joe@dataquest.io')
Обратите внимание, что без всего этого кода вывод эквивалентен выводу из стандартного класса Python.
Мы всегда можем перезаписать его, если хотим настроить представление нашего класса:
@dataclass
class Person():
name: str
age: int
height: float
email: str
def __repr__(self):
return (f'''This is a {self.__class__.__name__} called {self.name}.''')
person = Person('Joe', 25, 1.85, 'joe@dataquest.io')
print(person)
This is a Person called Joe.
Обратите внимание, что вывод представления настраивается.
Когда речь идет о сравнениях, модуль dataclasses
облегчает нам жизнь. Например, мы можем напрямую сравнить два экземпляра класса следующим образом:
@dataclass
class Person():
name: str = 'Joe'
age: int = 30
height: float = 1.85
email: str = 'joe@dataquest.io'
print(Person() == Person())
True
Обратите внимание, что мы использовали атрибуты по умолчанию, чтобы сделать пример короче.
В данном случае сравнение корректно, поскольку dataclass
создает за кулисами метод __eq__
, который и выполняет сравнение. Без декоратора нам пришлось бы создавать этот метод самостоятельно.
При использовании стандартного класса Python то же самое сравнение привело бы к другому результату, несмотря на то, что классы фактически равны друг другу:
class Person():
def __init__(self, name='Joe', age=30, height=1.85, email='joe@dataquest.io'):
self.name = name
self.age = age
self.height = height
self.email = email
print(Person() == Person())
False
Без использования декоратора dataclass
этот класс не проверяет, равны ли два экземпляра. Поэтому по умолчанию Python будет использовать для сравнения id
объекта, а они, как мы видим ниже, разные:
print(id(Person()))
print(id(Person()))
1734438049008
1734438050976
Все это означает, что нам придется написать метод __eq__
, который выполняет такое сравнение:
class Person():
def __init__(self, name='Joe', age=30, height=1.85, email='joe@dataquest.io'):
self.name = name
self.age = age
self.height = height
self.email = email
def __eq__(self, other):
if isinstance(other, Person):
return (self.name, self.age,
self.height, self.email) == (other.name, other.age,
other.height, other.email)
return NotImplemented
print(Person() == Person())
True
Теперь мы видим, что два объекта равны друг другу, но для получения этого результата нам пришлось написать больше кода.
Параметры @dataclass
Как мы видели выше, при использовании декоратора dataclass
за нас реализуются методы __init__
, __repr__
и __eq__
. Создание всех этих методов задается параметрами init
, repr
и eq
декоратора dataclass
. По умолчанию эти три параметра имеют значение True
. Если один из них создан внутри класса, то параметр игнорируется.
Однако у нас есть и другие параметры dataclass
, на которые следует обратить внимание, прежде чем двигаться дальше:
order
: включает сортировку класса, как мы увидим в следующем разделе. По умолчанию используетсяFalse
.frozen
: При значенииTrue
значения внутри экземпляра класса не могут быть изменены после его создания. По умолчанию используетсяFalse
.
Есть еще несколько методов, с которыми можно ознакомиться в документации.
Сортировка
При работе с данными часто возникает необходимость сортировки значений. В нашем сценарии мы можем захотеть отсортировать разных людей по какому-либо признаку. Для этого мы воспользуемся параметром order
упомянутого выше декоратора dataclass
, который позволяет выполнять сортировку в классе:
@dataclass(order=True)
class Person():
name: str
age: int
height: float
email: str
При установке параметра order
в значение True
автоматически формируются методы __lt__
(меньше), __le__
(меньше или равно), __gt__
(больше) и __ge__
(больше или равно), используемые для сортировки.
Давайте инстанцируем наши объекты joe
и mary
, чтобы проверить, больше ли один из них другого:
joe = Person('Joe', 25, 1.85, 'joe@dataquest.io')
mary = Person('Mary', 43, 1.67, 'mary@dataquest.io')
print(joe > mary)
False
Python сообщает нам, что joe
не больше mary
, но на основании каких критериев? Класс сравнивает объекты как кортежи, содержащие их атрибуты, примерно так:
print(('Joe', 25, 1.85, 'joe@dataquest.io') > ('Mary', 43, 1.67, 'mary@dataquest.io'))
False
Поскольку буква "J" идет перед "M", он говорит joe < mary
. Если бы имена были одинаковыми, то он перешел бы к следующему элементу в каждом кортеже. В данном случае он сравнивает объекты в алфавитном порядке. Хотя это может иметь определенный смысл в зависимости от решаемой задачи, мы хотим иметь возможность контролировать, как будут отсортированы объекты.
Для этого мы воспользуемся двумя другими возможностями модуля dataclasses
.
Первая - это функция field
. Эта функция используется для индивидуальной настройки одного атрибута класса данных, что позволяет определить новые атрибуты, которые будут зависеть от другого атрибута и будут созданы только после инстанцирования объекта.
В нашей задаче сортировки мы будем использовать field
для создания атрибута sort_index
в нашем классе. Этот атрибут может быть создан только после инстанцирования объекта, и именно его dataclasses
использует для сортировки:
from dataclasses import dataclass, field
@dataclass(order=True)
class Person():
sort_index: int = field(init=False, repr=False)
name: str
age: int
height: float
email: str
Два аргумента, которые мы передали в качестве False
, говорят о том, что этого атрибута нет в __init__
и что он не должен отображаться при вызове __repr__
. В функции field
есть и другие параметры, с которыми можно ознакомиться в документации.
После обращения к новому атрибуту мы воспользуемся вторым новым инструментом - методом __post_int__
. Как следует из названия, этот метод выполняется сразу после метода __init__
. Мы будем использовать __post_int__
для определения sort_index
, сразу после создания объекта. В качестве примера, допустим, мы хотим сравнить людей по их возрасту. Вот как это можно сделать:
@dataclass(order=True)
class Person():
sort_index: int = field(init=False, repr=False)
name: str
age: int
height: float
email: str
def __post_init__(self):
self.sort_index = self.age
Если мы проведем такое же сравнение, то узнаем, что Джо моложе Мэри:
joe = Person('Joe', 25, 1.85, 'joe@dataquest.io')
mary = Person('Mary', 43, 1.67, 'mary@dataquest.io')
print(joe > mary)
False
Если бы мы хотели отсортировать людей по росту, то использовали бы такой код:
@dataclass(order=True)
class Person():
sort_index: float = field(init=False, repr=False)
name: str
age: int
height: float
email: str
def __post_init__(self):
self.sort_index = self.height
joe = Person('Joe', 25, 1.85, 'joe@dataquest.io')
mary = Person('Mary', 43, 1.67, 'mary@dataquest.io')
print(joe > mary)
True
Джо выше Мэри. Обратите внимание, что мы задали sort_index
как float
.
Мы смогли реализовать сортировку в нашем классе данных без необходимости писать несколько методов.
Работа с неизменяемыми классами данных
Другим параметром @dataclass
, о котором мы говорили выше, является frozen
. При значении True
параметр frozen
не позволяет изменять атрибуты объекта после его создания.
С помощью frozen=False
мы можем легко выполнить такую модификацию:
@dataclass()
class Person():
name: str
age: int
height: float
email: str
joe = Person('Joe', 25, 1.85, 'joe@dataquest.io')
joe.age = 35
print(joe)
Person(name='Joe', age=35, height=1.85, email='joe@dataquest.io')
Мы создали объект Person
, а затем без проблем изменили атрибут age
.
Однако при установке значения True
любая попытка модификации объекта приводит к ошибке:
@dataclass(frozen=True)
class Person():
name: str
age: int
height: float
email: str
joe = Person('Joe', 25, 1.85, 'joe@dataquest.io')
joe.age = 35
print(joe)
---------------------------------------------------------------------------
FrozenInstanceError Traceback (most recent call last)
~\AppData\Local\Temp/ipykernel_5540/2036839054.py in <module>
8 joe = Person('Joe', 25, 1.85, 'joe@dataquest.io')
9
---> 10 joe.age = 35
11 print(joe)
<string> in __setattr__(self, name, value)
FrozenInstanceError: cannot assign to field 'age'
Обратите внимание, что в сообщении об ошибке указано FrozenInstanceError
.
Существует трюк, позволяющий изменять значение класса неизменяемых данных . Если наш класс содержит мутабельный атрибут, то этот атрибут может измениться, даже если класс заморожен. Это может показаться бессмысленным, но давайте рассмотрим пример.
Вспомним класс People
, который мы создали ранее в этой статье, но теперь сделаем его неизменяемым:
@dataclass(frozen=True)
class People():
people: List[Person]
@dataclass(frozen=True)
class Person():
name: str
age: int
height: float
email: str
Затем мы создаем два экземпляра класса Person
и используем их для создания экземпляра People
, который мы назовем two_people
:
joe = Person('Joe', 25, 1.85, 'joe@dataquest.io')
mary = Person('Mary', 43, 1.67, 'mary@dataquest.io')
two_people = People([joe, mary])
print(two_people)
People(people=[Person(name='Joe', age=25, height=1.85, email='joe@dataquest.io'), Person(name='Mary', age=43, height=1.67, email='mary@dataquest.io')])
Атрибут people
в классе People
представляет собой список. Мы можем легко получить доступ к значениям в этом списке в объекте two_people
:
print(two_people.people[0])
Person(name='Joe', age=25, height=1.85, email='joe@dataquest.io')
Итак, несмотря на то, что оба класса Person
и People
являются неизменяемыми, список таковым не является, а значит, мы можем менять в нем значения:
two_people.people[0] = Person('Joe', 35, 1.85, 'joe@dataquest.io')
print(two_people.people[0])
Person(name='Joe', age=35, height=1.85, email='joe@dataquest.io')
Обратите внимание, что возраст теперь 35 лет.
Мы не изменили атрибуты ни одного объекта из неизменяемых классов, но заменили первый элемент списка на другой, и список стал мутабельным.
Помните, что для безопасной работы с классами неизменяемых данных все атрибуты класса также должны быть неизменяемыми.
Наследование с dataclasses
Модуль dataclasses
также поддерживает наследование, то есть мы можем создать класс данных, который использует атрибуты другого класса данных. Продолжая использовать наш класс Person
, создадим новый класс Employee
, который наследует все атрибуты от класса Person
.
Таким образом, мы имеем Person
:
@dataclass(order=True)
class Person():
name: str
age: int
height: float
email: str
И новый класс Employee
:
@dataclass(order=True)
class Employee(Person):
salary: int
departament: str
Теперь мы можем создать объект класса Employee
, используя все атрибуты класса Person
:
print(Employee('Joe', 25, 1.85, 'joe@dataquest.io', 100000, 'Marketing'))
Employee(name='Joe', age=25, height=1.85, email='joe@dataquest.io', salary=100000, departament='Marketing')
Отныне все, что мы видели в этой статье, мы можем использовать и в классе Employee
.
Обратите внимание на атрибуты по умолчанию. Допустим, у нас есть атрибуты по умолчанию в Person
, но нет в Employee
. При таком сценарии, как в приведенном ниже коде, возникает ошибка:
@dataclass
class Person():
name: str = 'Joe'
age: int = 30
height: float = 1.85
email: str = 'joe@dataquest.io'
@dataclass(order=True)
class Employee(Person):
salary: int
departament: str
print(Employee('Joe', 25, 1.85, 'joe@dataquest.io', 100000, 'Marketing'))
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
~\AppData\Local\Temp/ipykernel_5540/1937366284.py in <module>
9
10 @dataclass(order=True)
---> 11 class Employee(Person):
12 salary: int
13 departament: str
~\anaconda3\lib\dataclasses.py in wrap(cls)
1011
1012 def wrap(cls):
-> 1013 return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)
1014
1015 # See if we're being called as @dataclass or @dataclass().
~\anaconda3\lib\dataclasses.py in _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)
925 if f._field_type in (_FIELD, _FIELD_INITVAR)]
926 _set_new_attribute(cls, '__init__',
--> 927 _init_fn(flds,
928 frozen,
929 has_post_init,
~\anaconda3\lib\dataclasses.py in _init_fn(fields, frozen, has_post_init, self_name, globals)
502 seen_default = True
503 elif seen_default:
--> 504 raise TypeError(f'non-default argument {f.name!r} '
505 'follows default argument')
506
TypeError: non-default argument 'salary' follows default argument
Если базовый класс имеет атрибуты по умолчанию, то и все атрибуты производного от него класса должны иметь значения по умолчанию.
Заключение
В этой статье мы рассмотрели, как модуль dataclasses
является очень мощным инструментом для быстрого и интуитивно понятного создания классов данных. Хотя мы многое увидели в этой статье, модуль содержит гораздо больше инструментов, и о нем всегда можно узнать еще больше.
На данный момент мы научились:
-
Определите класс с помощью
dataclasses
-
Использовать атрибуты по умолчанию и их правила
-
Создать метод представления
-
Сравнение классов данных
-
Сортировка классов данных
-
Использование наследования с классами данных
-
Работа с неизменяемыми классами данных