Введение в Data classes
Одна из новых возможностей, появившихся в 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)