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

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

Я Сергей Балакирев и на этом занятии мы познакомимся с относительно новой идеей «быстрого» описания классов (классов данных – Data Classes). О чем здесь речь? Смотрите, довольно часто при объявлении классов в программах на Python приходится прописывать инициализатор, например, следующим образом:

class Thing:
    def __init__(self, name, weight, price):
        self.name = name
        self.weight = weight
        self.price = price

То есть, передается несколько полей: name, weight, price, которые предполагается сохранять в объектах класса Thing. И это довольно типовая ситуация. Мало того, если создать объект этого класса и вывести его в консоль:

t = Thing("Учебник по Python", 100, 1024)
print(t)

то увидим нечто неинформативное, вроде:

<__main__.Thing object at 0x0000014B7D29A7D0>

Поэтому, опять же нередко, в таких классах с данными приходится прописывать магический метод __repr__(), например, так:

    def __repr__(self):
        return f"Thing: {self.__dict__}"

Видите, сколько типовой писанины приходится делать для описания классов, подобных классу Thing?! И это лишь необходимый минимум. Представьте объем рутиной работы для описания всего лишь нескольких таких классов.

Так как программист, зачастую, существо глубоко ленивое, но творческое, уже давно предпринимались попытки автоматизировать этот процесс. И вот, начиная с версии Python 3.7, появилась возможность «из коробки» использовать инструмент для оптимизации объявления классов, содержащих произвольные данные, то есть,  Data Classes. Для этого, в самом простом варианте, достаточно импортировать специальный декоратор, который так и называется dataclass:

from dataclasses import dataclass

И с его помощью объявим в программе еще один класс ThingData, который будет эквивалентен ранее объявленному классу Thing:

@dataclass
class ThingData:
    name: str
    weight: int
    price: float

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

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

from pprint import pprint

и вызовем ее для коллекции __dict__ класса ThingData:

pprint(ThingData.__dict__)

Видим, что __dict__ ссылается на объект типа mappingproxy, внутри которого имеется инициализатор __init__() и магический метод __repr__(). То есть, в класс ThingData автоматически были добавлены эти два метода. Кроме того, имеется магический метод __eq__() для выполнения операции сравнения на равенство объектов класса ThingData. Также имеется специальный словарь __dataclass_fields__, содержащий указанные нами атрибуты при описании класса ThingData. Таким образом, класс ThingData обладает необходимым базовым функционалом при работе с атрибутами: name, weight и price. Давайте в этом убедимся. Создадим объект этого класса:

td = ThingData("Учебник по Python", 100, 1024)

Обратите внимание, в качестве первого аргумента я указал строку, а вторые два – это вес и цена. То есть, параметры в инициализаторе следуют в том же порядке, что и атрибуты, указанные при описании класса ThingData. И это чаще всего так. Порядок, как правило, не изменяют и оставляют по умолчанию.

Далее, выведем на экран объект td с помощью функции print():

print(td)

Увидим в консоли информацию:

ThingData(name='Учебник по Python', weight=100, price=1024)

Видите, как информативно отображается объект td класса ThingData?! Это, как раз, благодаря переопределению магического метода __repr__() внутри класса ThingData. Для уверенности, мы можем даже явно его вызвать в функции print() следующим образом:

print(repr(td))

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

Отлично, первый шаг в понимании и использования декоратора dataclass мы с вами сделали. Но это только начало. Давайте попробуем протестировать еще один магический метод __eq__(), который также был добавлен в класс ThingData. Для этого мы создадим в программе второй объект этого класса:

td_2 = ThingData("Python ООП", 80, 512)

и выполним сравнение двух объектов:

print(td == td_2)

Увидим ожидаемое значение False. Но, если создать еще один экземпляр с тем же набором данных:

td_3 = ThingData("Python ООП", 80, 512)

то операция сравнения:

print(td_2 == td_3)

вернет значение True.

Я, думаю, из этих примеров вы уже поняли, как работает переопределенный магический метод  __eq__()? Он перебирает значение указанных нами атрибутов (name, weight, price) первого объекта и сравнивает с соответствующими значениями этих же атрибутов второго объекта. Это эквивалентно сравнению следующих кортежей на равенство:

(name, weight, price) == (name, weight, price)

