Еще одно впечатляющее обновление в Python 3.7 – это класс данных.
Содержание
- Альтернативы классам данных
- Основы класс данных
- Значения по умолчанию
- Типизирование
- Добавление методов
- Более гибкие классы данных
- Расширенные значения по умолчанию
- Нужно представление?
- Сравнение карт
- Неизменяемые классы данных
- Наследование
- Оптимизация классов данных
- Вывод и дальнейшее чтиво
Класс данных – это класс, который (в основном) хранит данные, хотя на самом деле, нет никаких ограничений. Он разработан при помощи декоратора @dataclass следующим образом:
1 2 3 4 5 6 |
from dataclasses import dataclass @dataclass class DataClassCard: rank: str suit: str |
Обратите внимание: этот код, как и другие примеры в данном руководстве, будет работать только в Python 3.7 и выше.
Есть вопросы по Python?
На нашем форуме вы можете задать любой вопрос и получить ответ от всего нашего сообщества!
Паблик VK
Одно из самых больших сообществ по Python в социальной сети ВК. Видео уроки и книги для вас!
Класс данных имеет уже реализованный базовый функционал. Например, вы можете создать экземпляр, выводить и сравнивать существующие классы данных, сразу без лишнего кода:
1 2 3 4 5 6 7 8 9 |
>>> queen_of_hearts = DataClassCard('Q', 'Hearts') >>> queen_of_hearts.rank 'Q' >>> queen_of_hearts DataClassCard(rank='Q', suit='Hearts') >>> queen_of_hearts == DataClassCard('Q', 'Hearts') True |
Сравним с обычным классом в Python. Минимальный код обычного класса может выглядеть примерно вот так:
1 2 3 4 |
class RegularCard def __init__(self, rank, suit): self.rank = rank self.suit = suit |
Так как здесь не нужно писать много кода, вы уже можете видеть признаки страданий в шаблоне: rank и suit повторяются три раза, чтобы просто инициализировать объект. Кроме этого, если вы попробуете использовать этот простой класс, вы заметите, что представление объектов не очень подробное, и по ряду причин, тот же объект queen of hearts (королева черв) не будет тем же, что и queen of hearts:
1 2 3 4 5 6 7 8 9 |
>>> queen_of_hearts = RegularCard('Q', 'Hearts') >>> queen_of_hearts.rank 'Q' >>> queen_of_hearts <__main__.RegularCard object at 0x7fb6eee35d30> >>> queen_of_hearts == RegularCard('Q', 'Hearts') False |
Похоже, что классы данных помогают нам за кулисами. ПО умолчанию, классы данных реализует метод .__repr__(), чтобы предоставить хорошее строковое представление, а также метод .__eq__(), который в состоянии выполнять базовые сравнения объектов.
Чтобы класс RegularCard имитировал указанный выше класс данных, вам нужно (в том числе) добавить следующие методы:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class RegularCard def __init__(self, rank, suit): self.rank = rank self.suit = suit def __repr__(self): return (f'{self.__class__.__name__}' f'(rank={self.rank!r}, suit={self.suit!r})') def __eq__(self, other): if other.__class__ is not self.__class__: return NotImplemented return (self.rank, self.suit) == (other.rank, other.suit) |
В этом руководстве, вы узнаете, какие именно удобства предоставляются классами данных в Python. В дополнение к хорошим представлениям и сравнениям, вы узнаете:
- Как добавлять значения по умолчанию к полям классов данных;
- Как классы данных позволяют упорядочивать объекты;
- Как представлять неизменяемые данные;
- Как классы данных обрабатывают наследование.
Скоро мы углубимся в эти особенности классов данных. Однако, вы можете подумать, что уже сталкивались с подобным прежде.
Альтернативы классам данных
Скорее всего, вы уже пользовались кортежами или словарями для простых структур данных. Вы можете представить карту «королевы черв» одним из следующих способов:
1 2 |
queen_of_hearts_tuple = ('Q', 'Hearts') queen_of_hearts_dict = {'rank': 'Q', 'suit': 'Hearts'} |
Это работает. Однако, это ставит перед вами большую ответственность, как перед программистом:
- Вам нужно помнить, что переменные queen_of_hearts_… представляет собой карту.
- Для версии _tuple, вам нужно помнить порядок атрибутов. Вписав (‘Пики’, ‘A’) вы наведете беспорядок в вашей программе, при этом, вы не получите внятное уведомление об ошибке.
- Если вы используете _dict, вы должны убедиться в том, что имена атрибутов согласованы. Например, {‘value’: ‘A’, ‘suit’: ‘Пики’} не будет работать так, как ожидается.
Кроме этого, использование этих структур не идеально:
1 2 3 4 |
>>> queen_of_hearts_tuple[0] # No named access 'Q' >>> queen_of_hearts_dict['suit'] # Would be nicer with .suit 'Hearts' |
Лучшая альтернатива – это namedtuple. Он уже давно используется для создания небольших читаемых структур данных. Фактически, мы можем пересоздать пример класса данных, показанного выше, при помощи namedtuple вот так:
1 2 3 |
from collections import namedtuple NamedTupleCard = namedtuple('NamedTupleCard', ['rank', 'suit']) |
Это определение NamedTupleCard даст тот же результат, что и наш пример с DataClassCard:
1 2 3 4 5 6 7 8 9 |
>>> queen_of_hearts = NamedTupleCard('Q', 'Hearts') >>> queen_of_hearts.rank 'Q' >>> queen_of_hearts NamedTupleCard(rank='Q', suit='Hearts') >>> queen_of_hearts == NamedTupleCard('Q', 'Hearts') True |
Зачем беспокоиться из-за класса данных?
Прежде всего, классы данных включают намного больше возможностей, чем вы могли видеть. В то же время, namedtuple также имеет различные функции, которые далеко не всегда желаемые. По дизайну, namedtuple – это регулярный кортеж. Это можно увидеть в сравнениях, например:
1 2 |
>>> queen_of_hearts == ('Q', 'Hearts') True |
Пока это выглядит хорошо, однако недостаточная осведомленность о собственном типе может привести к труднонаходимым багам, тем более, что он спокойно может сравнить два разных класса namedtuple:
1 2 3 4 |
>>> Person = namedtuple('Person', ['first_initial', 'last_name'] >>> ace_of_spades = NamedTupleCard('A', 'Spades') >>> ace_of_spades == Person('A', 'Spades') True |
namedtuple также содержит некоторые ограничения. Например, трудно добавить значения по умолчанию в некоторых полях в namedtuple. Кроме этого, он по природе неизменяемый. Это значит, что значение namedtuple не может меняться. В некоторых приложениях – это отлично, но в других настройках неплохо было бы иметь чуть больше гибкости:
1 2 3 4 |
>>> card = NamedTupleCard('7', 'Diamonds') >>> card.rank = '9' Ошибка AttributeError: can't set attribute |
Классы данных не заменят все применения namedtuple. Например, если вы хотите, что бы ваша структура данных вела себя как кортеж, то namedtuple – отличная альтернатива.
Еще одна альтернатива (в т.ч. и одно из вдохновений для классов данных) – то проект attrs. С установленным attrs (pip install attrs), вы можете прописать карту следующим образом:
1 2 3 4 5 6 |
import attr @attr.s class AttrsCard: rank = attr.ib() suit = attr.ib() |
Это может быть использовано абсолютно таким же образом, как в примерах с DataClassCard и NamedTupleCard, указанных ранее. Проект attrs просто замечательный и поддерживает некоторые возможности, которые не поддерживают классы данных, включая конвертеры и валидаторы. Кроме этого, attrs уже доступен какое-то время и поддерживается Python 2.7, как и Python 3.4 и выше.
Однако, attrs не является частью стандартной библиотеки и добавляет таким образом дополнительную зависимость в ваши проекты. С помощью классов данных, аналогичная функциональность будет доступна в любом случае.
В дополнение к картежу, словарям, namedtuple и attrs, существует множество других аналогичных проектов, включая typing.NamedTuple, namedlist, attrdict, plumber и fields. Хотя классы данных – отличная новая альтернатива, всё перечисленное все еще используются в случаях, когда старые варианты подходят лучше.
К примеру, если вам нужна совместимость с определенным API, ожидающим кортежи или нуждающимся в функционале, не поддерживаемым классами данных.
Основы класс данных
Давайте вернемся к классам данных. Для примера, мы создадим класс Position, который будет отображать географическое расположение с названием, а также показатели долготы и широты:
1 2 3 4 5 6 7 |
from dataclasses import dataclass @dataclass class Position: name: str lon: float lat: float |
Декоратор @dataclass делает класс – классом данных, прямо над определением класса. Под строкой (№4) class Position:, вы просто афишируете список полей, которые вы хотите в своем классе данных.
Нотация «:», которая используется для полей, использует новую функцию в Python 3.6, под названием типизация переменных. Мы в скором времени поговорим об типизации, и о том, почему мы определяемы типы данных, таких как str и float.
Эти несколько строк кода – это все, что вам нужно. Новый класс готов к использованию:
1 2 3 4 5 6 7 8 9 |
>>> pos = Position('Oslo', 10.8, 59.9) >>> print(pos) Position(name='Oslo', lon=10.8, lat=59.9) >>> pos.lat 59.9 >>> print(f'{pos.name} is at {pos.lat}°N, {pos.lon}°E') Oslo is at 59.9°N, 10.8°E |
Вы также можете создать классы данных аналогично тому, как создаются названные кортежи. Следующее является практическим эквивалентом определения Position, описанном ранее:
1 2 3 |
from dataclasses import make_dataclass Position = make_dataclass('Position', ['name', 'lat', 'lon']) |
Класс данных – это обычный класс Python. Единственное, что его отличает, это то, он содержит базовые методы модели данных, такие как .__init__(), .__repr__(), and .__eq__(), которые выкатили именно для вас.
Значения по умолчанию
Добавить значения по умолчанию в поля вашего класса данных – это очень просто:
1 2 3 4 5 6 7 |
from dataclasses import dataclass @dataclass class Position: name: str lon: float = 0.0 lat: float = 0.0 |
Это работает именно так, как когда вы определяли значения по умолчанию в определении метода .__init__() обычного класса:
1 2 3 4 5 6 7 8 |
>>> Position('Null Island') Position(name='Null Island', lon=0.0, lat=0.0) >>> Position('Greenwich', lat=51.8) Position(name='Greenwich', lon=0.0, lat=51.8) >>> Position('Vancouver', -123.1, 49.3) Position(name='Vancouver', lon=-123.1, lat=49.3) |
Далее мы узнаем о default_factory, который дает возможность предоставлять более сложные значения по умолчанию.
Типизирование
До сих пор мы не столкнулись с тем, что классы данных изначально поддерживают контроль типа. Вы могли заметить, что мы определили тип атрибутов класса вот так: name: str говорит, что атрибут «name» должны быть строкой (тип str).
Фактически, добавление какого-либо типа является обязательным при определении полей в вашем классе данных. Без типизации, поле не будет частью класса данных. Однако, если вы не хотите добавлять лишние типы в ваш класс данных, используйте typing.Any:
1 2 3 4 5 6 7 |
from dataclasses import dataclass from typing import Any @dataclass class WithoutExplicitTypes: name: Any value: Any = 42 |
Хотя вам нужно добавить тип при вводе в какой-нибудь форме при использовании классов данных, эти типы не запускаются во время выполнения. Следующий код запускается без каких-либо проблем:
1 2 |
>>> Position(3.14, 'pi day', 2018) Position(name=3.14, lon='pi day', lat=2018) |
Вот как типизация работает в Python: он является и всегда будет динамически типизированным языком.
Чтобы поймать возникшие ошибки, чекер типов, такой как Mypy может быть запущен в вашем исходном коде.
Добавление методов
Вы уже знаете, что класс данных – это просто обычный класс. Это значит, что вы можете свободно вносить собственные методы в класс данных.
Например, давайте подсчитаем расстояние между двумя точками на поверхности земли используя модуль math. Один из способов сделать это — воспользоваться формулой Haversine.
Вы можете внести метод .distance_to() в ваш класс дынных так же, как и с обычными классами:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
from dataclasses import dataclass from math import asin, cos, radians, sin, sqrt @dataclass class Position: name: str lon: float = 0.0 lat: float = 0.0 def distance_to(self, other): r = 6371 # Earth radius in kilometers lam_1, lam_2 = radians(self.lon), radians(other.lon) phi_1, phi_2 = radians(self.lat), radians(other.lat) h = (sin((phi_2 - phi_1) / 2)**2 + cos(phi_1) * cos(phi_2) * sin((lam_2 - lam_1) / 2)**2) return 2 * r * asin(sqrt(h)) |
Это работает так же, как вы могли ожидать:
1 2 3 4 |
>>> oslo = Position('Oslo', 10.8, 59.9) >>> vancouver = Position('Vancouver', -123.1, 49.3) >>> oslo.distance_to(vancouver) 7181.7841229421165 |
Более гибкие классы данных
До этого вы видели некоторые основные возможности класса данных: он предоставляет более удобные методы, при этом вы все еще можете вносить значения по умолчанию и другие методы.
Теперь вы узнаете о более продвинутых возможностях, такие как параметры декоратора @dataclass и функция field(). Вместе, они могут дать вам больше контроля над ситуацией при создании класса данных.
Давайте вернемся к примеру с игральными картами, который мы видели в начале руководства и внесем класс, содержащий колоду карт, пока мы находимся здесь:
1 2 3 4 5 6 7 8 9 10 11 |
from dataclasses import dataclass from typing import List @dataclass class PlayingCard: rank: str suit: str @dataclass class Deck: cards: List[PlayingCard] |
Простая колода , содержащая только две карты, может быть создана следующим образом:
1 2 3 4 5 6 |
queen_of_hearts = PlayingCard('Q', 'Hearts') # Королева червей ace_of_spades = PlayingCard('A', 'Spades') # Пиковый Туз two_cards = Deck([queen_of_hearts, ace_of_spades]) Deck(cards=[PlayingCard(rank='Q', suit='Hearts'), PlayingCard(rank='A', suit='Spades')]) |
Продвинутые значения по умолчанию
Представим, что вы хотите дать значение по умолчанию колоде. Это может быть удобно, если Deck() создало регулярную (французскую) колоду из 52 игральных карт. Сначала, определим масти и значения. Далее, внесем функцию make_french_deck(), которая создает список экземпляров PlayingCard:
1 2 3 4 5 |
RANKS = '2 3 4 5 6 7 8 9 10 J Q K A'.split() SUITS = '♣ ♢ ♡ ♠'.split() def make_french_deck(): return [PlayingCard(r, s) for s in SUITS for r in RANKS] |
Ради забавы, четыре разные масти определены используя юникод символы.
Обратите внимание: выше мы использовали символы, такие как ♠ прямо в исходном коде. Мы можем сделать это, так как Python поддерживает написание исходного кода в UTF-8 по умолчанию. Обратитесь к этой странице во вкладке Unicode, чтобы узнать, как ввести это на своей системе. Вы можете также ввести символы юникода для мастей при помощи \N (как \N{BLACK SPADE SUIT}) или \u (как \u2660).
Чтобы упростить сравнение карт в дальнейшем, масти и значения также отсортированы в обычном порядке.
1 2 3 |
>>> make_french_deck() [PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), ... PlayingCard(rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')] |
В теории, вы теперь можете использовать эту функцию для определения значения по умолчанию для Deck.cards:
1 2 3 4 5 6 |
from dataclasses import dataclass from typing import List @dataclass class Deck: # НЕ БУДЕТ РАБОТАТЬ cards: List[PlayingCard] = make_french_deck() |
Не нужно этого делать! Это подводит к одному из самых распространенных анти-шаблонов Python: использование изменяемых аргументов по умолчанию.
Проблема в том, что все экземпляры колоды будут использовать тот же объект списка как значение по умолчанию для .cards. Это значит, что, скажем, когда одна карда убирается из колоды, то она исчезает из всех остальных экземпляров колоды в том числе.
Вообще, классы данных попытаются остановить вас от этого решения, так что код выше приведет к ошибке ValueError.
Вместо этого, классы данных используют кое-что под названием default_factory для обработки изменяемых значений по умолчанию. Чтобы использовать default_factory (и многие другие крутые возможности классов данных), вам нужно использовать определитель field():
1 2 3 4 5 6 |
from dataclasses import dataclass, field from typing import List @dataclass class Deck: cards: List[PlayingCard] = field(default_factory=make_french_deck) |
Аргументом для default_factory может быть любой вызываемый нулевой параметр. Теперь легко создать цельную колоду игральных карт:
1 2 3 |
>>> Deck() Deck(cards=[PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), ... PlayingCard(rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')]) |
Определитель field() используется для кастомизации каждого поля или класса данных индивидуально. Вы увидите несколько примеров ниже. Для примера, рассмотрим поддерживаемые field() параметры:
- default: Значение поля по умолчанию;
- default_factory: Функция, которая возвращает начальное значение поля;
- init: использует поле в методе .__init__() (True по умолчанию);
- repr: Использует поле repr объекта (True по умолчанию);
- compare: Включает поле в сравнениях (True по умолчанию);
- hash: Включает поле при подсчете hash() (По умолчанию используется то же, что и при сравнении);
- metadata: Сопоставление с информацией о поле
В примере с Position вы видели, как добавлять некоторые значение по умолчанию, прописав lat: float = 0.0. Однако, если вы также хотите кастомизировать поле, например, чтобы спрятать его в repr, вам нужно использовать параметр по умолчанию:
1 |
lat: float = field(default=0.0, repr=False) |
Вы можете не определять и default, и default_factory.
Параметр metadata не используется лично классами данных, но доступен для вас (как и сторонние пакеты) для внесения информации в поля. В примере с Position, вы можете, например, определять, что долгота и широта должны быть в градусах:
1 2 3 4 5 6 7 |
from dataclasses import dataclass, field @dataclass class Position: name: str lon: float = field(default=0.0, metadata={'unit': 'degrees'}) lat: float = field(default=0.0, metadata={'unit': 'degrees'}) |
metadata (и другая информация о поле) может быть получена с помощью функции fields() (обратите внимание на множественное число, заканчивается с буквой «s»):
Вам нужно представление?
Напомним, что мы можем создавать колоды карт из воздуха:
1 2 3 |
>>> Deck() Deck(cards=[PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), ... PlayingCard(rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')]) |
Хотя это представление колоды явное и читаемое, оно также и очень многословное. Я удалил 48 из 52 в колоде в выдаче выше. На 80-и колоночном дисплее, выдача целой колоды занимает 22 строки! Давайте внесем боле сжатое представление. В целом, объект Python имеет две разные строки представления:
- repr(obj) определен obj.__repr__() и должен возвращать приемлемое для разработчика представление об объекте. При возможности, это должен быть код, который воссоздает объект. Классы данных могут это;
- str(obj) определен obj.__str__() и может возвращать приемлемое для разработчика представление об объекте. Классы данных не реализуют метод .__str__(), так что Python вернется назад к методу .__repr__().
Давайте реализуем юзер-френдли представление PlayingCard:
1 2 3 4 5 6 7 8 9 |
from dataclasses import dataclass @dataclass class PlayingCard: rank: str suit: str def __str__(self): return f'{self.suit}{self.rank}' |
Карты теперь выглядят намного приятнее, но колода все еще слишком многословная:
1 2 3 4 5 6 7 8 |
>>> ace_of_spades = PlayingCard('A', '♠') >>> ace_of_spades PlayingCard(rank='A', suit='♠') >>> print(ace_of_spades) ♠A >>> print(Deck()) Deck(cards=[PlayingCard(rank='2', suit='♣'), PlayingCard(rank='3', suit='♣'), ... PlayingCard(rank='K', suit='♠'), PlayingCard(rank='A', suit='♠')]) |
Чтобы показать, что вы можете внести собственный метод .__repr__() в том числе, мы нарушим принцип, согласно которому, метод должен возвращать код, который воссоздает объект. Практичность иногда должна превосходить чистоту. Следующий код вносит боле сжатое представление колоды:
1 2 3 4 5 6 7 8 9 10 |
from dataclasses import dataclass, field from typing import List @dataclass class Deck: cards: List[PlayingCard] = field(default_factory=make_french_deck) def __repr__(self): cards = ', '.join(f'{c!s}' for c in self.cards) return f'{self.__class__.__name__}({cards})' |
Обратите внимание на определитель !s в строке формата. Это значит, что мы явно хотим использовать представление str() наших игральных карт. С новым .__repr__(), представление колоды стало выглядеть читабельнее и проще для глаз:
1 2 3 4 5 |
>>> Deck() Deck(♣2, ♣3, ♣4, ♣5, ♣6, ♣7, ♣8, ♣9, ♣10, ♣J, ♣Q, ♣K, ♣A, ♢2, ♢3, ♢4, ♢5, ♢6, ♢7, ♢8, ♢9, ♢10, ♢J, ♢Q, ♢K, ♢A, ♡2, ♡3, ♡4, ♡5, ♡6, ♡7, ♡8, ♡9, ♡10, ♡J, ♡Q, ♡K, ♡A, ♠2, ♠3, ♠4, ♠5, ♠6, ♠7, ♠8, ♠9, ♠10, ♠J, ♠Q, ♠K, ♠A) |
Сравнение карт
Во многих карточных играх, карты сравниваются друг с другом. К примеру, при игре в пьяницу, выигрывает игрок, номинал карты которого выше. Как это было реализовано сейчас, то класс PlayingCard не поддерживает такой тип сравнения:
1 2 3 4 |
>>> queen_of_hearts = PlayingCard('Q', '♡') >>> ace_of_spades = PlayingCard('A', '♠') >>> ace_of_spades > queen_of_hearts TypeError: '>' not supported between instances of 'Card' and 'Card' |
Однако это легко (на первый взгляд) исправить:
1 2 3 4 5 6 7 8 9 |
from dataclasses import dataclass @dataclass(order=True) class PlayingCard: rank: str suit: str def __str__(self): return f'{self.suit}{self.rank}' |
Декоратор @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 могут быть сравнены:
1 2 3 4 |
>>> queen_of_hearts = PlayingCard('Q', '♡') >>> ace_of_spades = PlayingCard('A', '♠') >>> ace_of_spades > queen_of_hearts False |
Хотя как сравниваются две карты? Вы не определяли, каким должен быть порядок, и по какой-то причине, Python видимо верит, что королева выше, чем туз…
Выясняется, что классы данных сравнивают объекты, как если бы они были кортежами на своих полях. Другими словами, королева выше туза, так как “Q” (Queen) выше “A” (Ace), так как Q идет после А в алфавите.
1 2 |
>>> ('A', '♠') > ('Q', '♡') False |
Это не совсем что мы хотим. Вместо этого, нам нужно определить определенный тип индекса сортировки, который использует порядок номиналов (RANKS) и мастей (SUITS).
Например, как-то так:
1 2 3 4 5 |
>>> RANKS = '2 3 4 5 6 7 8 9 10 J Q K A'.split() >>> SUITS = '♣ ♢ ♡ ♠'.split() >>> card = PlayingCard('Q', '♡') >>> RANKS.index(card.rank) * len(SUITS) + SUITS.index(card.suit) 42 |
Для PlayingCard, чтобы использовать этот индекс сортировки для сравнений, нам нужно внести поле .sort_index в класс. Однако, это поле должно быть подсчитано другими полями .rank и .suit автоматически. Именно здесь нам понадобится метод .__post_init__(). Он позволяет проводить специальные подсчеты после вызова регулярного метода .__init__():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
from dataclasses import dataclass, field RANKS = '2 3 4 5 6 7 8 9 10 J Q K A'.split() SUITS = '♣ ♢ ♡ ♠'.split() @dataclass(order=True) class PlayingCard: sort_index: int = field(init=False, repr=False) rank: str suit: str def __post_init__(self): self.sort_index = (RANKS.index(self.rank) * len(SUITS) + SUITS.index(self.suit)) def __str__(self): return f'{self.suit}{self.rank}' |
Обратите внимание, что .sort_index внесен как первое поле класса. Таким образом, сравнение сначала проводится при помощи sort_index, при наличии связей, используемых другими полями. Используя field(), вы также должны определить, что .sort_index не должен быть включен как параметр в методе .__init__() (так как он подсчитан в полях .rank and .suit). Чтобы не запутать пользователя этими деталями реализации, будет неплохой идеей убрать .sort_index из repr класса.
Наконец, туз стал выше:
1 2 3 4 |
>>> queen_of_hearts = PlayingCard('Q', '♡') >>> ace_of_spades = PlayingCard('A', '♠') >>> ace_of_spades > queen_of_hearts True |
Создаем отсортированную колоду карт:
1 2 3 4 5 |
>>> Deck(sorted(make_french_deck())) Deck(♣2, ♢2, ♡2, ♠2, ♣3, ♢3, ♡3, ♠3, ♣4, ♢4, ♡4, ♠4, ♣5, ♢5, ♡5, ♠5, ♣6, ♢6, ♡6, ♠6, ♣7, ♢7, ♡7, ♠7, ♣8, ♢8, ♡8, ♠8, ♣9, ♢9, ♡9, ♠9, ♣10, ♢10, ♡10, ♠10, ♣J, ♢J, ♡J, ♠J, ♣Q, ♢Q, ♡Q, ♠Q, ♣K, ♢K, ♡K, ♠K, ♣A, ♢A, ♡A, ♠A) |
Или, если вам не важен порядок, вот так мы можем получить руку из 10 случайных карт:
1 2 3 |
>>> from random import sample >>> Deck(sample(make_french_deck(), k=10)) Deck(♢2, ♡A, ♢10, ♣2, ♢3, ♠3, ♢A, ♠8, ♠9, ♠2) |
Неизменяемые классы данных
Одно из определяющих свойств namedtuple, которые вы видели ранее – это то, что он неизменяем. Именно так, значение его полей никогда не может меняться. Для множества типов классов данных – это отлично! Чтобы сделать класс данных неизменяемым, установите frozen=True при создании.
Например, следующее – это неизменяемая версия класса Position, который вы видели ранее:
1 2 3 4 5 6 7 |
from dataclasses import dataclass @dataclass(frozen=True) class Position: name: str lon: float = 0.0 lat: float = 0.0 |
В замороженном классе данных вы не можете менять значения для полей после их создания:
1 2 3 4 5 6 |
>>> pos = Position('Oslo', 10.8, 59.9) >>> pos.name 'Oslo' >>> pos.name = 'Stockholm' dataclasses.FrozenInstanceError: cannot assign to field 'name' |
Имейте ввиду, что если ваш класс данных содержит изменяемые поля, они все еще могут изменяться. Это относится ко всем вложенным структурам данных в Python:
1 2 3 4 5 6 7 8 9 10 11 |
from dataclasses import dataclass from typing import List @dataclass(frozen=True) class ImmutableCard: rank: str suit: str @dataclass(frozen=True) class ImmutableDeck: cards: List[PlayingCard] |
Хотя и ImmutableCard, и ImmutableDeck неизменяемые, список, содержащий карты таковым не является. Хотя вы все еще можете менять карты в колоде:
1 2 3 4 5 6 7 8 9 |
>>> queen_of_hearts = ImmutableCard('Q', '♡') >>> ace_of_spades = ImmutableCard('A', '♠') >>> deck = ImmutableDeck([queen_of_hearts, ace_of_spades]) >>> deck ImmutableDeck(cards=[ImmutableCard(rank='Q', suit='♡'), ImmutableCard(rank='A', suit='♠')]) >>> deck.cards[0] = ImmutableCard('7', '♢') >>> deck ImmutableDeck(cards=[ImmutableCard(rank='7', suit='♢'), ImmutableCard(rank='A', suit='♠')]) |
Чтобы избежать этого, убедитесь, что все поля неизменяемого класса данных используют неизменяемые типы (но помните, что эти типы не выполняются при запуске). ImmutableDeck должен быть реализован при помощи кортежа, а не списка.
Наследование
Вы можете наследовать класс данных, не прилагая особых усилий. К примеру, мы расширим наш пример Position полем country и используем его для записи городов-столиц:
1 2 3 4 5 6 7 8 9 10 11 |
from dataclasses import dataclass @dataclass class Position: name: str lon: float lat: float @dataclass class Capital(Position): country: str |
В этом простом примере все работает без заминок:
1 2 |
>>> Capital('Oslo', 10.8, 59.9, 'Norway') Capital(name='Oslo', lon=10.8, lat=59.9, country='Norway') |
Поле country нашего Capital внесено после трех изначальных полей в Position. Здесь всё будет немного сложнее, если поля в базовом классе имеют значения по умолчанию:
1 2 3 4 5 6 7 8 9 10 11 |
from dataclasses import dataclass @dataclass class Position: name: str lon: float = 0.0 lat: float = 0.0 @dataclass class Capital(Position): country: str # НЕ БУДЕТ РАБОТАТЬ |
Этот код мгновенно упадёт с ошибкой TypeError, жалуясь на то, что «аргумент не по умолчанию «country» следует за аргументом по умолчанию». Проблема в том, что наше новое поле country не имеет значения по умолчанию, в то время как lon и lat имеют. Класс данных попытается прописать метод .__init__() в следующем виде:
1 2 |
def __init__(name: str, lon: float = 0.0, lat: float = 0.0, country: str): ... |
Однако это не работает в Python. Если параметр имеет значение по умолчанию, все следующие параметры должны также иметь значение по умолчанию. Иными словами, если поле в базовом классе имеет значение по умолчанию, то все новые внесённые поля которые были унаследованы, должны также иметь значения по умолчанию.
Еще один момент, на который стоит обратить внимание, это то, в каком порядке расположены поля в подклассе. Начиная с базового класса, поля упорядочены в том порядке, в котором они изначально были определены. Если поле переопределено в подкласс, его порядок не меняется. Например, если вы определите Position и Capital следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 |
from dataclasses import dataclass @dataclass class Position: name: str lon: float = 0.0 lat: float = 0.0 @dataclass class Capital(Position): country: str = 'Unknown' lat: float = 40.0 |
То порядок полей в Capital будет все еще name, lon, lat, country. Однако значение по умолчанию lat будет 40.0.
1 2 |
>>> Capital('Madrid', country='Spain') Capital(name='Madrid', lon=0.0, lat=40.0, country='Spain') |
Оптимизация классов данных
Я хочу закончить данное руководство парой слов о слотах. Слоты можно использовать, чтобы ускорить классы и использовать меньше памяти. Классы данных не имеют явного синтаксиса для работы со слотами, однако обычный способ создания слотов для классов данных работает. (Они просто являются обычными классами!).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
from dataclasses import dataclass @dataclass class SimplePosition: name: str lon: float lat: float @dataclass class SlotPosition: __slots__ = ['name', 'lon', 'lat'] name: str lon: float lat: float |
По сути своей, слоты определяются при помощи .__slots__, чтобы внести список переменных в класс. Переменные или атрибуты не присутствуют в .__slots__ могут не определиться. Более того, класс слотов может не иметь значений по умолчанию.
Преимущество внесения таких ограничений в том, что определенные оптимизации могут быть выполнены. Например, класс слотов занимает меньше памяти, что можно измерить, используя Pympler:
1 2 3 4 5 |
>>> from pympler import asizeof >>> simple = SimplePosition('London', -0.1, 51.5) >>> slot = SlotPosition('Madrid', -3.7, 40.4) >>> asizeof.asizesof(simple, slot) (440, 248) |
Аналогичным образом, классы слотов обычно быстрее при работе. Следующий пример измеряет скорость доступа к атрибутам в классе данных слотов и обычного класса данных при помощи timeit из стандартной библиотеки:
1 2 3 4 5 |
>>> from timeit import timeit >>> timeit('slot.name', setup="from position import SlotPosition; slot=SlotPosition('Oslo', 10.8, 59.9)") 0.05882283499886398 >>> timeit('simple.name', setup="from position import SimplePosition; simple=SimplePosition('Oslo', 10.8, 59.9)") 0.09207444800267695 |
В этом конкретном примере, класс слот примерно на 35% быстрее.
Выводы и несколько интересных статей
Классы данных – это одна из новых возможностей Python 3.7. С помощью классов данных, вам не нужно писать шаблонный код, для получения надлежащей инициализации, представления и сравнения ваших объектов.
Мы узнали, как определить собственный класс данных, а также:
- Как внести значения по умолчанию в поля в вашем классе данных;
- Как настраивать порядок объектов классов данных;
- Как работать с неизменяемыми классами данных;
- Как наследование работает для классов данных.
Если вы хотите углубиться в вопрос работы с классами данных, взгляните на PEP 557 и на дискуссию в родном репозитории GitHub.
В дополнение, можете ознакомиться с речью Реймонда Геттингера на PyCon 2018 о классах данных.
Если у вас нет Python 3.7, существуют бекпорты классов данных для Python 3.6. Так что идите с миром и пишите поменьше кода!
Являюсь администратором нескольких порталов по обучению языков программирования Python, Golang и Kotlin. В составе небольшой команды единомышленников, мы занимаемся популяризацией языков программирования на русскоязычную аудиторию. Большая часть статей была адаптирована нами на русский язык и распространяется бесплатно.
E-mail: vasile.buldumac@ati.utm.md
Образование
Universitatea Tehnică a Moldovei (utm.md)
- 2014 — 2018 Технический Университет Молдовы, ИТ-Инженер. Тема дипломной работы «Автоматизация покупки и продажи криптовалюты используя технический анализ»
- 2018 — 2020 Технический Университет Молдовы, Магистр, Магистерская диссертация «Идентификация человека в киберпространстве по фотографии лица»