Полное руководство по классам данных в Python 3.7

Еще одно впечатляющее обновление в Python 3.7 – это класс данных.

Содержание

Класс данных – это класс, который (в основном) хранит данные, хотя на самом деле, нет никаких ограничений. Он разработан при помощи декоратора @dataclass следующим образом:

Обратите внимание: этот код, как и другие примеры в данном руководстве, будет работать только в Python 3.7 и выше.

Есть вопросы по Python?

На нашем форуме вы можете задать любой вопрос и получить ответ от всего нашего сообщества!

Telegram Чат & Канал

Вступите в наш дружный чат по Python и начните общение с единомышленниками! Станьте частью большого сообщества!

Паблик VK

Одно из самых больших сообществ по Python в социальной сети ВК. Видео уроки и книги для вас!

Класс данных имеет уже реализованный базовый функционал. Например, вы можете создать экземпляр, выводить и сравнивать существующие классы данных, сразу без лишнего кода:

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

Так как здесь не нужно писать много кода, вы уже можете видеть признаки страданий в шаблоне: rank и suit повторяются три раза, чтобы просто инициализировать объект. Кроме этого, если вы попробуете использовать этот простой класс, вы заметите, что представление объектов не очень подробное, и по ряду причин, тот же объект queen of hearts (королева черв) не будет тем же, что и queen of hearts:

Похоже, что классы данных помогают нам за кулисами. ПО умолчанию, классы данных реализует метод .__repr__(), чтобы предоставить хорошее строковое представление, а также метод .__eq__(), который в состоянии выполнять базовые сравнения объектов.

Чтобы класс RegularCard имитировал указанный выше класс данных, вам нужно (в том числе) добавить следующие методы:

В этом руководстве, вы узнаете, какие именно удобства предоставляются классами данных в Python. В дополнение к хорошим представлениям и сравнениям, вы узнаете:

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

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

Альтернативы классам данных

Скорее всего, вы уже пользовались кортежами или словарями для простых структур данных. Вы можете представить карту «королевы черв» одним из следующих способов:

Это работает. Однако, это ставит перед вами большую ответственность, как перед программистом:

  • Вам нужно помнить, что переменные queen_of_hearts_… представляет собой карту.
  • Для версии _tuple, вам нужно помнить порядок атрибутов. Вписав (‘Пики’, ‘A’) вы наведете беспорядок в вашей программе, при этом, вы не получите внятное уведомление об ошибке.
  • Если вы используете _dict, вы должны убедиться в том, что имена атрибутов согласованы. Например, {‘value’: ‘A’, ‘suit’: ‘Пики’} не будет работать так, как ожидается.

Кроме этого, использование этих структур не идеально:

Лучшая альтернатива – это namedtuple. Он уже давно используется для создания небольших читаемых структур данных. Фактически, мы можем пересоздать пример класса данных, показанного выше, при помощи namedtuple вот так:

Это определение NamedTupleCard даст тот же результат, что и наш пример с DataClassCard:

Зачем беспокоиться из-за класса данных?

Прежде всего, классы данных включают намного больше возможностей, чем вы могли видеть. В то же время, namedtuple также имеет различные функции, которые далеко не всегда желаемые. По дизайну, namedtuple – это регулярный кортеж. Это можно увидеть в сравнениях, например:

Пока это выглядит хорошо, однако недостаточная осведомленность о собственном типе может привести к труднонаходимым багам, тем более, что он спокойно может сравнить два разных класса namedtuple:

namedtuple также содержит некоторые ограничения. Например, трудно добавить значения по умолчанию в некоторых полях в namedtuple. Кроме этого, он по природе неизменяемый. Это значит, что значение namedtuple не может меняться. В некоторых приложениях – это отлично, но в других настройках неплохо было бы иметь чуть больше гибкости:

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

Еще одна альтернатива (в т.ч. и одно из вдохновений для классов данных) – то проект attrs. С установленным attrs (pip install attrs), вы можете прописать карту следующим образом:

Это может быть использовано абсолютно таким же образом, как в примерах с DataClassCard и NamedTupleCard, указанных ранее. Проект attrs просто замечательный и поддерживает некоторые возможности, которые не поддерживают классы данных, включая конвертеры и валидаторы. Кроме этого, attrs уже доступен какое-то время и поддерживается Python 2.7, как и Python 3.4 и выше.