Кстати, если нас это поведение не устраивает и нужно сравнивать, например, объекты только по весу (полю weight), то, как вариант, никто не запрещает внутри класса ThingData объявить свой вариант магического метода __eq__():

@dataclass
class ThingData:
    name: str
    weight: int
    price: float
 
    def __eq__(self, other):
        return self.weight == other.weight

В этом случае существующий метод (который был объявлен нами) не меняется декоратором dataclass. И теперь сравнение происходит именно по весу:

td_2 = ThingData("Python ООП", 80, 640)
td_3 = ThingData("Python ООП 2", 80, 512)
print(td_2 == td_3)

И так происходит работа с методами: __init__(), __repr__(), __eq__(). Если они существуют внутри класса, то остаются неизменными.

Следующий важный базовый момент, при определении Data Classes, состоит в порядке объявления атрибутов с начальными значениями. Давайте предположим, что цена по умолчанию принимает значение 0. В этом случае класс ThingData можно определить так:

@dataclass
class ThingData:
    name: str
    weight: int
    price: float = 0

И, затем, создать объект этого класса:

td = ThingData("Учебник по Python", 100)
print(td)

В консоли увидим значение 0 для локального атрибута price. Если же будет передано какое-либо другое значение, например:

td = ThingData("Учебник по Python", 100, 512)

то price будет ссылаться на это новое значение.

Но здесь есть один маленький нюанс: все атрибуты со значениями по умолчанию должны идти последними. Например, если поменять атрибуты weight и price местами:

@dataclass
class ThingData:
    name: str
    price: float = 0
    weight: int

то интегрированная среда нам сразу подчеркнет атрибут price, а при попытке выполнить программу появится ошибка. Почему так произошло? И почему атрибуты с начальными значениями обязательно должны быть последними? Ответ прост и я думаю, некоторые из вас уже догадались в чем дело. Так как эти атрибуты, по сути, являются параметрами метода __init__() и идут в том порядке, в котором записаны в классе, то при данной записи получаем следующее объявление инициализатора:

def __init__(self, name: str, price: float = 0, weight: int)

Видите в чем тут ошибка? Параметры со значениями по умолчанию должны идти последними, а здесь это не так. Отсюда и возникает ошибка определения. Поэтому, когда мы объявляем класс как Data Class  с декоратором dataclass, то все атрибуты с начальными значениями должны быть записаны последними.

Второй тонкий момент со значениями по умолчанию связан с изменяемыми и неизменяемыми объектами. Я напомню вам одно важное поведение функций и методов в языке Python. Предположим, что мы в инициализатор класса Thing добавляем еще один параметр dims (размерность) следующим образом:

class Thing:
    def __init__(self, name, weight, price, dims=[]):
        self.name = name
        self.weight = weight
        self.price = price
        self.dims = dims

И, затем, создаем объект этого класса:

t = Thing("Учебник по Python", 100, 1024)
t.dims.append(10)

Если сейчас вывести список dims, то увидим вполне ожидаемый результат:

print(t.dims)

Но, если после этого создать еще один объект класса Thing, например, так:

t2 = Thing("Учебник по Python", 100, 1024)

и выведем у него список dims:

print(t2.dims)

то увидим не пустой список, а с одним уже существующим значением 10. Чаще всего такое поведение приводит к непредвиденным ошибкам в работе программы, т.к. начальный пустой список подразумевается независимым для каждого объекта класса Thing. Тогда как в показанном примере он оказывается общим.

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

@dataclass
class ThingData:
    name: str
    weight: int
    price: float = 0
    dims: list = []

Тогда спрашивается, как нам определить, например, список в качестве значения по умолчанию и так, чтобы он был независимым для каждого объекта класса ThingData? Для этого следует воспользоваться специальной функцией field() из модуля dataclasses:

from dataclasses import dataclass, field

и определить через нее значение по умолчанию следующим образом:

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

Здесь через параметр default_factory указывается, какой объект следует создавать в момент вызова инициализатора класса ThingData и присваивать атрибуту dims. В данном примере – это объект list (список). Теперь, при создании объекта этого класса:

td = ThingData("Учебник по Python", 100, 512)
print(td)

мы увидим, что dims ссылается на пустой список, как это и ожидалось.

Вообще с помощью функции field() можно довольно тонко настраивать поведение атрибутов в Data Classes, но об этом и других вещах мы продолжим говорить на следующем занятии.

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

Видео по теме