Одна из новых возможностей, появившихся в 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 <class 'list'> 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)
Комментарии (17)
menstenebris
02.07.2018 14:35Вот бы еще ВМ опиралась на хинтинг типов при предсказании типа. Сколько пишу на питоне, мне это всегда казалось самым слабым местом языка. Одни мытарства numpy и numba чего стоят.
igrishaev
02.07.2018 15:02-1Выглядит как набор костылей. Есть же нормальный Named Tuple, который и класс, и кортеж, и проверяет равенство по данным, а не ссылкам. И ради трех полей вводить столько абстракций… зачем?
fireSparrow
02.07.2018 15:26+2Вы не пробовали сначала прочитать статью, а уже потом комментировать?
igrishaev
02.07.2018 15:29Да, я прочитал всю статью, в т.ч. где упоминается Named Tuple. Непонятки в силе.
Tishka17 Автор
02.07.2018 15:44+1Как минимум два пункта очень сильно мешают:
- обязательная неизменяемость,
- равенство переменных двух разных логических типов.
Изменить это поведение нельзя, так как это сделает namedtuple несовместимым с обычным tuple
trapwalker
03.07.2018 17:13Ради трёх полей может и не стоит, конечно, но если посмотреть какой лютый треш творится в исходниках разных ORM и ODM типа MongoEngine или SQLAlchemy для вот этого всего… Тут хоть попытка стандартизовать механизм.
И всё равно, наверняка, остаётся много тонких неудобных мест с кастомными типами полей.
Конечно не везде уместно использовать такие структуры, но в некоторых случаях это спасёт от лютого велосипедостроения на словарях и метаклассах.igrishaev
03.07.2018 17:26Получается, что ОРМ и прочие библиотеки все равно не смогут этим воспользоваться, потому что это введет ограничение на версию Питона >= 3.7. Лучше было вынести в библиотеку.
trapwalker
03.07.2018 17:36Так оно ж и вынесено. Ну и всё, что не затрагивает синтаксис тайпхинтинга вполне можно реализовать хоть на 2.7 под __futures__
RadicalDreamer
02.07.2018 15:32Помнится, я изобретал костыль как-то для избежания лишней писанины при создании классов.
class MyClass: def __init__(self, arg1, arg2, arg3): l = locals() del l["self"] for name in l: setattr(self, name, l[name]) >>> myClass = MyClass(1, 2, 3) >>> myClass.arg3 3 >>> dir(myClass) ['__doc__', '__init__', '__module__', 'arg1', 'arg2', 'arg3']
Понятно, что это жуткий хак, и такое не стоит тащить в продакшн, зато выручало в тех случаях, когда трудно сходу определиться со списком аргументов в __init__ (с учетом того, что на каждое изменение наименования / удаление / добавление аргумента приходятся соответствующие изменения в теле функции __init__).YuriM1983
02.07.2018 17:56Написать-то можно. Главная проблема — инспектор кода в том же PyCharm'е будет постоянно жаловаться, что в переменная не инициализирована в __init__.
RadicalDreamer
02.07.2018 18:30Ну я в общем-то и не утверждал, что это полностью ready-to-use решение.
С PyCharm иметь дел не приходилось.
Другим возможным решением могла бы стать динамическая генерация кода __init__, но на мой взгляд, для одноразовых скриптов это был бы оверкилл.
trapwalker
03.07.2018 17:22А это, кстати, больше проблема PyCharm, имхо. Они никак не впилят своему линтеру понимание, что когда в классе объявлено свойство, то нужно вычислять тип соответствующего атрибута не по типу свойства, а по типу, возвращаемого геттером значения.
trapwalker
03.07.2018 17:19Я в таких случаях делаю специальный метакласс, который создаёт или обрабатывает свойства на основе атрибутов, задекларированных в теле класса. А в такой конструктор чтобы прокинуть какие-то параметры, которые не нужно хранить, придётся как-то кодировать имена подчеркиваниями, анализировать это… Короче Гвидо будет вертеться в постели от такого пренебрежения ПЕПом.
naryl
Языку шёл двадцать восьмой год… Теперь ждём ещё три-пять лет до очередного озарения Гвидо?