6 minute read

Data classes, как и collections.namedtuple, как и typing.NamedTuple - конструкторы классов.

collections.namedtuple, typing.NamedTuple

namedtuple и NamedTuple ограничены. Их стоит использовать если нужен готовый объект, который будет обладать свойствами кортежа, по умолчанию:

  • итерируемый (можно перебирать атрибуты как элементы кортежа)
  • с доступом к атрибутам по индексу
  • неизменяемый (нельзя изменять атрибуты)

1. Создаем итерируемый объект

from collections import namedtuple

RouterClass = namedtuple('Router', ['hostname', 'ip', 'ios'])
r1 = RouterClass('r1', '10.1.1.1', '15.4')
for i in r1:
    print(i)

r1
10.1.1.1
15.4

2. Обращаемся по индексу

In [8]: r1[0]
Out[8]: 'r1'

In [9]: r1[1]
Out[9]: '10.1.1.1'

In [10]: r1[2]
Out[10]: '15.4'

Хороший пример использования таких объектов - формирование запросов к БД:

import sqlite3
from collections import namedtuple


key = 'vlan'
value = 10
db_filename = 'dhcp_snooping.db'

keys = ['mac', 'ip', 'vlan', 'interface', 'switch']
DhcpSnoopRecord = namedtuple('DhcpSnoopRecord', keys)

conn = sqlite3.connect(db_filename)
query = 'select {} from dhcp where {} = ?'.format(','.join(keys), key)

print('-' * 40)
for row in map(DhcpSnoopRecord._make, conn.execute(query, (value,))):
    print(row.mac, row.ip, row.interface, sep='\n')
    print('-' * 40)

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

3. Полезные ссылки и источники примеров

Data classes

Объект Data classes:

  • изменяемый (можно изменять атрибуты, а можно отключить эту возможность)
  • не итерируемый, по умолчанию, но есть встроенные методы, которыми можно собрать атрибуты в словарь или кортеж (здесь появляется возможность обращаться по индексу или по имени)
  • автоматически будут созданы методы __init__, __repr__, __eq__
  • атрибуты класса нужно создавать с помощью аннотации типов. А с использованием модуля pydantic это превращается в киллерфичу, т.к. типы будут автоматически проверяться
  • атрибуты класса можно создавать динамически, сохраняя состояние (с помощью property или __post_init__)
  • можно сравнивать (по умолчанию класс создает только метод __eq__. Объекты сравниваются как кортежи (сравниваются элементы по порядку). Дополнительно можно включить создание методов __lt__, __le__, __gt__, __ge__)

1. Смотрим на методы, создаваемые по умолчанию

In [1]: from dataclasses import dataclass

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

In [3]: ip1 = IPAddress("10.1.1.1", 28)

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

# Смотрим созданные атрибуты:

In [5]: vars(ip1.__class__)
Out[6]:
mappingproxy({'__module__': '__main__',
              '__annotations__': {'ip': str, 'mask': str},
              '__dict__': <attribute '__dict__' of 'IPAddress' objects>,
              '__weakref__': <attribute '__weakref__' of 'IPAddress' objects>,
              '__doc__': 'IPAddress(ip: str, mask: str)',
              '__dataclass_params__': _DataclassParams(init=True,repr=True,eq=True,order=False,unsafe_hash=False,frozen=False),
              '__dataclass_fields__': {'ip': Field(name='ip',type=<class 'str'>,default=<dataclasses._MISSING_TYPE object at 0x7f88e761e790>,default_factory=<dataclasses._MISSING_TYPE object at 0x7f88e761e790>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),_field_type=_FIELD),
               'mask': Field(name='mask',type=<class 'str'>,default=<dataclasses._MISSING_TYPE object at 0x7f88e761e790>,default_factory=<dataclasses._MISSING_TYPE object at 0x7f88e761e790>,init=True,repr=True,hash=None,compare=True,metadata=mappingproxy({}),_field_type=_FIELD)},
              '__init__': <function __main__.__create_fn__.<locals>.__init__(self, ip: str, mask: str) -> None>,
              '__repr__': <function __main__.__create_fn__.<locals>.__repr__(self)>,
              '__eq__': <function __main__.__create_fn__.<locals>.__eq__(self, other)>,
              '__hash__': None})

# Видим __init__, __repr__, __eq__

2. Включаем проверку типов с помощью pydantic

С помощью pydantic можно включить проверку типов. Например:

In [1]: from pydantic.dataclasses import dataclass

In [2]: @dataclass
   ...: class Book:
   ...:     title: str
   ...:     price: int

In [3]: book = Book('Good Omens', price=35)

In [4]: book.__annotations__
Out[4]: {'title': str, 'price': int}

In [5]: vars(book)
Out[5]: {'title': 'Good Omens', 'price': 35, '__initialised__': True}

In [6]: book = Book('Good Omens', price='35')

In [7]: book.__annotations__
Out[7]: {'title': str, 'price': int}

In [8]: vars(book)
Out[8]: {'title': 'Good Omens', 'price': 35, '__initialised__': True}

# Можно заметить, что строка преобразуется в число
# Если строку преобразовать нельзя, возникает ошибка:

In [9]: book = Book('Good Omens', price='a')
---------------------------------------------------------------------------
ValidationError                           Traceback (most recent call last)
<ipython-input-12-c21f0df3a6ac> in <module>
----> 1 book = Book('Good Omens', price='a')

<string> in __init__(self, title, price)

~/virtenvs/3.8.4/lib/python3.8/site-packages/pydantic/dataclasses.cpython-38-x86_64-linux-gnu.so in pydantic.dataclasses._process_class._pydantic_post_init()

ValidationError: 1 validation error for Book
price
  value is not a valid integer (type=type_error.integer)

Примеры использования pydantic.dataclasses

3. Создаем атрибуты динамически

Атрибуты можно создавать динамически:

In [1]: from dataclasses import dataclass

In [2]: @dataclass
   ...: class IPAddress:
   ...:     ip: str
   ...:     mask: int
   ...: 
   ...:     def __post_init__(self):
   ...:         if not isinstance(self.mask, int):
   ...:             self.mask = int(self.mask)
   ...: 

In [3]: ip1 = IPAddress("10.10.1.1.", "24")

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

In [5]: ip1.mask = 28

In [6]: ip1.mask
Out[6]: 28

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

In [18]: from dataclasses import InitVar

In [19]: @dataclass
    ...: class Book:
    ...:     title: str
    ...:     author: str
    ...:     gen_desc: InitVar[bool] = True
    ...:     desc: str = None
    ...: 
    ...:     def __post_init__(self, gen_desc: bool):
    ...:         if gen_desc and self.desc is None:
    ...:             self.desc = "`%s` by %s" % (self.title, self.author)
    ...: 

In [20]: Book("Fareneheit 481", "Bradbury")
Out[20]: Book(title='Fareneheit 481', author='Bradbury', desc='`Fareneheit 481` by Bradbury')

In [21]: Book("Fareneheit 481", "Bradbury", gen_desc=False)
Out[21]: Book(title='Fareneheit 481', author='Bradbury', desc=None)

Атибуты можно создавать более гибко, с применением property:

from dataclasses import dataclass, field, asdict
from typing import Union, Dict


@dataclass
class Book:
    title: str
    price: float
    _price: float = field(init=False, repr=False)
    quantity: int = 0
    total: float
    _total: float = field(init=False)

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

    @total.setter
    def total(self, total: float) -> None:
        self._total = total

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

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

    def to_dict(self) -> Dict[str, str]:
        return {k: v for k, v in asdict(self).items() if not k.startswith("_")}

В примере выше для создания экземпляра класса достаточно указать 3 атрибута: total, price, quantity. Атрибут total является обязательным, но вычисляется динамечски за счет property, поэтому указывать его при создании экземпляра не нужно. Но, поскольку property делает атрибут не изменяемым, по умолчанию, нужно добавить для него setter, иначе при создании класса мы получим ошибку AttributeError: can't set attribute. Поскольку setter никакой роли здесь играть не будет (кроме того, что даст возможность изменения/создания total) создают переменную-пустышку (_total).

Переменная price является обязательной при создании экземпляра класса, а её изменение будет контролироваться setter’ом. getter здесь необходим, чтобы к переменной можно было обращаться.

4. Сравнение объектов Data classes

По умолчанию, в Data classes создается метод __eq__. Можно проверить объекты только на равенство. Но, если глобально разрешить остальные специальные методы сравнения (__lt__, __le__, __gt__, __ge__), то, с помощью параметра переменной compare можно выбрать по какому атрибуту будет выполняться сравнение экземпляров (атрибуты сравниваются по порядку по аналогии с кортежем). Например:

@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

В примере выше при сравнении экземпляров сравниваются self._ip, т.к. self.ip мы исключили из сравнения.

5. Передаем изменяемый аргумент в Data classes

Если необходимо передать изменяемый аргумент при создании экземпляра класса, используется параметр default_factory:

@dataclass
class Bookshelf:
    books: List[Book] = field(default_factory=list)

В default_factory передается любой вызываемый объект без аргументов (это может быть lambda).

6. Наследование атрибутов родительского класса

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

In [23]: from typing import Any

In [24]: @dataclass
    ...: class BaseBook:
    ...:     title: Any = None
    ...:     author: str = None
    ...:
    ...: @dataclass
    ...: class Book(BaseBook):
    ...:     desc: str = None
    ...:     title: str = "Unknown"
    ...:

In [25]: book = Book("Farenheit 481", "Bradbury")

In [26]: book
Out[26]: Book(title='Farenheit 481', author='Bradbury', desc=None)

In [27]: vars(book)
Out[27]: {'title': 'Farenheit 481', 'author': 'Bradbury', 'desc': None}

7. Использование __slots__

In [44]: @dataclass
    ...: class BaseBook:
    ...:      __slots__ = {"title", "author"}
    ...:      title: str
    ...:      author: str
    ...:

In [45]: book = BaseBook("Farenheit 481", "Bradbury")

In [46]: book
Out[46]: BaseBook(title='Farenheit 481', author='Bradbury')

In [47]: book.__dict__
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-47-f50441a761ab> in <module>
----> 1 book.__dict__

AttributeError: 'BaseBook' object has no attribute '__dict__'

In [48]: book.__slots__
Out[48]: {'author', 'title'}

8. Полезные ссылки и источники примеров