Однако, attrs не является частью стандартной библиотеки и добавляет таким образом дополнительную зависимость в ваши проекты. С помощью классов данных, аналогичная функциональность будет доступна в любом случае.

В дополнение к картежу, словарям, namedtuple и attrs, существует множество других аналогичных проектов, включая typing.NamedTuple, namedlist, attrdict, plumber и fields. Хотя классы данных – отличная новая альтернатива, всё перечисленное все еще используются в случаях, когда старые варианты подходят лучше.

К примеру, если вам нужна совместимость с определенным API, ожидающим кортежи или нуждающимся в функционале, не поддерживаемым классами данных.

Основы класс данных

Давайте вернемся к классам данных. Для примера, мы создадим класс Position, который будет отображать географическое расположение с названием, а также показатели долготы и широты:

Декоратор @dataclass делает класс – классом данных, прямо над определением класса. Под строкой (№4) class Position:, вы просто афишируете список полей, которые вы хотите в своем классе данных.

Нотация «:», которая используется для полей, использует новую функцию в Python 3.6, под названием типизация переменных. Мы в скором времени поговорим об типизации, и о том, почему мы определяемы типы данных, таких как str и float.

Эти несколько строк кода – это все, что вам нужно. Новый класс готов к использованию:

Вы также можете создать классы данных аналогично тому, как создаются названные кортежи. Следующее является практическим эквивалентом определения Position, описанном ранее:

Класс данных – это обычный класс Python. Единственное, что его отличает, это то, он содержит базовые методы модели данных, такие как .__init__(), .__repr__(), and .__eq__(), которые выкатили именно для вас.

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

Добавить значения по умолчанию в поля вашего класса данных – это очень просто:

Это работает именно так, как когда вы определяли значения по умолчанию в определении метода .__init__() обычного класса:

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

Типизирование

До сих пор мы не столкнулись с тем, что классы данных изначально поддерживают контроль типа. Вы могли заметить, что мы определили тип атрибутов класса вот так: name: str говорит, что атрибут «name» должны быть строкой (тип str).

Фактически, добавление какого-либо типа является обязательным при определении полей в вашем классе данных. Без типизации, поле не будет частью класса данных. Однако, если вы не хотите добавлять лишние типы в ваш класс данных, используйте typing.Any:

Хотя вам нужно добавить тип при вводе в какой-нибудь форме при использовании классов данных, эти типы не запускаются во время выполнения. Следующий код запускается без каких-либо проблем:

Вот как типизация работает в Python: он является и всегда будет динамически типизированным языком.

Чтобы поймать возникшие ошибки, чекер типов, такой как Mypy может быть запущен в вашем исходном коде.

Добавление методов

Вы уже знаете, что класс данных – это просто обычный класс. Это значит, что вы можете свободно вносить собственные методы в класс данных.

Например, давайте подсчитаем расстояние между двумя точками на поверхности земли используя модуль math. Один из способов сделать это — воспользоваться формулой Haversine.

Полное руководство по классам данных в Python 3.7

Вы можете внести метод .distance_to() в ваш класс дынных так же, как и с обычными классами:

Это работает так же, как вы могли ожидать:

Более гибкие классы данных

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

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

Давайте вернемся к примеру с игральными картами, который мы видели в начале руководства и внесем класс, содержащий колоду карт, пока мы находимся здесь:

Простая колода , содержащая только две карты, может быть создана следующим образом:

Продвинутые значения по умолчанию

Представим, что вы хотите дать значение по умолчанию колоде. Это может быть удобно, если Deck() создало регулярную (французскую) колоду из 52 игральных карт. Сначала, определим масти и значения. Далее, внесем функцию make_french_deck(), которая создает список экземпляров PlayingCard:

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

Обратите внимание: выше мы использовали символы, такие как ♠ прямо в исходном коде. Мы можем сделать это, так как Python поддерживает написание исходного кода в UTF-8 по умолчанию. Обратитесь к этой странице во вкладке Unicode, чтобы узнать, как ввести это на своей системе. Вы можете также ввести символы юникода для мастей при помощи \N (как \N{BLACK SPADE SUIT}) или \u (как \u2660).

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

