Введение в Data classes

habr.png

Одна из новых возможностей, появившихся в Python 3.7 — классы данных (Data classes). Они призваны автоматизировать генерацию кода классов, которые используются для хранения данных. Не смотря на то, что они используют другие механизмы работы, их можно сравнить с «изменяемыми именованными кортежами со значениями по-умолчанию».


Введение


Все приведенные примеры требуют для своей работы Python 3.7 или выше

Большинству python-разработчикам приходится регулярно писать такие классы:

class RegularBook:
    def __init__(self, title, author):
        self.title = title
        self.author = author

Уже на этом примере видна избыточность. Идентификаторы title и author используются несколько раз. Реальный класс же будет ещё содержать переопределенные методы __eq__ и __repr__.

Модуль dataclasses содержит декоратор @dataclass. С его использованием аналогичный код будет выглядеть так:

@dataclass
class Book:
    title: str
    author: str

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

Что же вы получаете в результате? Вы автоматически получаете класс, с реализованными методами __init__, __repr__, __str__ и __eq__. Кроме того, это будет обычный класс и вы можете наследоваться от него или добавлять произвольные методы.

>>> book = Book(title="Fahrenheit 451", author="Bradbury")
>>> book
Book(title='Fahrenheit 451', author='Bradbury')
>>> book.author
'Bradbury'
>>> other =  Book("Fahrenheit 451", "Bradbury")
>>> book == other
True


Альтернативы


Кортеж или словарь

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

book = ("Fahrenheit 451", "Bradbury")
other = {'title': 'Fahrenheit 451', 'author': 'Bradbury'}

Однако у такого подхода есть недостатки:


  • Необходимо помнить, что переменная содержит данные, относящиеся к данной структуре.
  • В случае словаря, вы должны следить за названиями ключей. Такая инициализация словаря {'name': 'Fahrenheit 451', 'author': 'Bradbury'} тоже будет формально корректной.
  • В случае кортежа вы должны следить за порядком значений, так как они не имеют имен.

Есть вариант получше:


Namedtuple

from collections import namedtuple 
NamedTupleBook = namedtuple("NamedTupleBook", ["title", "author"])

Если мы воспользуемся классом, созданным таким образом, мы получим фактически то же самое, что и использованием с data class.

>>> book = NamedTupleBook("Fahrenheit 451", "Bradbury")
>>> book.author
'Bradbury'
>>> book
NamedTupleBook(title='Fahrenheit 451', author='Bradbury')
>>> book == NamedTupleBook("Fahrenheit 451", "Bradbury"))
True

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

Во-первых, вы все ещё можете сравнивать экземпляры разных классов.

>>> Car = namedtuple("Car", ["model", "owner"])
>>> book = NamedTupleBook("Fahrenheit 451", "Bradbury"))
>>> book == Car("Fahrenheit 451", "Bradbury")
True

Во-вторых, именованные кортежи неизменяемы. В некоторых ситуациях это бывает полезно, но хотелось бы большей гибкости.
И наконец, вы можете оперировать именованным кортежем так же как обычным. Например, итерироваться.


Другие проекты

Если не ограничиваться стандартной библиотекой, можно найти другие решения данной задачи. В частности, проект attrs. Он умеет даже больше чем dataclass и работает на более старых версиях python таких как 2.7 и 3.4. И тем не менее, то, что он не является частью стандартной библиотеки, может быть неудобно


Создание

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

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

from dataclasses import make_dataclass
Book = make_dataclass("Book", ["title", "author"])
book = Book("Fahrenheit 451", "Bradbury")


Значения по-умолчанию

Одна из полезных особенностей — легкость добавления к полям значений по-умолчанию. Все ещё не требуется переопределять метод __init__, достаточно указать значения прямо в классе.

@dataclass
class Book:
    title: str = "Unknown"
    author: str = "Unknown author"

Они будут учтены в сгенерированном методе __init__

>>> Book()
Book(title='Unknown', author='Unknown author')
>>> Book("Farenheit 451")
Book(title='Farenheit 451', author='Unknown author')

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

Кроме того, важно следить за порядком определения полей, имеющих значения по-умолчанию, так как он в точности соответствует их порядку в методе __init__


Иммутабельные классы данных

Экземпляры именованных кортежей неизменяемые. Во многих ситуациях, это хорошая идея. Для классов данных вы тоже можете сделать это. Просто укажите параметр frozen=True при создании класса и если вы попытаетесь изменять его поля, выбросится исключение FrozenInstanceError

@dataclass(frozen=True)
class Book:
    title: str
    author: str
>>> book = Book("Fahrenheit 451", "Bradbury")
>>> book.title = "1984"
dataclasses.FrozenInstanceError: cannot assign to field 'title'


Настройка класса данных

