Dataclass'ы в Python Python

Dataclass'ы в Python

Основы работы с классами данных

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

Классы данных являются объектами, схожими на обычные классы в Python, но специализирующимися, главным образом, на создании и хранении данных. Возможность использования данных классов появилась лишь в Python 3.7, благодаря использованию декоратора @dataclass из модуля dataclasses:

from dataclasses import dataclass

@dataclass
class Currency:
    country: str
    USD: float

Этот декоратор фактически добавляет к классу данных весь базовый функционал, который отличает его от обычных классов в Python. В частности, данный декоратор автоматически формирует за нас такие «магические» методы, как __repr__ или __eq__:

pound_sterling = Currency('United Kingdom', 1.22)
pound_sterling
# Currency(country='United Kingdom', USD=1.22)
pound_sterling.USD
# 1.22
pound_sterling == Currency('United Kingdom', 1.22)
# True

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

class Currency:
    def __init__(self, country, USD):
        self.country = country
        self.USD = USD

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

pound_sterling = Currency('United Kingdom', 1.22)
pound_sterling
# <__main__.Currency object at 0x0000016D45928FD0>
pound_sterling == Currency('United Kingdom', 1.22)
# False

Но, на этом преимущества классов данных не заканчиваются.

Аннотации типов

Из предыдущих примеров вы наверняка заметили, что при инициализации свойств в классе данных, после имени соответствующей переменной через двоеточие мы указывали типы данных. Данный синтаксис, доступный начиная с версии Python 3.6 обозначает аннотацию типов, которая достаточно полезна при выявлении соответствующих ошибок. Так, если мы попробуем присвоить значение, которое не соответствует заданному в классе типу, то интерпретатор предупредит нас об этом:

Yen = Currency('Japan', 'Don`t know')
Expected type 'float', got 'str' instead

Кроме того, в случае затруднений в определении фактического типа данных при инициализации конкретного экземпляра класса данных, для аннотации типов мы можем воспользоваться типом Any из модуля typing:

from dataclasses import dataclass
from typing import Any, Union

@dataclass
class Settings:
    default_user: Union[int, str]  # int or str
    random_seed: Any  # Any type

Значения по умолчанию

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

from dataclasses import dataclass

@dataclass
class AccidentStatus:
    days_without: int = 0
    total_number: int = 0
    last: str = 'None'

Процесс инициализации свойств классов данных значениями по умолчанию абсолютно идентичен тем же действиям, производящимся в методе __init__ для обычного класса:

nuclear_plant = AccidentStatus()
AccidentStatus(days_without=0, total_number=0, last='None')

Методы

Классы данных поддерживают методы точно так же, как и обычные классы:

@dataclass
class AccidentStatus:
    days_without: int = 0
    total_number: int = 0
    last: str = 'None'

    def new_accident(self, last: str):
        self.days_without = 0
        self.total_number += 1
        self.last = last

nuclear_plant = AccidentStatus(days_without=21)
nuclear_plant.new_accident('Donut without glaze')
AccidentStatus(days_without=0, total_number=1, last='Donut without glaze')

Продвинутое использование классов данных

С тем, чтобы на практике рассмотреть основные потенциальные возможности классов данных, давайте попробуем создать класс Bit, где пропишем тип и размер конкретных насадок (битов) для отвертки и, класс BitSet с набором соответствующих насадок в виде списка экземпляров класса Bit. Допустим, что у нас в наборе может быть лишь насадка типа (+) с наконечником под крестообразный шлиц и насадка типа (-) с наконечником под прямой шлиц. Размер же насадок в наборе может колебаться от 3-х до 8 мм с шагом 1 мм, как для крестообразного, так для прямого шлица.

from dataclasses import dataclass
from typing import List

@dataclass
class Bit:
    type: str
    size: int

@dataclass
class BitSet:
    set: List[Bit]

Теперь можно легко создать набор насадок для отвертки (бит) на основе выше заданных критериев с помощью простой функции:

def make_default_set():
    return [Bit('-', i) for i in range(3, 8)] + [Bit('+', j) for j in range(3, 8)]