В теории, вы теперь можете использовать эту функцию для определения значения по умолчанию для Deck.cards:

Не нужно этого делать! Это подводит к одному из самых распространенных анти-шаблонов Python: использование изменяемых аргументов по умолчанию.

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

Вообще, классы данных попытаются остановить вас от этого решения, так что код выше приведет к ошибке ValueError.

Вместо этого, классы данных используют кое-что под названием default_factory для обработки изменяемых значений по умолчанию. Чтобы использовать default_factory (и многие другие крутые возможности классов данных), вам нужно использовать определитель field():

Аргументом для default_factory может быть любой вызываемый нулевой параметр. Теперь легко создать цельную колоду игральных карт:

Определитель field() используется для кастомизации каждого поля или класса данных индивидуально. Вы увидите несколько примеров ниже. Для примера, рассмотрим поддерживаемые field() параметры:

  • default: Значение поля по умолчанию;
  • default_factory: Функция, которая возвращает начальное значение поля;
  • init: использует поле в методе .__init__() (True по умолчанию);
  • repr: Использует поле repr объекта (True по умолчанию);
  • compare: Включает поле в сравнениях (True по умолчанию);
  • hash: Включает поле при подсчете hash() (По умолчанию используется то же, что и при сравнении);
  • metadata: Сопоставление с информацией о поле

В примере с Position вы видели, как добавлять некоторые значение по умолчанию, прописав lat: float = 0.0. Однако, если вы также хотите кастомизировать поле, например, чтобы спрятать его в repr, вам нужно использовать параметр по умолчанию:

Вы можете не определять и default, и default_factory.

Параметр metadata не используется лично классами данных, но доступен для вас (как и сторонние пакеты) для внесения информации в поля. В примере с Position, вы можете, например, определять, что долгота и широта должны быть в градусах:

metadata (и другая информация о поле) может быть получена с помощью функции fields() (обратите внимание на множественное число, заканчивается с буквой «s»):

Вам нужно представление?

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

Хотя это представление колоды явное и читаемое, оно также и очень многословное. Я удалил 48 из 52 в колоде в выдаче выше. На 80-и колоночном дисплее, выдача целой колоды занимает 22 строки! Давайте внесем боле сжатое представление. В целом, объект Python имеет две разные строки представления:

  • repr(obj) определен obj.__repr__() и должен возвращать приемлемое для разработчика представление об объекте. При возможности, это должен быть код, который воссоздает объект. Классы данных могут это;
  • str(obj) определен obj.__str__() и может возвращать приемлемое для разработчика представление об объекте. Классы данных не реализуют метод .__str__(), так что Python вернется назад к методу .__repr__().

Давайте реализуем юзер-френдли представление PlayingCard:

Карты теперь выглядят намного приятнее, но колода все еще слишком многословная:

Чтобы показать, что вы можете внести собственный метод .__repr__() в том числе, мы нарушим принцип, согласно которому, метод должен возвращать код, который воссоздает объект. Практичность иногда должна превосходить чистоту. Следующий код вносит боле сжатое представление колоды:

Обратите внимание на определитель !s в строке формата. Это значит, что мы явно хотим использовать представление str() наших игральных карт. С новым .__repr__(), представление колоды стало выглядеть читабельнее и проще для глаз:

Сравнение карт

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

Однако это легко (на первый взгляд) исправить:

Декоратор @dataclass имеет две формы. До сих пор мы видели простую форму, где @dataclass определен без круглых скобок и параметров. Однако, вы также можете задать параметры @dataclass в круглых скобках. Поддерживаются следующие параметры:

  • init: Вносит метод .__init__() (True по умолчанию)
  • repr: Вносит метод .__repr__() (True по умолчанию)
  • eq: Вносит метод .__eq__() (True по умолчанию)
  • order: Вносит методы упорядочивания (False по умолчанию)
  • unsafe_hash: Выполняет внесение метода .__hash__() (False по умолчанию)
  • frozen: если True, присвоение к полям вызывает ошибку (False по умолчанию)

Ознакомьтесь с оригинальным PEP для дополнительной информации о каждом параметре. После установки order=True, экземпляры PlayingCard могут быть сравнены:

Хотя как сравниваются две карты? Вы не определяли, каким должен быть порядок, и по какой-то причине, Python видимо верит, что королева выше, чем туз…