Кроме параметра frozen, декоратор @dataclass обладает другими параметрами:


  • init: если он равен True (по-умолчанию), генерируется метод __init__. Если у класса уже определен метод __init__, параметр игнорируется.
  • repr: включает (по-умолчанию) создание метода __repr__. Сгенерированная строка содержит имя класса и название и представление всех полей, определенных в классе. При этом можно исключить отдельные поля (см. ниже)
  • eq: включает (по-умолчанию) создание метода __eq__. Объекты сравниваются так же, как если бы это были кортежи, содержащие соответствующие значения полей. Дополнительно проверяется совпадение типов.
  • order включает (по-умолчанию выключен) создание методов __lt__, __le__, __gt__ и __ge__. Объекты сравниваются так же, как соответствующие кортежи из значений полей. При этом так же проверяется тип объектов. Если order задан, а eq — нет, будет сгенерировано исключение ValueError. Так же, класс не должен содержать уже определенных методов сравнения.
  • unsafe_hash влияет на генерацию метода __hash__. Поведение так же зависит от значений параметров eq и frozen


Настройка отдельных полей

В большинстве стандартных ситуаций это не потребуется, однако есть возможность настроить поведение класса данных вплоть до отдельных полей с использованием функции field.


Изменяемые значения по-умолчанию

Типичная ситуация, о которой говорилось выше — использование списков или других изменяемых значений по-умолчанию. Мы можете захотеть класс «книжная полка», содержащий список книг. Если вы запустите следующий код:

@dataclass
class Bookshelf:
    books: List[Book] = []

интерпретатор сообщит об ошибке:

ValueError: mutable default  for field books is not allowed: use default_factory

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

Чтобы избежать проблем, предлагается использовать параметр default_factory функции field. В качестве его значения может быть любой вызываемый объект или функция без параметров.
Корректная версия класса выглядит так:

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


Другие параметры

Кроме указанного default_factory функция field имеет следующие параметры:


  • default: значение по-умолчанию. Этот параметр необходим, так как вызов field заменяет задание значения поля по-умолчанию
  • init: включает (задан по-умолчанию) использование поля в методе __init__
  • repr: включает (задан по-умолчанию) использование поля в методе __repr__
  • compare включает (задан по-умолчанию) использование поля в методах сравнения (__eq__, __le__ и других)
  • hash: может быть булевое значение или None. Если он равен True, поле используется при вычислении хэша. Если указано None (по-умолчанию) — используется значение параметра compare.
    Одной из причин указать hash=False при заданном compare=True может быть сложность вычисления хэша поля при том, что оно необходимо для сравнения.
  • metadata: произвольный словарь или None. Значение оборачивается в MappingProxyType, чтобы оно стало неизменяемым. Этот параметр не используется самими классами данных и предназначено для работы сторонних расширений.


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

Автосгенерированный метод __init__ вызывает метод __post_init__, если он определен в классе. Как правило он вызывается в форме self.__post_init__(), однако если в классе определены переменные типа InitVar, они будут переданы в качестве параметров метода.

Если метод __init__ не был сгенерирован, то он __post_init__ не будет вызываться.

Например, добавим сгенерированное описание книги


@dataclass
class Book:
    title: str
    author: str
    desc: str = None

    def __post_init__(self):
        self.desc = self.desc or "`%s` by %s" % (self.title, self.author)
>>>  Book("Fareneheit 481", "Bradbury")
Book(title='Fareneheit 481', author='Bradbury', desc='`Fareneheit 481` by Bradbury')


Параметры только для инициализации

Одна из возможностей, связанных с методом __post_init__ — параметры, используемые только для инициализации. Если при объявления поля указать в качестве его типа InitVar, его значение будет передано как параметр метода __post_init__. Никак по-другому такие поля не используются в классе данных.

@dataclass
class Book:
    title: str
    author: str
    gen_desc: InitVar[bool] = True
    desc: str = None

    def __post_init__(self, gen_desc: str):
        if gen_desc and self.desc is None:
            self.desc = "`%s` by %s" % (self.title, self.author)
>>> Book("Fareneheit 481", "Bradbury")
Book(title='Fareneheit 481', author='Bradbury', desc='`Fareneheit 481` by Bradbury')
>>> Book("Fareneheit 481", "Bradbury", gen_desc=False)
Book(title='Fareneheit 481', author='Bradbury', desc=None)


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

Когда вы используете декоратор @dataclass, он проходит по всем родительским классам начиная с object и для каждого найденного класса данных сохраняет поля в упорядоченный словарь (ordered mapping), затем добавляя свойства обрабатываемого класса. Все сгенерированные методы используют поля из полученного упорядоченного словаря.

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

Так как упорядоченный словарь хранит значения в порядке вставки, то для следующих классов

@dataclass
class BaseBook:
    title: Any = None
    author: str = None

@dataclass
class Book(BaseBook):
    desc: str = None
    title: str = "Unknown"

будет сгенерирован __init__ метод с такой сигнатурой:

def __init__(self, title: str="Unknown", author: str=None, desc: str=None)

© Habrahabr.ru