Как использовать классы данных 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

  • Использовать атрибуты по умолчанию и их правила

  • Создать метод представления

  • Сравнение классов данных

  • Сортировка классов данных

  • Использование наследования с классами данных

  • Работа с неизменяемыми классами данных

Вернуться на верх