В этом руководстве мы рассмотрим различные способы создания классов данных в python, начиная с самых старых и заканчивая новыми. Надеемся, что в конце вы убедитесь в необходимости использовать pydantic dataclasses как способ создания классов данных по умолчанию.
классы.
Создание классов в python по умолчанию
Самым старым способом определения класса в python является специальный метод __init__
. Для нашего примера мы будем работать с классом Point
, принимающим в качестве входных данных координаты x
и y
.
class Point:
def __init__(self, x: int, y: int):
self.x = x
self.y = y
p1 = Point(1, 2)
print(p1.x) # will print 1
print(p1) # will print something like <__main__.Point object at 0x7fc5d4283c90>
p2 = Point(1, 2)
print(p1 == p2) # will print False
Как вы можете видеть, здесь присутствует некоторый код, поскольку мы определяем одни и те же переменные как аргументы метода и атрибуты класса (x
и y
). Когда мы пытаемся распечатать созданный нами объект, чтобы посмотреть, как он выглядит, мы видим странное представление по умолчанию, сделанное python.
Хуже того, мы можем инстанцировать объект, подобный следующему Point(1, 'foo')
, и python вообще не будет жаловаться.
Можно сказать, что такие инструменты, как mypy или pyright, помогут вам поймать ошибку, но не все хотят их использовать, поэтому приходится искать другой способ.
Также в реализации класса по умолчанию не реализованы методы сравнения, поэтому p == p2
возвращает False
даже если атрибуты у двух объектов имеют одинаковые значения. 🥲
Вот как мы можем исправить эти три проблемы с помощью следующего кода.
class Point:
def __init__(self, x: int, y: int):
for item in [x, y]:
if not isinstance(item, int):
raise ValueError(f'{item} is not an integer')
self.x = x
self.y = y
def __repr__(self):
return f'Point(x={self.x}, y={self.y})'
def __eq__(self, other):
if other.__class__ is self.__class__:
return (self.x, self.y) == (other.x, other.y)
return NotImplemented
# we take the opportunity to write the opposite method
def __ne__(self, other):
result = self.__eq__(other)
if result is NotImplemented:
return NotImplemented
return not result
p1 = Point(1, 2)
print(p1) # will print Point(x=1, y=2)
p2 = Point(1, 2)
print(p1 == p2) # will print True
Point(1, 'foo') # will raise ValueError
Теперь у нас есть рабочий класс с красивым представлением, но посмотрите, сколько кода мы должны написать… А ведь мы даже не реализовали все методы сравнения.
Namedtuples
Я сделаю небольшое отступление по поводу
именованных кортежей, потому что кто-то может возразить, что это способ быстро объявить класс, и да, это так, но не без некоторых оговорок…
Рассмотрим следующий пример:
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])
p1 = Point(1, 2)
print(p1) # will print Point(x=1, y=2)
p2 = Point(1, 'foo') # we can still mess with variable type
print(p1 == (1, 2)) # will print True :(
Хорошо, с namedtuples мы имеем красивое представление по умолчанию, но мы страдаем от отсутствия проверки типов, как и в стандартном способе создания классов, и поскольку он наследует класс tuple
, сравнение с кортежем в последней инструкции будет корректным, что не всегда то, что мы хотим.
attrs
Принимая во внимание проблемы, связанные с созданием классов, о которых говорилось выше, известный питонист решил предложить решение с помощью библиотеки под названием attrs. Давайте посмотрим, как мы можем переписать наш класс Point
.
from attrs import define, field, validators
@define
class Point:
x: int = field(validator=[validators.instance_of(int)])
y: int = field(validator=[validators.instance_of(int)])
p1 = Point(1, 2)
print(p1) # will print Point(x=1, y=2)
p2 = Point(1, 2)
print(p1 == p2) # will print True
print(p1 == (1, 2)) # will print False
p = Point(1, 'foo') # will raise a TypeError
Итак, мы ясно видим разницу с классом, который мы написали от руки выше. У нас есть:
- Красивое представление по умолчанию
- Проверка типа с помощью функции поля и validators.
- Реализация сравнения по умолчанию, учитывающая тип сравниваемых объектов. Вот почему тест с atuple возвращает
False
.
Это хорошо продуманная библиотека, и ее можно настраивать различными способами, например, определять слоты, замороженные классы, аргументы только по ключевым словам и многое другое.
dataclasses
В python3.7 язык вводит dataclasses, определенные в PEP 557 с целью упростить написание классов. На самом деле, эта новая стандартная библиотека в значительной степени вдохновлена attrs
. Давайте посмотрим, как выглядит наш знаменитый класс Point
:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2) # will print True
print(p1 == (1, 2)) # will print False
p = Point(1, 'foo') # will not raise a TypeError :(
Мы имеем почти те же преимущества, что и определение attrs
, за исключением того, что тип аргумента не проверяется во время инициализации.
Единственный способ добиться такой проверки — сделать следующее:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
def __post_init__(self):
if not isinstance(self.x, int):
raise TypeError('x is not an integer')
if not isinstance(self.y, int):
raise TypeError('y is not an integer')
Point(1, 'foo') # will raise a TypeError
Да, это немного отстойно, но такова была воля сопровождающих CPython — иметь упрощенную версию attrs
без валидации и прочих радостей.
pydantic dataclasses
Наконец, мы поговорим о pydantic, библиотеке валидации данных, ставшей известной благодаря
относительно молодым веб-фреймворком FastAPI. Если вы не знакомы с ним, я настоятельно рекомендую ознакомиться с его api, это еще одна хорошо продуманная часть программного обеспечения. Я также написал статью в блоге, в которой описал некоторые из ее преимуществ.
Функция, которая интересует нас в этой статье, — это классы данных. Мы снова рассмотрим реализацию нашего класса Point
. 😁
from pydantic.dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p1 = Point(1, 2)
print(p1) # will print Point(x=1, y=2)
p2 = Point(1, 2)
print(p1 == p2) # will print True
print(p1 == (1, 2)) # will print False
p = Point(1, 'foo') # will raise a pydantic.ValidationError
У нас есть все преимущества, которые мы имели с реализацией attrs
, а написанный код стал еще меньше!
import dataclasses
import pydantic.dataclasses
@dataclasses.dataclass
class Point:
x: int
y: int
# no error raised
print(Point('foo', 2))
Point = pydantic.dataclasses.dataclass(Point)
# error raised
print(Point('foo', 2))
Здесь мы обернули обычный класс данных в pydantic, и у нас есть те же проверки и возможности!
Pydantic — фантастическая библиотека (да, я немного предвзят), и я могу только порекомендовать вам ознакомиться с ее документацией.
Вот и все по этому руководству, надеюсь, оно вам понравилось. Берегите себя и до встречи в следующий раз! 😁
Если вам понравилась моя статья и вы хотите продолжить обучение вместе со мной, не стесняйтесь следовать за мной здесь и подписывайтесь на мою рассылку 😉