Объекты значения (Value Object) в программировании на Python
Объекты значения в Python
Основное решение: dataclass с frozen=True
Самый современный и удобный способ создания value object в Python - использование декоратора @dataclass с параметром frozen=True. Такой класс автоматически получает методы __init__, __repr__, __eq__ и __hash__ (благодаря frozen). Экземпляры становятся неизменяемыми, что соответствует концепции объекта значения.
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1 == p2) # True
print(hash(p1)) # некоторое числообъект значения в python (объект значения в python)
Основные шаги: аннотации типов, декоратор, frozen. Проблемы возникают, если атрибут содержит изменяемый тип (например, список). В таком случае @dataclass не создаёт __hash__, и объект нельзя использовать в множествах или как ключ словаря. Решение - вручную определить __hash__ или использовать неизменяемые типы (tuple, frozenset).
Типичная ошибка:
Попытка изменить атрибут frozen-объекта вызывает FrozenInstanceError. Это ожидаемо, но иногда нужно изменить значение в __post_init__ - для этого используется object.__setattr__.
Вариант 1: namedtuple - минимальный код без аннотаций
Вопрос: «Как создать неизменяемый объект с минимальным объёмом кода?»
Использование collections.namedtuple позволяет быстро определить класс с кортежем полей.
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])
p = Point(1, 2)
print(p.x, p.y) # 1 2
print(hash(p)) # работаетЦель: простая структура данных, сериализуемая в кортеж. Случаи использования: замена кортежам с именованными полями, когда не нужна валидация.
Проблема:
Нет аннотаций типов (до Python 3.6), нельзя добавлять методы без обёртки, сложно расширять. Для добавления методов приходится создавать подкласс, что лишает некоторых преимуществ.
Вариант 2: обычный класс с __slots__ и полным определением методов
Вопрос: «Как реализовать value object без использования внешних библиотек или декораторов?»
class Point:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x!r}, {self.y!r})"
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2) # TrueЦель: полный контроль над реализацией, минимум зависимостей. Случаи использования: когда нужно тонкое управление поведением или обратная совместимость.
Проблема:
Много шаблонного кода; легко забыть определить __hash__ или допустить ошибку в __eq__. Необходимость явно указывать __slots__ для экономии памяти.
Вариант 3: библиотека attrs - расширенная валидация и конвертация
Вопрос: «Как получить продвинутую валидацию и автогенерацию методов без написания boilerplate?»
from attr import define, frozen, validators
@define(frozen=True, auto_attribs=True)
class Point:
x: float = field(validator=validators.instance_of(float))
y: float
p = Point(1.0, 2.0)
print(p)
# Point(x=1.0, y=2.0)Цель: удобство dataclass + дополнительные возможности (валидаторы, конвертеры, слоты). Случаи использования: сложные доменные объекты с проверкой инвариантов.
Проблема:
Внешняя зависимость (attrs). Необходимость изучения дополнительного API. При некорректном использовании валидаторов возможны неочевидные ошибки.
Вариант 4: pydantic - строгая типизация и сериализация
Вопрос: «Как обеспечить автоматическую валидацию данных из внешних источников (JSON, API)?»
from pydantic import BaseModel
class Point(BaseModel, frozen=True):
x: float
y: float
p = Point(x="3.14", y=2.0) # x будет приведено к float
print(p)
# Point(x=3.14, y=2.0)Цель: строгая валидация, автоматическая сериализация/десериализация, генерация JSON Schema. Случаи использования: интеграция с веб-фреймворками, конфигурационные объекты.
Проблема:
Значительный размер библиотеки, более медленная инициализация по сравнению с dataclass. Не все функции могут быть нужны для простых value object.
Дополнительные примеры
Создание value object для денег с операциями
Используем dataclass с frozen=True и добавляем арифметические операции.
from dataclasses import dataclass
from decimal import Decimal
from typing import Union
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self):
# Преобразование числа в Decimal, если передано не Decimal
if not isinstance(self.amount, Decimal):
object.__setattr__(self, 'amount', Decimal(str(self.amount)))
if self.currency not in ('USD', 'EUR', 'RUB'):
raise ValueError(f"Неподдерживаемая валюта: {self.currency}")
def __add__(self, other: 'Money') -> 'Money':
if self.currency != other.currency:
raise ValueError("Валюты должны совпадать")
return Money(self.amount + other.amount, self.currency)
def __repr__(self):
return f"{self.amount:.2f} {self.currency}"
# Пример использования
m1 = Money(100.50, 'USD')
m2 = Money(50.25, 'USD')
print(m1 + m2) # 150.75 USD150.75 USD
Использование value object в качестве ключей словаря
Благодаря реализации __hash__ объекты Point можно использовать в множествах и словарях.
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
points_set = {Point(0,0), Point(1,1), Point(0,0)}
print(points_set) # Две разные точки (дубликат удалён){Point(x=0.0, y=0.0), Point(x=1.0, y=1.0)}Сопоставление с образцом (pattern matching) для value object
Определим __match_args__, чтобы использовать match.
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
__match_args__ = ('x', 'y')
def describe(point):
match point:
case Point(0, 0):
return "Начало координат"
case Point(x, y):
return f"Точка ({x}, {y})"
print(describe(Point(3, 4)))Точка (3.0, 4.0)
Композиция value objects: заказ с позициями
Покажем, как value object могут входить в состав других объектов.
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __mul__(self, factor: int) -> 'Money':
return Money(self.amount * factor, self.currency)
@dataclass(frozen=True)
class Product:
name: str
price: Money
@dataclass(frozen=True)
class OrderLine:
product: Product
quantity: int
def total(self) -> Money:
return self.product.price * self.quantity
tea = Product("Чай", Money(Decimal('2.50'), 'RUB'))
line = OrderLine(tea, 3)
print(line.total()) # 7.50 RUB7.50 RUB
Сериализация и восстановление value object с помощью pydantic
Используем модель pydantic с frozen=True для автоматической валидации из словаря.
from pydantic import BaseModel
class Point(BaseModel, frozen=True):
x: float
y: float
data = {"x": "3.14", "y": "2.71"}
p = Point(**data)
print(p)
print(p.json()) # сериализация в JSONx=3.14 y=2.71
{"x": 3.14, "y": 2.71}