Введение в Python Data Classes (часть 2)

Курс по Python ООП: https://stepik.org/a/116336

Я Сергей Балакирев и мы продолжаем тему Data Classes – классы данных. На предыдущем занятии мы в целом научились объявлять такие классы с помощью декоратора dataclass и определять набор необходимых атрибутов:

@dataclass
class ThingData:
    name: str
    weight: int
    price: float = 0
    dims: list = field(default_factory=list)

Но давайте представим, что нам нужно в некотором классе, например, Vector3D при инициализации формировать вычисляемое свойство:

class Vector3D:
    def __init__(self, x: int, y: int, z: int):
        self.x = x
        self.y = y
        self.z = z
        self.length = (x * x + y * y + z * z) ** 0.5

Здесь локальный атрибут length вычисляется на основе параметров x, y, z. Как это можно сделать при объявлении Data Classes?

Метод __post_init__()

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

@dataclass
class V3D:
    x: int
    y: int
    z: int

Что делать дальше, как определить локальный атрибут length внутри объекта класса V3D? Для этого существует следующая хитрость. Инициализаторы классов, сформированные с помощью декоратора dataclass, в конце своего вызова вызывают специальный метод __post_init__(). Именно в этом методе можно формировать любые вычисляемые свойства, например, так:

@dataclass
class V3D:
    x: int
    y: int
    z: int
 
    def __post_init__(self):
        self.length = (self.x * self.x + self.y * self.y + self.z * self.z) ** 0.5

Если далее мы сформируем объект этого класса и выведем его в консоль:

v = V3D(1, 2, 3)
print(v)

то увидим:

V3D(x=1, y=2, z=3)

Спрашивается, почему здесь не видно свойства length? В действительности, оно присутствует в объекте v и мы в этом легко можем убедиться:

print(v.__dict__)

увидим:

{'x': 1, 'y': 2, 'z': 3, 'length': 3.7416573867739413}

Но тогда почему оно не выводится функцией repr()? Дело в том, что магический метод __repr__() выводит только те атрибуты, которые были указаны при объявлении класса. Все остальные, что создаются в процессе формирования объекта, не учитываются в методе __repr__(). Как тогда выйти из этой ситуации и указать, что локальный атрибут length также следует отображать? Очень просто! Давайте укажем этот атрибут при объявлении класса с небольшим уточнением:

@dataclass
class V3D:
    x: int
    y: int
    z: int
    length: float = field(init=False)
 
    def __post_init__(self):
        self.length = (self.x * self.x + self.y * self.y + self.z * self.z) ** 0.5

Мы здесь воспользовались уже знакомой нам функцией field() и отметили, что атрибут length не следует использовать в качестве параметра инициализатора. Изящно, правда?! Теперь, при отображении объекта v класса, мы увидим и параметр length.

Функция field()

Вообще функция field() предоставляет богатый функционал по управлению объявляемых атрибутов в Data Classes. Мы увидели, как работают два ее параметра: init и default_factory. Довольно часто можно встретить использование еще трех:

  • repr – булевое значение True/False указывает использовать ли атрибут в магическом методе __repr__() (по умолчанию True);
  • compare – булевое значение True/False указывает использовать ли атрибут при сравнении объектов (по умолчанию True);
  • default – значение по умолчанию (начальное значение).

Смысл их вполне понятен. Давайте исключим атрибут x из метода __repr__() и из операций сравнения атрибуты z и length. Получим следующее объявление класса:

@dataclass
class V3D:
    x: int = field(repr=False)
    y: int
    z: int = field(compare=False)
    length: float = field(init=False, compare=False)
 
    def __post_init__(self):
        self.length = (self.x * self.x + self.y * self.y + self.z * self.z) ** 0.5

Тогда при отображении и сравнении двух объектов:

v = V3D(1, 2, 3)
v2 = V3D(1, 2, 5)
print(v)
print(v == v2)

мы увидим только три атрибута: y, z, length и результат True, т.к. координаты x, y объектов v и v2 совпадают.

С остальными параметрами функции field() можно познакомиться на странице официальной документации:

https://docs.python.org/3/library/dataclasses.html

Объявление параметров типа InitVar

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

class Vector3D:
    def __init__(self, x: int, y: int, z: int, calc_len: bool = True):
        self.x = x
        self.y = y
        self.z = z
        self.length = (x * x + y * y + z * z) ** 0.5 if calc_len else 0

А как это сделать при объявлении Data Classes? Для определения параметров, участвующих в инициализации (таких, как calc_len) в модуле dataclasses есть специальный класс типа InitVar:

from dataclasses import dataclass, field, InitVar

Если при объявлении атрибут аннотируется этим классом, то он автоматически передается как параметр в метод __post_init__(), чтобы им можно было воспользоваться при формировании вычисляемых свойств:

@dataclass
class V3D:
    x: int = field(repr=False)
    y: int
    z: int = field(compare=False)
    calc_len: InitVar[bool] = True
    length: float = field(init=False, compare=False, default=0)
 
    def __post_init__(self, calc_len: bool):
        if calc_len:
            self.length = (self.x * self.x + self.y * self.y + self.z * self.z) ** 0.5

