Python Part I · Module 16 · Object-Oriented Programming
Dataclasses for Results a Whole Flow Passes Around
Chapter 16.14 ended with a dictionary that was doing almost everything right and failing in two specific ways: nothing anywhere stated what fields a result has, and a missing one was discovered three functions after it went missing. This chapter closes both with four lines, using a decorator that is the only one this curriculum introduces before Part II. It also hands back three methods you have already written or wished for by hand — and it is worth being precise about which of its promises are enforced and which are merely documentation, because one of the two is widely misunderstood.
Intermediate16 min readPythonPart IFundamentalsOOPDataclasses
Part I · Module 16 · Chapter 16.15 · Dataclasses for Results a Whole Flow Passes Around
1. Four Declared Fields
from dataclasses import dataclass
@dataclass
class TestResult:
name: str
seed: int
passed: bool
runtime_s: float
r = TestResult("smoke", 1234, True, 182.4)
print("the object :", r)
print("a field :", r.name, r.seed)
print("its type :", type(r).__name__)
print("its storage:", r.__dict__)the object : TestResult(name='smoke', seed=1234, passed=True, runtime_s=182.4)
a field : smoke 1234
its type : TestResult
its storage: {'name': 'smoke', 'seed': 1234, 'passed': True, 'runtime_s': 182.4}@dataclass is a decorator — a line beginning with @ placed above a class
that modifies it. This is the only one introduced in Part I, and it is used
exactly as shown; Part II covers decorators properly.
The class body contains no __init__, no self, and no assignments. Four lines
naming fields and their types, and the decorator writes the rest.
2. Gap One: the Fields Are Now Stated
Chapter 16.14's first gap was that a dict's shape existed only as a habit. Now it is a declaration a reader, an editor and a program can all see:
from dataclasses import dataclass, fields
@dataclass
class TestResult:
name: str
seed: int
passed: bool
runtime_s: float
print("declared fields:")
for f in fields(TestResult):
print(f" {f.name:<11} {f.type}")
print("field names:", [f.name for f in fields(TestResult)])declared fields:
name <class 'str'>
seed <class 'int'>
passed <class 'bool'>
runtime_s <class 'float'>
field names: ['name', 'seed', 'passed', 'runtime_s']Note f.type came back as the class object itself rather than the string
"str" — the declaration is stored as a real reference, not as text.
The structure is introspectable. A reporting tool can ask what a result contains without anyone writing that list a second time.
3. Gap Two: a Missing Field Fails at Construction
The second gap was that an incomplete dict was built happily and failed later:
from dataclasses import dataclass
@dataclass
class TestResult:
name: str
seed: int
passed: bool
runtime_s: float
print("building an incomplete result")
r = TestResult("smoke", 1234, True)building an incomplete result
Traceback (most recent call last):
File "missing.py", line 13, in <module>
r = TestResult("smoke", 1234, True)
TypeError: TestResult.__init__() missing 1 required positional argument: 'runtime_s'The failure is at the line that built the object, naming the field that is
missing. Compare Chapter 16.14 Section 4, where the same mistake produced a
KeyError inside a summarising function two calls away.
That is the whole of the second gap, closed. A result either exists complete or does not exist.
Defaults work as they do anywhere (Chapter 11.5):
from dataclasses import dataclass
@dataclass
class TestResult:
name: str
seed: int
passed: bool = False
runtime_s: float = 0.0
print(TestResult("smoke", 1234))
print(TestResult("cdc", 5678, True, 204.1))TestResult(name='smoke', seed=1234, passed=False, runtime_s=0.0)
TestResult(name='cdc', seed=5678, passed=True, runtime_s=204.1)4. The Types Are Not Enforced
The most common misunderstanding, and it matters:
from dataclasses import dataclass
@dataclass
class TestResult:
name: str
seed: int
passed: bool
runtime_s: float
r = TestResult("smoke", "not an integer", "yes", [1, 2])
print("constructed happily:", r)
print("seed is a:", type(r.seed).__name__)
print("runtime is a:", type(r.runtime_s).__name__)constructed happily: TestResult(name='smoke', seed='not an integer', passed='yes', runtime_s=[1, 2])
seed is a: str
runtime is a: listNothing checked anything. seed: int is a type hint, and Chapter 11.12
established that hints are documentation — Python does not verify them at
runtime.
So what did they buy? Exactly Section 2's answer: a declaration. A reader
knows what was intended, an editor can warn, and a type checker run separately
will catch the mismatch. What they do not buy is a runtime guarantee, and a
dataclass that must validate needs an explicit check — Chapter 14.7's raise at
the boundary, written in a __post_init__ method that this curriculum does not
otherwise cover.
A dataclass declares the shape. It does not police the contents.
5. Three Methods You Already Wrote by Hand
The decorator generates more than __init__:
from dataclasses import dataclass
@dataclass
class Generated:
name: str
seed: int
class ByHand:
def __init__(self, name, seed):
self.name = name
self.seed = seed
def __repr__(self):
return f"ByHand(name={self.name!r}, seed={self.seed})"
def __eq__(self, other):
return self.name == other.name and self.seed == other.seed
g1, g2 = Generated("smoke", 1234), Generated("smoke", 1234)
h1, h2 = ByHand("smoke", 1234), ByHand("smoke", 1234)
print("repr :", g1, "|", h1)
print("equal :", g1 == g2, "|", h1 == h2)
print("lines :", "4 declared", "vs", "13 hand-written")repr : Generated(name='smoke', seed=1234) | ByHand(name='smoke', seed=1234)
equal : True | True
lines : 4 declared vs 13 hand-writtenThree things arrive free, and you have met all three as problems:
__init__— the fourself.x = xlines from Chapter 16.3, which is also where forgettingself.silently built an empty object.__repr__— Chapter 16.8's memory address, now readable in lists and debuggers without writing anything.__eq__— Chapter 16.2 Section 8 found that two objects with identical fields compareFalse. A dataclass compares by field value instead.
And __eq__ arrives without Chapter 16.8 Section 6's hashing trap, because the
decorator handles both together:
from dataclasses import dataclass
@dataclass(frozen=True)
class Key:
name: str
seed: int
keys = {Key("smoke", 1), Key("smoke", 1), Key("cdc", 2)}
print("three added, set holds:", len(keys))
print("sorted :", sorted(keys, key=lambda k: k.name))
print("as a dict key :", {Key("smoke", 1): "PASS"}[Key("smoke", 1)])three added, set holds: 2
sorted : [Key(name='cdc', seed=2), Key(name='smoke', seed=1)]
as a dict key : PASS(The set is sorted before printing because a set has no reliable order — Chapter 7.2's rule applies to your own objects too.)
Two identical Key objects collapsed to one in the set, because equality is by
value and frozen=True supplies a matching __hash__.
6. The One Error a Dataclass Refuses
Chapter 11.5's mutable-default trap is finally caught by a tool rather than by a bug:
from dataclasses import dataclass
def define_bad():
@dataclass
class TestResult:
name: str
tags: list = []
return TestResult
try:
define_bad()
except ValueError as e:
print("refused at definition time")
print("ValueError:", e)refused at definition time
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factoryThe error is at class-definition time, not at the second instance — which is when the plain-class version of this bug produces its first wrong answer. The message names the field and the fix.
The fix uses field(default_factory=...), which builds a fresh list per
instance:
from dataclasses import dataclass, field
@dataclass
class TestResult:
name: str
tags: list = field(default_factory=list)
a = TestResult("smoke")
b = TestResult("cdc")
a.tags.append("nightly")
print("a:", a)
print("b:", b)
print("same list:", a.tags is b.tags)a: TestResult(name='smoke', tags=['nightly'])
b: TestResult(name='cdc', tags=[])
same list: FalseThat is Chapter 16.4 Section 5's fix — a new list per instance — expressed as a
declaration instead of a line in __init__.
7. Freezing a Result That Should Not Change
Chapter 16.6 asked whether a method should mutate. For a recorded result, the answer is usually that nothing should:
from dataclasses import dataclass
@dataclass(frozen=True)
class TestResult:
name: str
passed: bool
r = TestResult("smoke", True)
print("reading is fine:", r.name, r.passed)
r.passed = Falsereading is fine: smoke True
Traceback (most recent call last):
File "frozen.py", line 12, in <module>
r.passed = False
^^^^^^^^
File "<string>", line 16, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'passed'frozen=True makes assignment an error. For a result that records what
happened, that is the right default — a run's outcome is a fact, and code that
wants a modified copy should say so:
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class TestResult:
name: str
passed: bool
original = TestResult("smoke", True)
amended = replace(original, passed=False)
print("original:", original)
print("amended :", amended)
print("distinct:", original is not amended)original: TestResult(name='smoke', passed=True)
amended : TestResult(name='smoke', passed=False)
distinct: Truereplace() is Chapter 16.6 Section 4's "return a new object" pattern, supplied
for you.
8. Predict, Run, Observe
from dataclasses import dataclass, asdict
@dataclass
class R:
name: str
seed: int = 0
a = R("smoke", 1234)
b = R("smoke", 1234)
c = R("smoke")
print("A:", a == b, a is b)
print("B:", c)
print("C:", asdict(a))
print("D:", a.__dict__ == asdict(a))
a.seed = 9999
print("E:", a, a == b)A: True False
B: R(name='smoke', seed=0)
C: {'name': 'smoke', 'seed': 1234}
D: True
E: R(name='smoke', seed=9999) FalseA — equal by value, distinct objects. Contrast Chapter 16.2 Section 8, where
the hand-written class gave False.
C and D — asdict() converts back to a plain dictionary, and it matches
__dict__ for a flat dataclass. That is the bridge to Module 21's JSON and CSV
work: a dataclass internally, a dict at the boundary.
E — a plain dataclass is mutable, so assignment works and equality
follows the new value. Use frozen=True when that is not what you want.
9. Exercises
Exercise 1 — four fields. Write a dataclass with four typed fields and create two instances. Print both.
Exercise 2 — the declaration. Use fields() to list a dataclass's field
names and types without reading the source.
Exercise 3 — missing field. Omit a required argument and record the error.
Compare it to Chapter 16.14's KeyError.
Exercise 4 — hints are not checks. Construct an instance with the wrong type in every field. Confirm it succeeds, and say what the hints are for.
Exercise 5 — three free methods. Write the hand-written equivalent of a two-field dataclass. Count the lines and name each generated method.
Exercise 6 — the mutable default. Write tags: list = [] and record the
error. Fix it with default_factory and prove two instances do not share.
Exercise 7 — frozen. Freeze a dataclass, try to assign to a field, then
produce a modified copy with replace().
Exercise 8 — round trip. Convert a dataclass to a dict with asdict() and
back with R(**d). Say where in a real flow each direction would be used.
10. Summary
@dataclassis a decorator that writes a class's boilerplate from a list of declared fields.- The fields are stated where a reader, an editor and a program can see them — Chapter 16.14's first gap.
- A missing field fails at construction, naming the field — Chapter 16.14's second gap.
- Type hints are not enforced; a dataclass declares the shape and does not police the contents.
__init__,__repr__and__eq__arrive free — the three things you wrote by hand or missed in Chapters 16.3, 16.8 and 16.2.- Equality is by field value, not identity.
frozen=Truealso supplies__hash__, so instances work in sets and as dict keys.- A mutable default raises at class-definition time, naming
default_factory— Chapter 11.5's trap, caught by a tool. frozen=Truesuits anything you record, andreplace()gives you the modified copy.asdict()converts back to a plain dict at a boundary.
The last chapter of the module argues that you should often use none of this.
Where this fits
Part of the Python curriculum.