Казалось бы, той же функцией можно задать и значение по умолчанию, однако, в этом случае мы столкнемся с ситуацией так называемого Anti-Pattern - изменяемого аргумента по умолчанию. Суть этой ситуации сводится к тому, что в случае изменений в списке для одного экземпляра нашего класса, те же изменения автоматически появятся и во всех его остальных экземплярах. В обычном классе данная проблема решается путем создания нового списка для каждого из его экземпляров. Но, для этого в данном классе в отношении списка, подверженного изменениям, сначала приходится устанавливать специальное значение по умолчанию, приравниваемое к None, а затем, в методе __init__ еще и обрабатывать это значение с помощью следующей конструкции:

if arg == None:
    arg = [...]

В классах же данных эта проблема решается гораздо проще – за счет функции field() в сочетании с ее поименованным аргументом default_factory:

from dataclasses import dataclass, field
from typing import List

@dataclass
class BitSet:
    set: List[Bit] = field(default_factory = make_default_set)

Теперь, можно убедиться, что проблема решена:

standard_set = BitSet()
my_set = BitSet()
my_set.set[0] = Bit('+', 6)
# BitSet(set=[Bit(type='+', size=6), ...]
Значением поименованного аргумента default_factory может являться любая функция, не принимающая аргументов.

Кроме default_factory, у функции field() есть еще множество других поименованных аргументов, полный перечень которых приведен ниже:

  • default - Задает значение по умолчанию
  • default_factory - Задает функцию, которая должна возвращать значение по умолчанию
  • init - Указывает, присутствие (видимость) поля при создании экземпляра в __init__(). По умолчанию - True.
  • repr - Указывает, видимость поля при строковом представлении по результатам __repr__(). По умолчанию - True.
  • compare - Указывает, использовать ли поле при сравнениях в __cmp__(). По умолчанию - True.
  • hash - Указывает, учитывать ли поле при вычислении хеша? По умолчанию равно compare.
  • metadata - Добавляет к полю информацию, расширяющую сведения о нем

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

from dataclasses import dataclass, field

@dataclass
class Order:
    order_number: int
    acknowledgement: str = field(default='Не добавлять', init=False)

Теперь при создании нового экземпляра класса мы не сможем добавить слова благодарности.

order = Order(444, acknowledgement='Большое спасибо!')
# TypeError: __init__() got an unexpected keyword argument 'acknowledgement'
order = Order(444)
order.acknowledgement = ' Большое спасибо!'
# Order(order_number=444, acknowledgement=' Большое спасибо!')

Довольно интересным также является поименованный аргумент metadata, позволяющий прикреплять к полю дополнительную информацию, которую затем можно отображать с помощью функции fields():

from dataclasses import dataclass, field, fields

@dataclass
class Warehouse:
    boxes: int = field(default=0, metadata={'size': '30*40*60', 'color': 'white'})
    barrels: int = field(default=0, metadata={'height': '100', 'color': 'grey'})

fields(Warehouse)[0].metadata['size']
# 30*40*60

Репрезентация

В классах данных предусмотрены такие методы, как obj.__repr__() и obj.__str__(), которые позволяют отображать информацию из этих класса в удобном для разработчиков или пользователей виде. При этом метод obj.__repr__() призван возвращать информацию адаптированную для разработчиков и воссоздающую (по возможности) свой собственный экземпляр, а метод obj.__str__(), по сути дублирует .repr, но предполагает свою реализацию таким образом, чтобы возвращаемая им информация была бы более дружественной для пользователей.

Давайте на примере, рассмотренного в предыдущем разделе нашей статьи, класса Bit, попытаемся изменить метод .str так, чтобы он был более дружественным для пользователей и возвращал бы сугубо лишь имеющиеся у нас в этом классе данные по типу и размеру насадок (бит) для отверток:

from dataclasses import dataclass

@dataclass
class Bit:
    bit_type: str
    bit_size: int

    def __str__(self):
        return f'{self.bit_type}{self.bit_size}'

Теперь настало время изменить класс BitSet с набором для отверток (списком экземпляров класса Bit), таким образом, чтобы из него возвращалось бы только его название и список с характеристиками, имеющихся в этом классе (наборе) бит (насадок для отвертки):

from dataclasses import dataclass, field

