Введение в Python Dataclasses – Часть 1

Spread the love

Если вы читаете эту статью, то возможно вы уже знакомы с Python 3.7 и читали о его новых функциях. Это статья посвящена типу классов, а именно Dataclasses. Я долго ждал их появление.

Эта серия статей будет состоять из двух частей:

  1. В первой части будет описаны базовые возможности Dataclasses
  2. Во второй части я подробнее опишу dataclasses.field

Вступление

Dataclasses – это новые классы в Python, которые предназначены создания объектов данных (или как их еще называют классов данных). Вы спрашиваете, что такое объекты данных? Вот неполный список функций, которые определяют объекты данных:

  • Они предназначены для хранения данных и тем самым они представлять собой особый тип классов. Например: это может быть просто число или, даже это может быть экземпляр модели ORM. Создавая таким образом особый вид сущности. Так же они могут содержит атрибуты, которые определяют или представляют эту сущность.
  • Их так же можно сравнить с другими объектами того же типа. Например: число может быть больше, меньше или равно другому числу

У Dataclasses есть, конечно, больше возможностей, но этого списка достаточно для начало, чтобы помочь вам понять суть.

Чтобы разобраться с Dataclasses, мы реализуем простой класс, который будет содержат простое число и позволит нам выполнить вышеупомянутые операции. Сначала мы используем обычные классы для этого, а затем сделаем все тоже самое с помощью Dataclasses.

Но прежде чем мы начнем, пару слов о том как создать Dataclasses

В Python 3.7 предоставлен новый декоратор dataclass, который используется для преобразования обычного класса в класс данных (dataclass).

Все, что вам нужно сделать, это обернуть класс в декоратор:

from dataclasses import dataclass

@dataclass
class A:
 …

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

Инициализация

Обычный класс

class Number:
  __init__(self, val):
    self.val = val
 
>>> one = Number(1)
>>> one.val
>>> 1

dataclass

@dataclass
class Number:
  val:int 
 
>>> one = Number(1)
>>> one.val
>>> 1

Что изменилось с использованием декоратора dataclass:

  1. Нет необходимости определять __init__ и затем присваивать значения self
  2. Мы определили атрибуты-члены класса что гораздо более читабельно, наряду с определением типов (type hinting). Теперь мы сразу видим, что val имеет тип int.

Zen of Python: читабельность важна

Также возможно определить значения по умолчанию:

@dataclass
class Number:
    val:int = 0

Представление

Представление объекта – это значимое строковое представление объекта, которое очень полезно при отладке.

Представление объектов Python по умолчанию не особо понятно и читабельно, обычно это что типа такого object at 0x7ff395b2ccc0:

class Number:
    def __init__(self, val = 0):
    self.val = val
 
>>> a = Number(1)
>>> a
>>> <__main__.Number object at 0x7ff395b2ccc0>

Это не дает нам понимание о полезности объекта и приводит к сложности при отладки.

Значимое представление может быть реализовано путем определения метода __repr__ в определении класса.

def __repr__(self):
    return self.val

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

>>> a = Number(1)
>>> a
>>> 1

dataclass автоматически добавляет функцию __repr__, поэтому нам не нужно ее реализовывать вручную.

@dataclass
class Number:
    val: int = 0
>>> a = Number(1)
>>> a
>>> Number(val = 1)

Сравнение данных

Как правило, объекты данных необходимо сравнивать друг с другом.

Сравнение между двумя объектами a иb обычно состоит из следующих операций:

  • a < b
  • a > b
  • a == b
  • a >= b
  • a <= b

В python можно определить методы в классах, которые могут выполнять вышеуказанные операции. Для простоты, я продемонстрирую лишь реализацию == и <.

Обычный класс

class Number:
    def __init__( self, val = 0):
       self.val = val
 
    def __eq__(self, other):
        return self.val == other.val
 
    def __lt__(self, other):
        return self.val < other.val

dataclass

@dataclass(order = True)
class Number:
    val: int = 0

Да, вот и все.

Нам не нужно определять методы __eq__ и __lt__, потому что декоратор dataclass автоматически добавляет их в определение класса при вызове с order = True