Обратите внимание, я здесь для атрибута length добавил параметр default=0 в функции field(). То есть, начальное значение атрибута length равно нулю. Если параметр calc_len равен True, то в методе __post_init__() будет пересчитано и сформировано новое значение локального атрибута length.

Параметры декоратора dataclass

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

Параметр

Описание

init = [True | False]

Принимает булево значение, по умолчанию True. Если значение True, то в классе объявляется инициализатор, иначе – не объявляется.

repr = [True | False]

Принимает булево значение, по умолчанию True. Если значение True, то в классе объявляется магический метод __repr__(), иначе – не объявляется.

eq = [True | False]

Принимает булево значение, по умолчанию True. Если значение True, то в классе объявляется магический метод __eq__(), иначе – не объявляется.

order = [True | False]

Принимает булево значение, по умолчанию False. Если значение True, то в классе объявляются магические методы для операций сравнения <; <=; >; >=, иначе – не объявляются.

unsafe_hash = [True | False]

Влияет на формирование магического метода __hash__()

frozen = [True | False]

Принимает булево значение, по умолчанию False. Если значение True, то атрибуты объектов класса становятся неизменными (можно только проинициализировать один раз в инициализаторе).

slots = [True | False]

Принимает булево значение, по умолчанию False. Если значение True, то атрибуты объявляются в коллекции __slots__.

Существуют и другие параметры декоратора dataclass. Подробно о них можно почитать на странице официальной документации:

https://docs.python.org/3/library/dataclasses.html

Давайте последовательно рассмотрим основные из них. Первые параметры init, repr, eq я, думаю, понятны. Если в декоратор передать аргумент init=False:

@dataclass(init=False)

то класс будет сформирован без собственного инициализатора (будет использован инициализатор базового класса). В результате у нас не получится создать объект с передачей значений аргументов:

v = V3D(1, 2, 3, False)

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

Следующий параметр:

@dataclass(repr=False)

запрещает формирование магического метода __repr__() внутри текущего класса. В результате, будет использован аналогичный метод базового класса. В этом легко убедиться, если создать объект и вывести его в консоль:

v = V3D(1, 2, 3, False)
print(v)

Увидим что то похожее на:

<__main__.V3D object at 0x00000236FAD67D50>

По аналогии работает параметр eq:

@dataclass(repr=False, eq=False)

Он запрещает формирование собственного магического метода __eq__() для сравнения объектов между собой на равенство. Теперь объекты сравниваются по их идентификаторам, и так как они разные, то при сравнении:

v = V3D(1, 2, 3, False)
v2 = V3D(1, 2, 3)
print(v == v2)

получаем значение False.

Следующий параметр order может быть установлен в True только совместно с eq=True. Например, следующая строчка приведет к ошибке:

@dataclass(eq=False, order=True)

Поэтому нам нужно или убрать параметр eq (по умолчанию он True), либо явно прописать у него значение True:

@dataclass(eq=True, order=True)

Я, думаю, вы догадались почему? Операции сравнения на больше или равно, меньше или равно используют магический метод __eq__(). Поэтому он должен присутствовать.

Итак, после включения параметра order, у нас появляется возможность сравнивать объекты класса на больше, меньше и больше или равно и меньше или равно:

@dataclass(eq=True, order=True)
class V3D:
    x: int
    y: int
    z: int
 
 
v = V3D(1, 2, 5)
v2 = V3D(1, 2, 3)
 
print(v < v2)  # False
print(v > v2)  # True

Сравнение выполняется на уровне кортежей, содержащих значения атрибутов (x, y, z) в порядке их объявления в классе. В данном случае происходит последовательное сравнение сначала значений x между собой, затем, y и потом – z. Как только встречается пара, для которой можно вычислить значение True или False, проверка завершается. Фактически, в приведенном примере, сравниваются между собой только последние числа 5 и 3, остальные равны, поэтому операции < и > их пропускают.

Если нам нужно исключить какие-либо атрибуты из операций сравнений, то, как я уже отмечал, для этого следует использовать функцию field() и в ней через параметр compare исключить соответствующее поле:

@dataclass(eq=True, order=True)
class V3D:
    x: int = field(compare=False)
    y: int
    z: int

Теперь сравниваться будут объекты только по двум локальным атрибутам y и z.

Здесь следует обратить внимание на то, что если в классе объявить какой-либо метод сравнения на больше, меньше или больше либо равно или меньше либо равно, то возникнет исключение TypeError:

@dataclass(order=True)
class V3D:
    x: int = field(compare=False)
    y: int
    z: int
 
    def __lt__(self, other):
        return self.x < other.x and self.y < other.y

Последний параметр, который мы рассмотрим – frozen, позволяет «замораживать» значения атрибутов класса. Например:

@dataclass(frozen=True)
class V3D:
    x: int
    y: int
    z: int
 
 
v = V3D(1, 2, 3)
print(v)
v.x = 5

Приведет к ошибке в последней строчке, т.к. менять локальные атрибуты при frozen=True в объектах класса нельзя.

На этом мы завершим это занятие, на следующем продолжим эту тему и в частности увидим, как объявление Data Classes работает при наследовании.

Курс по Python ООП: https://stepik.org/a/116336

Видео по теме