Objects and errors
Dataclasses and Value Objects
What @dataclass generates, why a mutable default needs default_factory, and how frozen, order and __post_init__ turn a class into a value object.
You write a class for a task, and then you write __init__,
__repr__ and __eq__. None of those three contains a decision you had
to make. A month later you add a field, forget to add it to __eq__, and lose an
afternoon to a test that fails for a purely mechanical reason.
Python's dataclasses module deletes that work. You write the fields with their
types, and the decorator writes the methods. What remains is the part that carries actual
information: whether the object may change after it is built, what equality means for it, and
whether it is a thing or a description of a thing.
That last question decides the shape of a domain model. A Money with the same
amount and currency as another Money is the same amount of money, so replacing one
copy with the other changes nothing. Two Session records with the same session id
are the same session even if one was last seen at nine and the other at noon, so replacing one
with the other loses information. The first is a value object, the second is an entity, and a
model that confuses them produces duplicate rows and tests that pass for the wrong reason.
The decorator writes the three methods you were going to write
A dataclass is a normal class with annotations at the top. The decorator reads those
annotations and generates an __init__, a __repr__ and an
__eq__ from them:
from dataclasses import asdict, dataclass, field
@dataclass
class Task:
title: str
estimate: int = 30
tags: list[str] = field(default_factory=list)
first = Task("write the report", tags=["writing"])
second = Task("write the report")
print(first)
print(first == Task("write the report", tags=["writing"]))
print(first.tags is second.tags)
print(Task.__hash__)
print(asdict(first))
Task(title='write the report', estimate=30, tags=['writing'])
True
False
None
{'title': 'write the report', 'estimate': 30, 'tags': ['writing']}
Three generated methods, one generated attribute. The __eq__ compares every
field that is marked for comparison, and asdict walks the fields and returns plain
data, which is what you want when serialising. The None on the fourth line is the
one piece of the output worth pausing on: when a dataclass has eq=True and is not
frozen, the decorator sets __hash__ to None, so the object cannot go
in a set or act as a dictionary key. Lesson 10 covers why that is the correct default.
Here is the same class written by hand, which is what you are choosing not to write:
class Task:
def __init__(self, title, estimate=30, tags=None):
self.title = title
self.estimate = estimate
self.tags = list(tags) if tags else []
def __repr__(self):
return f"Task({self.title!r}, {self.estimate}, {self.tags})"
def __eq__(self, other):
if not isinstance(other, Task):
return NotImplemented
return (self.title, self.estimate, self.tags) == (
other.title,
other.estimate,
other.tags,
)
print(Task("write the report", tags=["writing"]))
print(Task("write the report", tags=["writing"]) == Task("write the report", tags=["writing"]))
Task('write the report', 30, ['writing'])
True
The behaviour matches, and the maintenance cost does not. Every added field means three
edits, and the __eq__ that forgets one of them fails silently by reporting two
different objects as equal. The dataclass version cannot drift, because there is no second
place listing the fields.
A mutable default is a class-level bug, so the decorator refuses it
The obvious way to give every task an empty tag list is to write one. The decorator stops you, and the error names the fix:
from dataclasses import dataclass
@dataclass
class Bad:
title: str
tags: list[str] = []
Traceback (most recent call last):
File "bad_task.py", line 4, in <module>
@dataclass
^^^^^^^^^
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
The list would have been one object shared by every instance, which is the trap from lesson
10 with better error reporting. Frames through dataclasses.py are left out of that
traceback. What matters is the message, and the fix is field:
tags: list[str] = field(default_factory=list)
default_factory takes a callable, and the decorator calls it once per instance
instead of evaluating it once at class definition. Any zero-argument callable works, so
field(default_factory=dict), field(default_factory=set) and
field(default_factory=lambda: datetime.now(UTC)) all do what you expect. The
confirmation is the False earlier: two tasks built with no arguments hold two
different lists.
Use default_factory only for genuinely mutable or computed defaults. A string,
a number, a tuple or None is fine as a plain default, and
field(default="x") is a longer way to write something the decorator already
handles.
__post_init__ is where validation goes
Type annotations are not enforced at runtime, so Money annotated with
int will happily accept a string. The generated __init__ calls
__post_init__ at the end if you have defined it, which makes it the one place that
runs after every field is set:
from dataclasses import dataclass
@dataclass(frozen=True, order=True)
class Money:
amount_cents: int
currency: str = "EUR"
def __post_init__(self):
if not isinstance(self.amount_cents, int):
raise TypeError("amount_cents must be an int")
if self.currency not in {"EUR", "USD", "GBP"}:
raise ValueError(f"unsupported currency: {self.currency!r}")
@property
def display(self):
return f"{self.amount_cents / 100:.2f} {self.currency}"
print(Money(1250))
print(Money(500) < Money(1250))
Money(amount_cents=1250, currency='EUR')
True
Pass an unsupported currency and the traceback shows the exact place in the chain, including
the generated constructor in <string>:
print(Money(1250, "BTC"))
Traceback (most recent call last):
File "money.py", line 16, in <module>
print(Money(1250, "BTC"))
~~~~~^^^^^^^^^^^^^
File "<string>", line 5, in __init__
File "money.py", line 13, in __post_init__
raise ValueError(f"unsupported currency: {self.currency!r}")
ValueError: unsupported currency: 'BTC'
Raising here rather than in the caller means an invalid Money cannot exist, so
every function that receives one can skip the checking. Customise with __post_init__
rather than by writing your own __init__, because a hand-written
__init__ replaces the generated one and you lose the field handling you were
relying on unless you remember to call super().__init__().
frozen=True is what makes sharing a value object safe
A value object that someone can modify in place is a value object in name only, because the copy you hold can change without you touching it:
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
amount_cents: int
currency: str = "EUR"
fee = Money(1250)
print(fee == Money(1250), len({fee, Money(1250)}))
try:
fee.amount_cents = 900
except Exception as exc:
print(type(exc).__name__, exc)
True 1
FrozenInstanceError cannot assign to field 'amount_cents'
Frozen also supplies the __hash__ that the mutable version lacked, which is why
the set literal on the first line works and holds one element. To change a frozen value you
build a new one, and dataclasses.replace saves you from restating the fields that
did not change. Continuing from the block above:
from dataclasses import replace
updated = replace(fee, amount_cents=900)
print(fee, updated)
Money(amount_cents=1250, currency='EUR') Money(amount_cents=900, currency='EUR')
Two caveats. FrozenInstanceError is a subclass of AttributeError,
so a broad except AttributeError somewhere above will swallow it and leave you
debugging a value that looks unchanged. And frozen is not enforcement: it overrides attribute
assignment, so object.__setattr__(fee, "amount_cents", 0) still writes.
Frozen means "this object is not designed to change", not "the interpreter will stop you".
Adding slots=True to the decorator is a separate, smaller decision: the
instance keeps its fields in fixed slots instead of a dictionary, so there is no
__dict__ and assigning an undeclared attribute raises
AttributeError instead of quietly succeeding. It costs nothing here and catches
typos, at the price of losing dynamic attributes on purpose.
order=True compares field by field, in the order you declared them
order=True generates __lt__, __le__,
__gt__ and __ge__ that compare tuples of fields. For
Money that gives Money(500) < Money(1250), and it also gives you a
comparison that means nothing: Money(500, "EUR") < Money(500, "USD") is
True because the strings are compared, not the amounts. Ordering across currencies
is not a fact about money, and the honest options are to compare only within one currency or to
leave order off and sort by an explicit key.
The generated comparison also fails at the first field that cannot be ordered, which can be several fields deep:
from dataclasses import dataclass, field
@dataclass(frozen=True, order=True)
class Line:
sku: str
quantity: int
meta: dict = field(default_factory=dict)
print(sorted([Line("A-1", 1, {"x": 1}), Line("A-1", 1, {"y": 2})]))
Traceback (most recent call last):
File "line.py", line 11, in <module>
print(sorted([Line("A-1", 1, {"x": 1}), Line("A-1", 1, {"y": 2})]))
~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<string>", line 17, in __lt__
TypeError: '<' not supported between instances of 'dict' and 'dict'
Two lines with different SKUs sort without complaint, because the comparison stops at the first field that differs and never reaches the dictionary. Excluding the field from comparison is the fix, and it fixes equality at the same time:
@dataclass(frozen=True, order=True)
class Line:
sku: str
quantity: int
meta: dict = field(default_factory=dict, compare=False)
print(sorted([Line("A-1", 2), Line("A-2", 1)]))
print(Line("A-1", 2) == Line("A-1", 2, {"note": "x"}))
[Line(sku='A-1', quantity=2, meta={}), Line(sku='A-2', quantity=1, meta={})]
True
compare=False means the field is still stored, still printed by the generated
__repr__, and ignored by equality and ordering. That is the right treatment for
metadata that does not define the value.
A value object is equal by value, and an entity is equal by identity
Everything above assumes the object is described entirely by its fields. An entity is not: it has an identity that persists while its attributes change, and equality has to follow the identity rather than the current field values:
from dataclasses import dataclass
@dataclass(eq=False)
class Session:
session_id: str
user_id: str
seen_at: str
def __eq__(self, other):
return isinstance(other, Session) and self.session_id == other.session_id
def __hash__(self):
return hash(self.session_id)
morning = Session("s-1", "ana", "09:00")
evening = Session("s-1", "ana", "21:40")
other = Session("s-2", "ana", "09:00")
print(morning == evening, morning == other)
print(len({morning, evening, other}))
True False
2
eq=False keeps the decorator out of the decision, and the hand-written pair
compares and hashes on session_id alone. The set holds two sessions: the first
and second are the same session, the third is a different one. A default dataclass would have
compared all three fields, reported the morning and evening sessions as different, and put
three entries in the set.
Getting this backwards is expensive in both directions. Treating an entity as a value object
means every attribute update looks like a different record, so a cache keyed on the object
misses and a de-duplicating set never de-duplicates. Treating a value object as an entity means
two Money(1250) instances are compared by identity, so an equality test that
should pass fails and a dictionary keyed by amount holds duplicates. Decide which one you are
building before you write the fields.
A NamedTuple is a tuple first, and that is the whole difference
typing.NamedTuple gives you a similar-looking class with different
foundations. It is a subclass of tuple, and every tuple behaviour comes with
it:
from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
label: str = "origin"
origin = Point(0, 0)
print(origin)
print(origin == (0.0, 0.0, "origin"))
x, y, label = origin
print(x, y, label)
print(origin._replace(label="centre"))
print(hasattr(origin, "__dict__"))
try:
origin.x = 1
except Exception as exc:
print(type(exc).__name__, exc)
Point(x=0, y=0, label='origin')
True
0 0 origin
Point(x=0, y=0, label='centre')
False
AttributeError can't set attribute
The second line is the one to notice. A Point compares equal to a plain tuple
with the same contents, because it is a plain tuple with extra methods. That is convenient when
the value really is a record in a sequence, and a hazard when it is not, because any function
that takes a tuple accepts your Point and can pass a bare tuple back.
Choose a NamedTuple when the fields are positional by nature, there are two or
three of them, and the tuple protocol is part of the interface. Choose a dataclass when you
want a keyword constructor with defaults, per-field control over comparison, validation,
computed properties, or the option of mutation. NamedTuple gives you indexing,
assignment to several names at once, ordering by position and a free _replace; a
dataclass gives you
field, frozen, order, compare,
slots and __post_init__. Neither is a superset of the other, and the
tuple heritage is what tips the decision.
Model a shopping cart with two types. LineItem holds a sku, a
quantity and a unit_cents price. Cart holds a list of
line items.
LineItemrejects a quantity below 1 and a negative price, and two line items with the same fields compare equal.LineItemcan be sorted by the natural field order, andtotal_centsis a computed property.Cartstarts empty without sharing a list with any other cart, and itstotal_centssums the line totals.
Then answer: why is Cart not frozen, and what would you pass to
field() if LineItem also carried a tags set that
should not affect equality or ordering?
Show the solution and the reasoning
The two types are on opposite sides of the value object and entity line, and the decorator arguments follow from that. A line item is fully described by its three fields, so it is a value object: frozen, ordered, and validated once at construction. A cart accumulates items during its life, so it is an entity with mutable state and a list that has to belong to one instance.
from dataclasses import dataclass, field
@dataclass(frozen=True, order=True)
class LineItem:
sku: str
quantity: int
unit_cents: int
def __post_init__(self):
if self.quantity < 1:
raise ValueError("quantity must be at least 1")
if self.unit_cents < 0:
raise ValueError("unit_cents must not be negative")
@property
def total_cents(self):
return self.quantity * self.unit_cents
@dataclass
class Cart:
items: list[LineItem] = field(default_factory=list)
def add(self, item):
self.items.append(item)
return self
@property
def total_cents(self):
return sum(item.total_cents for item in self.items)
python3 lab11.py
Cart(items=[LineItem(sku='B-2', quantity=1, unit_cents=500), LineItem(sku='A-1', quantity=3, unit_cents=250)])
total: 1250
[LineItem(sku='A-1', quantity=3, unit_cents=250), LineItem(sku='B-2', quantity=1, unit_cents=500)]
value equality: True
separate lists: True
ValueError quantity must be at least 1
The sort on the third line is not alphabetical by accident. order=True
compares sku first, so "A-1" comes before "B-2", and
by the time quantity or price could matter there is nothing left to compare. Sorting by total
price instead would need sorted(cart.items, key=lambda item: item.total_cents),
because the generated comparison has no way to know which field you meant.
Cart cannot be frozen, and the failure would be immediate:
self.items.append(item) would raise FrozenInstanceError.
Frozen suits values that are replaced rather than updated. A cart is updated, which is why it
gets an add method that returns self, and why the default_factory on
items matters. Without it, two carts would append to the same list.
For a tags set that should not affect equality or ordering, pass both flags
to field:
tags: set[str] = field(default_factory=set, compare=False)
default_factory=set gives each line item its own set,
compare=False keeps it out of __eq__ and out of the generated
ordering methods so a set is never compared with <, and the field still
appears in the __repr__ when you print the object during debugging.
Progress
Mark this lesson done and it stays marked in this browser.