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?