Data classes#

Часто в Python необходимо создавать классы в которых указаны только несколько переменных. При этом, для реализации таких операций как сравнение экземпляров класса требуется создать несколько специальных методов, добавить сюда строковое представление объекта и для создания довольно простого класса, требуется много кода.

Примечание

Data classes это новый функционал, он входит в стандартную бибилиотеку начиная с Python 3.7. Для предыдущих версий надо ставить отдельный модуль dataclasses или использовать сторонний типа модуля attr.

Модуль dataclasses предоставляет декоратор dataclass с помощью которого можно существенно упростить создание классов:

In [9]: dataclass?
Signature:
dataclass(
    _cls=None,
    *,
    init=True,
    repr=True,
    eq=True,
    order=False,
    unsafe_hash=False,
    frozen=False,
)
Docstring:
Returns the same class as was passed in, with dunder methods
added based on the fields defined in the class.

Examines PEP 526 __annotations__ to determine fields.

If init is true, an __init__() method is added to the class. If
repr is true, a __repr__() method is added. If order is true, rich
comparison dunder methods are added. If unsafe_hash is true, a
__hash__() method function is added. If frozen is true, fields may
not be assigned to after instance creation.
File:      /usr/local/lib/python3.7/dataclasses.py
Type:      function

Пример класса IPAddress:

class IPAddress:
    def __init__(self, ip, mask):
        self._ip = ip
        self._mask = mask

    def __repr__(self):
        return f"IPAddress({self.ip}/{self.mask})"

И соответствующего класса созданного с помощью dataclass:

In [11]: @dataclass
    ...: class IPAddress:
    ...:     ip: str
    ...:     mask: int
    ...:

In [12]: ip1 = IPAddress('10.1.1.1', 28)

In [13]: ip1
Out[13]: IPAddress(ip='10.1.1.1', mask=28)

Для создания класса данных используется аннотация типов. Декоратор dataclass использует указанные переменные и дополнительные настройки для создания атрибутов для экземпляров класса, а также методов __init__, __repr__ и других.

Все переменные, которые определены на уровне класса, по умолчанию, будут прописаны в методе __init__ и будут ожидаться как аргументы при создании экземпляра.

Примечание

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

Метод __post_init__#

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

@dataclass
class IPAddress:
    ip: str
    mask: int

    def __post_init__(self):
        if not isinstance(self.mask, int):
            self.mask = int(self.mask)


In [46]: ip1 = IPAddress('10.10.1.1', '24')

In [47]: ip1.mask
Out[47]: 24

Параметры order и frozen#

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

  • frozen - контролирует можно ли менять значения переменных

  • order - если равен True, добавляет к классу методы __lt__, __le__, __gt__, __ge__

Если параметр order равен True, экземпляры класса можно сравнивать и упорядочивать:

@dataclass(order=True)
class IPAddress:
    ip: str
    mask: int


In [12]: ip1 = IPAddress('10.1.1.1', 28)

In [14]: ip1 == ip2
Out[14]: False

In [15]: ip1 < ip2
Out[15]: True

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

In [24]: ip1 = IPAddress('10.10.1.1', 24)

In [25]: ip2 = IPAddress('10.2.1.1', 24)

In [26]: ip2 > ip1
Out[26]: True

In [27]: ip_list = [ip1, ip2]

In [28]: ip_list
Out[28]: [IPAddress(ip='10.10.1.1', mask=24), IPAddress(ip='10.2.1.1', mask=24)]

In [30]: sorted(ip_list)
Out[30]: [IPAddress(ip='10.10.1.1', mask=24), IPAddress(ip='10.2.1.1', mask=24)]

Функция field#

Функция field позволяет указывать параметры работы с отдельными переменными.

dataclasses.field(*, default=MISSING, default_factory=MISSING,
                  repr=True, hash=None, init=True, compare=True, metadata=None)

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

@dataclass
class User:
    username: str
    password: str = field(repr=False)


In [49]: user1 = User('John', '12345')

In [50]: user1
Out[50]: User(username='John')

Все переменные, которые определены на уровне класса, по умолчанию, будут прописаны в методе __init__ и будут ожидаться как аргументы при создании экземпляра. Иногда в классе могут присутствовать переменные, которые вычисляются на основании аргументов __init__, а не передаются как аргументы. В этом случае, можно воспользоваться параметром init в field и вычислить значение динамически в__post_init__:

@dataclass
class Book:
    title: str
    price: int
    quantity: int
    total: int = field(init=False)

    def __post_init__(self):
        self.total = self.price * self.quantity


In [52]: book = Book('Good Omens', 35, 5)

In [53]: book.total
Out[53]: 175

In [54]: book
Out[54]: Book(title='Good Omens', price=35, quantity=5, total=175)

Функция field также поможет исправить ситуацию с сортировкой в классе IPAddress. Указав compare=False при создании переменной, можно исключить ее из сравнения и сортировки. Также в классе добавлена дополнительная переменная _ip, которая содержит IP-адрес в виде числа. Для этой переменной init=False, так как это значение не надо передавать при создании экземпляра, и repr=False, так как переменная не должна отображаться в строковом представлении:

@dataclass(order=True)
class IPAddress:
    ip: str = field(compare=False)
    _ip: int = field(init=False, repr=False)
    mask: int

    def __post_init__(self):
        self._ip = int(ipaddress.ip_address(self.ip))


In [40]: ip1 = IPAddress('10.10.1.1', 24)

In [41]: ip2 = IPAddress('10.2.1.1', 24)

In [42]: ip_list = [ip1, ip2]

In [43]: sorted(ip_list)
Out[43]: [IPAddress(ip='10.2.1.1', mask=24), IPAddress(ip='10.10.1.1', mask=24)]

In [44]: ip1 > ip2
Out[44]: True

Функции asdict, astuple, replace#

In [2]: from dataclasses import asdict, astuple, replace, dataclass

In [3]: @dataclass(order=True, frozen=True)
   ...: class IPAddress:
   ...:     ip: str
   ...:     mask: int = 24
   ...:

In [4]: ip1 = IPAddress('10.1.1.1', 28)

In [5]: asdict(ip1)
Out[5]: {'ip': '10.1.1.1', 'mask': 28}

In [6]: astuple(ip1)
Out[6]: ('10.1.1.1', 28)

In [8]: replace(ip1, mask=24)
Out[8]: IPAddress(ip='10.1.1.1', mask=24)

In [9]: ip3 = replace(ip1, mask=24)

In [10]: ip3
Out[10]: IPAddress(ip='10.1.1.1', mask=24)

Работа с property#

@dataclass
class Book:
    title: str
    price: float
    _price: float = field(init=False, repr=False)
    quantity: int = 0 # TypeError: non-default argument 'quantity' follows default argument

    @property
    def total(self):
        return round(self.price * self.quantity, 2)

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Значение должно быть числом')
        if not value >= 0:
            raise ValueError('Значение должно быть положительным')
        self._price = float(value)


In [79]: b1 = Book('Good Omens', 35, 5)

In [80]: b1.price
Out[80]: 35.0

In [81]: b1.total
Out[81]: 175.0

In [82]: b1.price = 30

In [83]: b1.total
Out[83]: 150.0