@dataclass
class BitSet:
    bit_set: List[Bit] = field(default_factory=make_default_set)

    def __str__(self):
        set_str = ', '.join(f'{b!s}' for b in self.bit_set)
        return f'{self.__class__.__name__}: {set_str}'

BitSet()
# BitSet: -3, -4, -5, -6, -7,-8, +3, +4, +5, +6, +7,+8,

Очевидно, что такая запись для пользователей будет гораздо более понятной, чем та которая раннее выводилась базовым методом .repr. Как вы могли видеть, в методе .str при форматировании вывода мы использовали конструкцию {b!s}. Данная конструкция позволяет выводить характеристики насадок в классе Bit в строковом представлении.

Сравнение

Используемый при создании классов данных декоратор @dataclass, который раннее использовался нами без каких-либо параметров, на самом деле может иметь следующий арсенал поименованных аргументов:

  • init - Указывает на необходимость добавления метода .__init__(). (По умолчанию True).
  • repr - Разрешает добавлять метод .__repr__(). (По умолчанию True).
  • eq - Разрешает добавлять метод .__eq__(). (По умолчанию True).
  • order - Для экземпляров класса дает возможность их сортировки и сравнения. (По умолчанию False).
  • unsafe_hash - Добавляет метод .__hash__(). (По умолчанию False).
  • frozen - Обеспечивает неизменяемость класса. Т.е., если данный аргумент установлен в True, то при изменении свойств в экземпляре данного класса интерпретатор выдает ошибку. (По умолчанию False).

Давайте посмотрим, как инициализация поименованного аргумента order значением True позволит нам сравнивать экземпляры класса Order между собой.

from dataclasses import dataclass, field

@dataclass(order=True)
class Order:
    order_number: int
    acknowledgement: str = field(default='Not added', init=False)

order = Order(444)
next_order = Order(445)
order < next_order
# True

В вышеприведенном примере сравнение заказов в экземплярах класса Order происходит лишь по первому его свойству order_number. Вместе с тем существуют ситуации, когда экземпляры классов данных приходится сравнивать и сортировать исходя сразу из нескольких их свойств. С этой целью в классах данных можно применить дополнительное свойство sort_index, инициализацию значения которого нужно задать в специальном методе __post_init__().

Давайте посмотрим, как это работает на примере уже знакомого нам класса Bit с характеристиками насадок для отверток. Допустим, нам нужно отсортировать насадки так, так, чтобы первыми шли насадки типа (-), а вторыми - типа (+). Но, загвоздка тут в том, что код ASCII для символа (-) составляет 45, а для символа (+) - 43. Т.е. автоматическая сортировка по алфавиту, а также сравнение (-) < (+), в нашем случае будут давать ложный результат. При всей кажущейся сложности, на самом деле выход из вышеописанной ситуации основывается лишь на создании дополнительного списка bits, где требуется просто указать необходимую нам последовательность сортировки наших значений. Затем же, исходя из индексов элементов списка bits в методе __post_init__() мы просто должны организовать расчет значений для инициализации свойства sort_index:

from dataclasses import dataclass, field

bits = ['-', '+']

@dataclass(order=True)
class Bit:
    sort_index: int = field(init=False, repr=False)
    bit_type: str
    bit_size: int

    def __post_init__(self):
        self.sort_index = (bits.index(self.bit_type) * 100 + self.bit_size)

    def __repr__(self):
        return f'{self.bit_type}{self.bit_size}'

Bit('+', 4) > Bit('-', 4)
# True

sorted([Bit('+', 4), Bit('+', 3), Bit('-', 1)])
# [-1, +3, +4]

В нашем примере сразу же после инициализации экземпляра класса Bit, его самое первое свойство sort_index автоматически должно устанавливаться в значение, рассчитываемое в методе __post_init__(). Умножая в этом методе индекс соответствующего элемента на 100, мы тем самым гарантируем, что сортировка, прежде всего, будет учитывать тип насадки, а затем уже корректироваться исходя из ее прибавляемого размера. Таким образом, когда в примере мы сравниваем два экземпляра класса Bit('+', 4) > Bit('-', 4), фактически же сравниваются значения этих экземпляров 104 и 4, которые были рассчитаны методом __post_init__() и записаны в свойство sort_index при их инициализации.