Ну, как это реализуется?

Когда вы используете dataclass, он добавляет функции __eq__ и __lt__ в определение класса. Мы уже знаем это. Как эти функции знают, что нужно проверить равенство или сделать сравнение?

Сгенерированная функцией __eq__ будет сравнивать кортеж своих атрибутов с кортежем атрибутов другого экземпляра того же класса. В нашем случае вот что эквивалентно автоматически сгенерированной функции __eq__:

def __eq__(self, other):
    return (self.val,) == (other.val,)

Давайте посмотрим на более сложный пример:

Мы напишем класс данных Person, которое будет содержать имя (name) и возраст (age).

@dataclass(order = True)
class Person:
    name: str
    age:int = 0

Автоматически сгенерированный метод __eq__ будет эквивалентен:

def __eq__(self, other):
    return (self.name, self.age) == ( other.name, other.age)

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

Аналогично, эквивалентная функция __le__ будет похожа на:

def __le__(self, other):
    return (self.name, self.age) <= (other.name, other.age)

Необходимость определения функции, подобной __le__, обычно возникает, когда вам нужно отсортировать список ваших объектов данных. Встроенная функция сортировки Python основана на сравнении двух объектов.

>>> import random

>>> a = [Number(random.randint(1,10)) for _ in range(10)] #generate list of random numbers

>>> a
>>> [Number(val=2), Number(val=7), Number(val=6), Number(val=5), Number(val=10), Number(val=9), Number(val=1), Number(val=10), Number(val=1), Number(val=7)]

>>> sorted_a = sorted(a) #Sort Numbers in ascending order
>>> [Number(val=1), Number(val=1), Number(val=2), Number(val=5), Number(val=6), Number(val=7), Number(val=7), Number(val=9), Number(val=10), Number(val=10)]

>>> reverse_sorted_a = sorted(a, reverse = True) #Sort Numbers in descending order 

>>> reverse_sorted_a
>>> [Number(val=10), Number(val=10), Number(val=9), Number(val=7), Number(val=7), Number(val=6), Number(val=5), Number(val=2), Number(val=1), Number(val=1)]

dataclass как вызываемый декоратор

Не всегда желательно, что бы были определены все методы. Возможно вам понадобится только хранение значений и проверка равенства. Таким образом, вам нужны только определенные методы __init__ и __eq__. Если бы мы могли сказать декоратору не генерировать другие методы, это уменьшило бы некоторые накладные расходы, и у нас были бы только нужные операции над объектом данных.

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

Из официальных документов декоратор может использоваться как вызываемый со следующими аргументами:

@dataclass(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)
class C:
 …
  1. init : По умолчанию будет создан метод __init__. Если ему передано значение False, у класса не будет метода __init__.
  2. repr : Метод __repr__ генерируется по умолчанию. Если ему передано значение False, у класса не будет метода __repr__.
  3. eq: По умолчанию будет создан метод __eq__. Если ему передано значение False, метод __eq__ не будет добавлен классом данных, но по умолчанию все равное будет object.__eq__.
  4. order : По умолчанию генерируются методы __gt__, __ge__, __lt__, __le__. Если будет False, они не будут заданы.

Аргумент frozen мы обсудим чуть позже. Аргумент unsafe_hash заслуживает отдельного поста из-за его сложных вариантов использования.

Сейчас вернемся к нашему примеру использования, вот что нам нужно:

1. __init__
2. __eq__

Эти функции генерируются по умолчанию, поэтому нам не нужно генерировать другие функции. Как нам это сделать? Просто передайте соответствующим аргументам значение False.

@dataclass(repr = False) # order, unsafe_hash and frozen are False
class Number:
    val: int = 0

>>> a = Number(1)

>>> a
>>> <__main__.Number object at 0x7ff395afe898>

>>> b = Number(2)

>>> c = Number(1)

>>> a == b
>>> False

>>> a < b
>>> Traceback (most recent call last):
 File “<stdin>”, line 1, in <module>
TypeError: ‘<’ not supported between instances of ‘Number’ and ‘Number’

Frozen экземпляры

