DEV Community

Naveen Karasu
Naveen Karasu

Posted on

Day 25/420: Dataclasses and value objects -- value equality, hashability, and immutability for free

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

frozen=True generates three behaviors:

  • Value __eq__. Equal channels compare equal, so the palette set collapses 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)