Python Data Classes при наследовании

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

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

from dataclasses import dataclass, field, InitVar
from typing import Any
 
 
@dataclass
class Goods:
    uid: Any
    price: Any = None
    weight: Any = None

А ниже, на его основе объявим дочерний класс Book для описания книг:

@dataclass
class Book(Goods):
    title: str = ""
    author: str = ""
    price: float = 0
    weight: int | float = 0

Смотрите, здесь в дочернем классе повторяются атрибуты price и weight. Спрашивается, какие инициализаторы в итоге будут формироваться в первом и во втором классах? Для первого базового класса Goods все очевидно. Будет объявлен инициализатор со следующей сигнатурой:

def __init__(self, uid: Any, price: Any = None, weight: Any = None):

А в дочернем классе будем иметь такой инициализатор:

def __init__(self, uid: Any, price: float = 0, weight: int | float = 0, title: str = "", author: str = ""):

То есть, уникальные атрибуты добавляются в конец, а переопределенные (по типу данных в аннотации и начальному значению) остаются в своих позициях, но с указанными типами и начальными значениями. Поэтому, если далее мы создадим объект класса Book и выведем его на экран:

b = Book(1)
print(b)

то увидим строчку:

Book(uid=1, price=0, weight=0, title='', author='')

Или же, можно создать объект, указав весь набор аргументов:

b = Book(1, 1000, 100, "Python ООП", "Балакирев С.М.")
print(b)

Тогда в консоль будет выведена информация:

Book(uid=1, price=1000, weight=100, title='Python ООП', author='Балакирев С.М.')

Учитывая порядок атрибутов и параметров в инициализаторах, мы в дочернем классе у всех атрибутов должны указывать начальные значения. Иначе, получим ошибку, что параметры со значениями по умолчанию price и weight идут до обычных позиционных параметров.

Вызов метода __post_init__() при наследовании

Давайте несколько усложним нашу программу и будем формировать значение uid автоматически при создании каждого нового объекта. Я сделаю это следующим образом:

@dataclass
class Goods:
    current_uid = 0
 
    uid: int = field(init=False)
    price: Any = None
    weight: Any = None
 
    def __post_init__(self):
        print("Goods: post_init")
        Goods.current_uid += 1
        self.uid = Goods.current_uid

Здесь в класс Goods добавлен обычный атрибут current_uid = 0 с начальным значением 0 и без аннотации. В результате, это будет просто атрибут уровня класса. Атрибут uid исключен из параметров инициализатора и формируется теперь в методе __post_init__(). В этом методе выводится строка "Goods: post_init", чтобы было видно, что этот метод был вызван. И, затем, вычисляется текущее значение локального атрибута uid (у каждого объекта он будет уникальным).

Дочерний класс Book мы пока оставим без изменений. Если теперь создать объект и вывести его в консоль:

b = Book(1000, 100, "Python ООП", "Балакирев С.М.")
print(b)

то увидим следующие строчки:

Goods: post_init
Book(uid=1, price=1000, weight=100, title='Python ООП', author='Балакирев С.М.')

То есть, метод __post_init__() базового класса был успешно вызван и свойство uid корректно сформировано.

Давайте теперь добавим такой же метод __post_init__() в дочерний класс Book, например, так:

@dataclass
class Book(Goods):
    title: str = ""
    author: str = ""
    price: float = 0
    weight: int | float = 0
 
    def __post_init__(self):
        print("Book: post_init")

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

Как исправить эту ситуацию? В самом простом варианте можно явно вызвать метод __post_init__() базового класса. Благо, он там всегда присутствует:

    def __post_init__(self):
        super().__post_init__()
        print("Book: post_init")

Теперь никаких проблем с начальной инициализацией вычисляемых свойств базового класса у нас не возникает.

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

Пользовательские методы в параметре default_factory функции field

Давайте еще несколько усложним нашу программу и добавим в дочерний класс Book еще один атрибут measure следующим образом:

@dataclass
class Book(Goods):
    title: str = ""
    author: str = ""
    price: float = 0
    weight: int | float = 0
    measure: list = field(default_factory=GoodsMethodsFactory.get_init_measure)
 
    def __post_init__(self):
        super().__post_init__()
        print("Book: post_init")

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

Глядя сейчас на эту строчку, у вас может возникнуть вопрос: почему бы эту функцию (метод) не объявить внутри класса Book, а потом указать в параметре default_factory? Но если мы сейчас попробуем это сделать:

measure: list = field(default_factory=Book.get_init_measure)

и определим метод get_init_measure() как статический внутри класса Book:

    @staticmethod
    def get_init_measure():
        return [0, 0, 0]

то получим ошибку, что имя Book не определено. На мой взгляд, красивый выход из этой ситуации – это объявить еще один самостоятельный класс, в котором определять методы, используемые в функции field, например, так:

class GoodsMethodsFactory:
    @staticmethod
    def get_init_measure():
        return [0, 0, 0]

Тогда никаких ошибок не будет и локальный атрибут measure сформируется с нужными нам начальными значениями:

Book(uid=1, price=1000, weight=100, title='Python ООП', author='Балакирев С.М.', measure=[0, 0, 0])

Функция make_dataclass

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

dataclasses.make_dataclass(cls_name, fields, *, bases=(), namespace=None, init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False, match_args=True, kw_only=False, slots=False, weakref_slot=False)

Основные из них следующие:

  • cls_name – название нового класса (в виде строки);
  • fields – поля (локальные атрибуты) объектов класса;
  • * - произвольный набор позиционных аргументов;
  • bases – список базовых классов;
  • namespace – словарь для определения атрибутов самого класса (например, так можно объявлять методы класса).

Некоторые остальные параметры вам уже известны, они совпадают с параметрами декоратора dataclass.

Давайте для примера объявим класс аналогичный следующему:

class Car:
    def __init__(self, model, max_speed, price):
        self.model = model
        self.max_speed = max_speed
        self.price = price
 
    def get_max_speed(self):
        return self.max_speed

С помощью функции make_dataclass() это можно сделать так:

CarData = make_dataclass("CarData", [("model", str),
                                     "max_speed",
                                     ("price", float, field(default=0))],
                         namespace={'get_max_speed': lambda self: self.max_speed})

Создадим объект этого класса:

c = CarData("BMW", 256, 4096)

и выведем его на экран:

print(c)
print(c.get_max_speed())

Как видите, все отработало как надо.

Обычно, функцию make_dataclass() используют, если требуется сформировать класс данных в процессе работы программы. Если же нам нужно обычное объявление (а это наиболее частая ситуация), то гораздо удобнее использовать декоратор dataclass.

Вот основные возможности формирования классов данных на языке Python. Более подробную информацию, как всегда, можно посмотреть на странице официальной документации:

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

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

Видео по теме