Day 25/420: Dataclasses and value objects
420 Day Python Production Track
Two things bite me whenever I represent a small factual value as a plain class or dict: two equal values don't compare equal, and the value gets mutated under code that was still using it. Today I'll show both with an RGBColor, then fix them with @dataclass(frozen=True).
The two-line bug
class RGBColor:
def __init__(self, r, g, b):
self.r = r
self.g = g
self.b = b
teal = RGBColor(0, 128, 128)
also_teal = RGBColor(0, 128, 128)
print(teal == also_teal) # False
Same three channels, and == is False. Python's default __eq__ compares identity -- "is this the same object in memory" -- not contents. Two colors with equal channels are two different objects, so they're not equal. The instant teal lands in a set of "colors already used in this palette," the dedup logic silently fails and you ship duplicates.
It gets worse when the value is mutable and shared:
def darken(color, amount):
color.r = max(0, color.r - amount) # mutates in place
color.g = max(0, color.g - amount)
color.b = max(0, color.b - amount)
return color
brand = RGBColor(0, 128, 128)
shadow = darken(brand, 40)
print(brand.r, brand.g, brand.b) # 0 88 88 -> the brand color moved too
darken looks like it returns a new color. It returns the same object it mutated, so brand -- which the rest of the theme is still referencing -- just changed. A value is a fact you pass around; it should not be editable by whoever happens to hold it.
The fix: one decorator
from dataclasses import dataclass
@dataclass(frozen=True)
class RGBColor:
r: int
g: int
b: int
def __post_init__(self):
for name, value in (("r", self.r), ("g", self.g), ("b", self.b)):
if not 0 <= value <= 255:
raise ValueError(f"{name} out of range 0-255: {value}")
def darkened(self, amount):
clamp = lambda v: max(0, v - amount)
return RGBColor(clamp(self.r), clamp(self.g), clamp(self.b))
teal = RGBColor(0, 128, 128)
print(teal == RGBColor(0, 128, 128)) # True -> value equality
print(len({teal, RGBColor(0, 128, 128)})) # 1 -> hashable, dedups by value
shadow = teal.darkened(40)
print(teal) # RGBColor(r=0, g=128, b=128) -> unchanged
print(shadow) # RGBColor(r=0, g=88, b=88) -> a new object
frozen=True generates three behaviors:
-
Value
__eq__. Equal channels compare equal, so the palettesetcollapses duplicates. -
__hash__. The color can be a set member or dict key. A mutable object can't be -- if it changed, its hash would go stale and the set would lose it. - Immutability. Assignment is blocked, so a shared value can't be edited under you.
try:
teal.r = 255
except Exception as exc:
print(type(exc).__name__) # FrozenInstanceError
darkened returns a new RGBColor instead of mutating -- the same way str.lower() gives you a new string. Sharing is safe again because nobody can change what they're holding.
Validate in __post_init__
The dataclass writes __init__ for you. To add invariants, implement __post_init__: the generated init assigns the fields, then calls it. The range check above means an out-of-range channel can never be constructed -- not from a test, not from parsed config, not from JSON. One place, every path.
The mutable-default trap
A field default cannot be a mutable object. This raises ValueError at class definition time:
# @dataclass
# class Palette:
# colors: list = [] # ValueError: mutable default
The dataclass refuses because one list would be shared across every instance. Use field(default_factory=...):
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Palette:
name: str
colors: tuple = field(default_factory=tuple)
p = Palette("ocean")
print(p.colors) # () -> fresh per instance, not shared
I use tuple, not list. On a frozen object, frozen=True only blocks reassigning the field; a list default would still let callers mutate its contents in place. A tuple keeps the whole value immutable.
Value object vs entity
The question that decides whether to freeze: if two instances have identical fields, are they the same thing?
-
RGBColor,Coordinate,Money, a date range: yes, interchangeable -> value object -> frozen dataclass, compared by value. -
User,Order,Account: no -- two accounts with the same balance are still different accounts -> entity -> stable id, compared by id.
A value object is what; an entity is who. When a thing is fully described by its contents, make it a frozen dataclass and stop hand-writing equality, hashing, and immutability.
What value type in your codebase is still a bare dict that probably shouldn't be?
Top comments (0)