Неизменяемые классы данных

Достаточно интересная особенность возникает у класса данных при использовании им декоратора @dataclass совместно с его поименованным аргументом frozen. Ведь применение данного аргумента, фактически является альтернативой именованным кортежам в плане возможности создания неизменяемых структур данных (классов):

from dataclasses import dataclass

@dataclass(frozen=True)
class FrozenBit:
    bit_type: str
    bit_size: int

first_bit = FrozenBit('-', 3)
first_bit.bit_type = '+'
# dataclasses.FrozenInstanceError: cannot assign to field 'bit_type'

Благодаря инициализации аргумента frozen значением True, мы уже не сможем изменить в существующем экземпляре first_bit ни одну из характеристик насадки (биты), заданных при инициализации этого экземпляра. Однако, если в создаваемом таким образом классе у вас есть многоуровневые вложенные структуры наподобие словарей, списков или же иных классов данных с изменяемыми элементами, то все эти элементы таковыми и останутся. Поэтому, в случае необходимости распространения неизменяемости на все вложенные элементы класса придется позаботиться об этом отдельно, например, за счет замени списков кортежами.

Наследование

Одним из весьма значимых преимуществ классов данных перед схожими на них информационными структурами является возможность наследования. Чтобы практически рассмотреть данный функционал давайте создадим класс DeliveryOrder на основе класса Order:

from dataclasses import dataclass, field

@dataclass(order=True)
class Order:
    order_number: int
    acknowledgement: str = field(default='Not added', init=False)

@dataclass
class DeliveryOrder(Order):
    address: str

При наследовании важно иметь в виду, что если в родительском классе есть свойства со значениями по умолчанию, то все эти же свойства для дочерних класса также должны иметь значения по умолчанию. В противном случае, интерпретатор нам выдаст ошибку non-default argument follows default argument. Это вызвано тем, что с одной стороны в дочернем классе всегда сначала инициализируются родительские методы и свойства, а с другой стороны, не инициализированные параметры (аргументы без значения по умолчанию) в Python всегда также должны обрабатываться первыми. Также важно понимать, что меняя порядок следования свойств и методов в дочернем классе, на самом деле этот порядок остается таким же, каким был зафиксирован в родительском классе.

Слоты

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

Попробуем создать простой класс данных с использованием слотов:

from dataclasses import dataclass, field

@dataclass
class SlotsBit:
    __slots__ = ['bit_type', 'bit_size']
    bit_type: str
    bit_size: int

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

from pympler import asizeof

dc = Bit('+', 4)
slots_dc = SlotsBit('+', 4)
asizeof.asizeof(dc)
# 464
asizeof.asizeof(slots_dc)
# 136

Используя же Python модуль timeit можно оценить скорость выполнения операций для двух выше обозначенных классов:

from timeit import timeit

timeit('dc.bit_size', setup="dc=Bit('+', 4)", globals=globals())
# 0.025671799999999995
timeit('slots_dc.bit_size', setup="slots_dc=SlotsBit('+', 4)", globals=globals())
# 0.02028190000000002

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

Заключение

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

В этой статье мы рассмотрели вопросы:

  • создания своих классов данных
  • установки в этих классах значений по умолчанию
  • многообразной настройки классы данных
  • реализации наследования в вышеназванных классах

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

Практический Python для начинающих
Практический Python для начинающих

Станьте junior Python программистом за 7 месяцев

 7 месяцев

Возможно будет интересно

🏆 Hello, world! Python
Новичок
🏆 Hello, world!

Мы вчера запустили новый www.pylot.me. Должны были в следующую среду, но запустили вчера.

2022-10-04
Как практиковаться в Python? Python
Новичок
Как практиковаться в Python?

Для улучшения качества знаний и повышения уровня программиста, необходим постоянный практикум. Где можно это организовать самостоятельно, и как практиковаться в Python?

2022-10-19
Условные конструкции и сопоставление структурных шаблонов Шпаргалки
Новичок
Условные конструкции и сопоставление структурных шаблонов

Шпаргалка по условным конструкциям и сопоставлению структурных шаблонов

2022-11-09