Frozen (Замороженные) экземпляры – это объекты, атрибуты которых нельзя изменить после инициализации объекта.

В Python невозможно создать действительно неизменяемые объекты. Всегда можно найти способ изменить объект.

Создать неизменяемые атрибуты в объекте в Python – трудная задача, и я не буду подробно останавливаться на этом.

Вот что мы ожидаем от неизменного объекта:

>>> a = Number(10) # Предполагая, что числовой класс является неизменным

>>> a.val = 10 # Тут должна появится Error

С помощью классов данных можно определить замороженный объект, используя декоратор dataclass как вызываемый объект с аргументом frozen = True.

Когда создается экземпляр замороженного объекта класса данных, любая попытка изменить атрибуты объекта вызывает FrozenInstanceError.

@dataclass(frozen = True)
class Number:
    val: int = 0

>>> a = Number(1)
>>> a.val
>>> 1

>>> a.val = 2
>>> Traceback (most recent call last):
 File “<stdin>”, line 1, in <module>
 File “<string>”, line 3, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field ‘val’

Таким образом, замороженный экземпляр – отличный способ для хранения…

  • констант
  • конфигурационных параметров

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

Обработка после инициализации

С помощью Dataclasses мы может отказаться от использования метода __init__ для присваивания переменных self. Но теперь мы теряем гибкость выполнения вызовов функций, которые могут потребоваться сразу после назначения переменных.

Давайте обсудим пример, в котором мы определяем класс Float для хранения чисел с плавающей точкой, и сразу вычисляем целую и десятичную части, после инициализации.

Обычный класс

import math

class Float:
    def __init__(self, val = 0):
        self.val = val
        self.process()
 
    def process(self):
        self.decimal, self.integer = math.modf(self.val)
 
>>> a = Float( 2.2)
>>> a.decimal
>>> 0.2000
>>> a.integer
>>> 2.0

К счастью, в классах данных обработка после инициализации выполняется с помощью метода __post_init__.

Сгенерированный метод __init__ вызывает метод __post_init__ . Таким образом, любая обработка может быть выполнена в этом методе.

import math

@dataclass
class FloatNumber:
    val: float = 0.0
 
    def __post_init__(self):
        self.decimal, self.integer = math.modf(self.val)
 
>>> a = Number(2.2)
>>> a.val
>>> 2.2
>>> a.integer
>>> 2.0
>>> a.decimal
>>> 0.2

Изящно, не правда ли!

Наследование

Dataclasses поддерживают наследование так же как обычные классы Python.

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

@dataclass
class Person:
    age: int = 0
    name: str

@dataclass
class Student(Person):
    grade: int

>>> s = Student(20, "John Doe", 12)
>>> s.age
>>> 20
>>> s.name
>>> "John Doe"
>>> s.grade
>>> 12

Обратите внимание на тот факт, что аргументы для Student находятся в порядке полей, определенных в определении класса.

Как себя ведет __post_init__ во время наследования?

Поскольку __post_init__ – это просто еще одна функция, ее вызов не меняется:

@dataclass
class A:
    a: int
    
    def __post_init__(self):
        print("A")

@dataclass
class B(A):
    b: int
    
    def __post_init__(self):
        print("B")

>>> a = B(1,2)
>>> B

В приведенном выше примере вызывается только метод __post_init__ класса B. Но как нам вызвать метод __post_init__ класса A?

Поскольку это функция родительского класса, его можно вызвать с помощью super.

@dataclass
class B(A):
    b: int
    
    def __post_init__(self):
        super().__post_init__() #Call post init of A
        print("B")

>>> a = B(1,2)
>>> A
    B

Заключение

Итак, выше приведены несколько способов, которыми Dataclasses облегчают жизнь разработчикам Python. Я старался быть тщательным и охватывать большинство случаев использования, но ни один человек не идеален. Пишите, если вы обнаружите ошибки или захотите, чтобы я обратил внимание на какие то другие варианты использования.

В следующей статье я расскажу о dataclasses.field.

Оригинал статьи: Shikhar Chauhan Understanding Python Dataclasses — Part 1


Spread the love

Добавить комментарий

Ваш e-mail не будет опубликован.