Выясняется, что классы данных сравнивают объекты, как если бы они были кортежами на своих полях. Другими словами, королева выше туза, так как “Q” (Queen) выше “A” (Ace), так как Q идет после А в алфавите.

Это не совсем что мы хотим. Вместо этого, нам нужно определить определенный тип индекса сортировки, который использует порядок номиналов (RANKS) и мастей (SUITS).

Например, как-то так:

Для PlayingCard, чтобы использовать этот индекс сортировки для сравнений, нам нужно внести поле .sort_index в класс. Однако, это поле должно быть подсчитано другими полями .rank и .suit автоматически. Именно здесь нам понадобится метод .__post_init__(). Он позволяет проводить специальные подсчеты после вызова регулярного метода .__init__():

Обратите внимание, что .sort_index внесен как первое поле класса. Таким образом, сравнение сначала проводится при помощи sort_index, при наличии связей, используемых другими полями. Используя field(), вы также должны определить, что .sort_index не должен быть включен как параметр в методе .__init__() (так как он подсчитан в полях .rank and .suit). Чтобы не запутать пользователя этими деталями реализации, будет неплохой идеей убрать .sort_index из repr класса.

Наконец, туз стал выше:

Создаем отсортированную колоду карт:

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

Неизменяемые классы данных

Одно из определяющих свойств namedtuple, которые вы видели ранее – это то, что он неизменяем. Именно так, значение его полей никогда не может меняться. Для множества типов классов данных – это отлично! Чтобы сделать класс данных неизменяемым, установите frozen=True при создании.

Например, следующее – это неизменяемая версия класса Position, который вы видели ранее:

В замороженном классе данных вы не можете менять значения для полей после их создания:

Имейте ввиду, что если ваш класс данных содержит изменяемые поля, они все еще могут изменяться. Это относится ко всем вложенным структурам данных в Python:

Хотя и ImmutableCard, и ImmutableDeck неизменяемые, список, содержащий карты таковым не является. Хотя вы все еще можете менять карты в колоде:

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

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

Вы можете наследовать класс данных, не прилагая особых усилий. К примеру, мы расширим наш пример Position полем country и используем его для записи городов-столиц:

В этом простом примере все работает без заминок:

Поле country нашего Capital внесено после трех изначальных полей в Position. Здесь всё будет немного сложнее, если поля в базовом классе имеют значения по умолчанию:

Этот код мгновенно упадёт с ошибкой TypeError, жалуясь на то, что «аргумент не по умолчанию «country» следует за аргументом по умолчанию». Проблема в том, что наше новое поле country не имеет значения по умолчанию, в то время как lon и lat имеют. Класс данных попытается прописать метод .__init__() в следующем виде:

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

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

То порядок полей в Capital будет все еще name, lon, lat, country. Однако значение по умолчанию lat будет 40.0.

Оптимизация классов данных

Я хочу закончить данное руководство парой слов о слотах. Слоты можно использовать, чтобы ускорить классы и использовать меньше памяти. Классы данных не имеют явного синтаксиса для работы со слотами, однако обычный способ создания слотов для классов данных работает. (Они просто являются обычными классами!).

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

Преимущество внесения таких ограничений в том, что определенные оптимизации могут быть выполнены. Например, класс слотов занимает меньше памяти, что можно измерить, используя Pympler:

Аналогичным образом, классы слотов обычно быстрее при работе. Следующий пример измеряет скорость доступа к атрибутам в классе данных слотов и обычного класса данных при помощи timeit из стандартной библиотеки:

В этом конкретном примере, класс слот примерно на 35% быстрее.

Выводы и несколько интересных статей

Классы данных – это одна из новых возможностей Python 3.7. С помощью классов данных, вам не нужно писать шаблонный код, для получения надлежащей инициализации, представления и сравнения ваших объектов.

Мы узнали, как определить собственный класс данных, а также:

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

Если вы хотите углубиться в вопрос работы с классами данных, взгляните на PEP 557 и на дискуссию в родном репозитории GitHub.

В дополнение, можете ознакомиться с речью Реймонда Геттингера на PyCon 2018 о классах данных.

Если у вас нет Python 3.7, существуют бекпорты классов данных для Python 3.6. Так что идите с миром и пишите поменьше кода!