Python Part I · Module 16 · Object-Oriented Programming
When a Dictionary Is Enough
Fifteen chapters have built up an apparatus, and the honest closing argument is that most flow automation should use very little of it. The instinct after learning classes is to model everything as one, and the result is a codebase where a four-field record needs a constructor, a repr, a comparison and a file of its own, to do something a dictionary literal did in one line. This chapter is about the threshold — what a record has to be doing before a class earns its place — and the answer is narrower than most people expect.
Intermediate16 min readPythonPart IFundamentalsOOPDesign
Part I · Module 16 · Chapter 16.16 · When a Dictionary Is Enough
1. The Instinct
After Chapter 16.15, a test result looks like it obviously wants a class. Here is the same record three ways:
from dataclasses import dataclass
as_dict = {"name": "smoke", "seed": 1234, "passed": True, "runtime_s": 182.4}
@dataclass
class AsDataclass:
name: str
seed: int
passed: bool
runtime_s: float
class AsPlainClass:
def __init__(self, name, seed, passed, runtime_s):
self.name = name
self.seed = seed
self.passed = passed
self.runtime_s = runtime_s
d = as_dict
c = AsDataclass("smoke", 1234, True, 182.4)
p = AsPlainClass("smoke", 1234, True, 182.4)
print("dict :", d["name"], d["runtime_s"])
print("dataclass:", c.name, c.runtime_s)
print("class :", p.name, p.runtime_s)
print("lines of definition:", 1, 6, 6)dict : smoke 182.4
dataclass: smoke 182.4
class : smoke 182.4
lines of definition: 1 6 6All three hold the same four values and all three read them back. The question is what the extra five lines bought.
2. The Plain Class Buys Nothing
The canonical observation for this chapter:
# a class with only __init__ and no behaviour
# is a dataclass wearing a costumeAnd the dataclass does the same job better, as Chapter 16.15 measured:
from dataclasses import dataclass
@dataclass
class AsDataclass:
name: str
seed: int
class AsPlainClass:
def __init__(self, name, seed):
self.name = name
self.seed = seed
c1, c2 = AsDataclass("smoke", 1), AsDataclass("smoke", 1)
p1, p2 = AsPlainClass("smoke", 1), AsPlainClass("smoke", 1)
print("dataclass repr :", c1)
print("plain repr has address:", " object at 0x" in repr(p1))
print("dataclass equality:", c1 == c2)
print("plain equality :", p1 == p2)dataclass repr : AsDataclass(name='smoke', seed=1)
plain repr has address: True
dataclass equality: True
plain equality : FalseThe hand-written class is strictly worse: more lines, an unreadable repr
(Chapter 16.8) and broken equality (Chapter 16.2 Section 8). If you are writing
a class whose body is only __init__, write @dataclass instead.
That disposes of one of the three. The real question is the remaining two.
3. The Test Is Behaviour
A class earns its place when it has behaviour that belongs with the data, or an invariant to protect. A record with neither is a dictionary.
Here is a record with neither:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
as_dict = {"x": 3, "y": 4}
as_class = Point(3, 4)
print("dict :", as_dict["x"] + as_dict["y"])
print("class:", as_class.x + as_class.y)dict : 7
class: 7Nothing is gained. Now one with both:
LEGAL = {"PENDING", "PASS", "FAIL"}
class TestRun:
"""Has an invariant (status must be legal) and behaviour (label)."""
def __init__(self, name, seed):
self.name = name
self.seed = seed
self.status = "PENDING"
def set_status(self, status):
if status not in LEGAL:
raise ValueError(f"not a legal status: {status!r}")
self.status = status
def label(self):
return f"{self.name}/{self.seed}:{self.status}"
r = TestRun("smoke", 1234)
r.set_status("FAIL")
print("label:", r.label())
try:
r.set_status("FAILED")
except ValueError as e:
print("refused:", e)label: smoke/1234:FAIL
refused: not a legal status: 'FAILED'The class is now doing two things a dict cannot: it rejects an illegal state, and it owns the rule for building a label. That is what the five extra lines bought.
A dict has no equivalent — any code anywhere can write
result["status"] = "FAILED" and nothing objects.
4. Your Data Probably Arrives as a Dict Already
The argument specific to this field. Flow data comes from JSON, CSV and tool output, and all of it arrives as dictionaries:
from dataclasses import dataclass, asdict
@dataclass
class TestResult:
name: str
seed: int
passed: bool
incoming = [
{"name": "smoke", "seed": 1234, "passed": True},
{"name": "cdc", "seed": 5678, "passed": False},
]
objects = [TestResult(**d) for d in incoming]
outgoing = [asdict(o) for o in objects]
print("in :", incoming[0])
print("obj :", objects[0])
print("out :", outgoing[0])
print("round trip identical:", incoming == outgoing)
print("failures:", [o.name for o in objects if not o.passed])
print("same, from dicts:", [d["name"] for d in incoming if not d["passed"]])in : {'name': 'smoke', 'seed': 1234, 'passed': True}
obj : TestResult(name='smoke', seed=1234, passed=True)
out : {'name': 'smoke', 'seed': 1234, 'passed': True}
round trip identical: True
failures: ['cdc']
same, from dicts: ['cdc']The last two lines produce identical answers. If the only thing between reading
the JSON and writing it back is a filter, the conversion to objects and back
is work that bought nothing — and TestResult(**d) will raise on any file
with an extra field, which may or may not be what you want.
Module 21 covers JSON and CSV properly. The design point here: convert at a boundary when something in between genuinely needs the object, not reflexively.
5. The Threshold, Honestly
This is not an argument against ever using a dataclass. Chapter 16.14's two gaps are real, and they bite at a specific point:
def load():
return {"name": "smoke", "seed": 1234, "passed": True, "runtime_s": 182.4}
def enrich(r):
r["label"] = f"{r['name']}/{r['seed']}"
return r
def filter_slow(records):
return [r for r in records if r["runtime_s"] > 100]
def report(records):
for r in records:
print(f" {r['label']} {r['runtim_s']}s") # typo, four functions in
report(filter_slow([enrich(load())]))Traceback (most recent call last):
File "far.py", line 19, in <module>
report(filter_slow([enrich(load())]))
~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "far.py", line 16, in report
print(f" {r['label']} {r['runtim_s']}s") # typo, four functions in
~^^^^^^^^^^^^
KeyError: 'runtim_s'Four functions deep and the typo is found at run time, in the reporting branch — which in a real flow is the branch that runs once a night.
The threshold:
If a record is built in one place and consumed within a few lines, a dict is enough. If it is passed through several functions, written by several people, or persisted and reloaded, declare it.
Four lines of dataclass is cheap insurance against a KeyError in a nightly
report. Four lines of dataclass for a two-line script is ceremony.
6. The Decision
| Situation | Use |
|---|---|
| A handful of values used in the next few lines | a dict |
| Data read from JSON/CSV and written straight back | a dict |
| A record passed between functions or modules | a dataclass |
| A record persisted, reloaded or compared | a dataclass |
| Something with an invariant to protect | a class |
| Something with behaviour belonging to the data | a class |
| Something holding other objects and coordinating them | a class (Chapter 16.13) |
A record with only __init__ and no behaviour | never a plain class — a dataclass |
The default is the first row. That is the chapter's position, and it is the opposite of the instinct Chapter 16.1 to 16.15 will have given you.
7. Three Classes That Should Not Exist
The instinct produces three recognisable shapes.
The getter/setter class, imported from languages that need it:
class BadResult:
def __init__(self, name):
self._name = name
def get_name(self):
return self._name
def set_name(self, value):
self._name = value
b = BadResult("smoke")
b.set_name("cdc")
print("ceremony:", b.get_name())
print("Python :", {"name": "cdc"}["name"])ceremony: cdc
Python : cdcChapter 16.7 established that Python has no privacy, so the getter protects nothing. It is six lines implementing attribute access, which Python already has.
The class with one method and no state — Chapter 16.5 Section 5's rule:
class ReportFormatter:
def format(self, records):
return [f"{r['name']} {r['runtime_s']}s" for r in records]
def format_report(records):
return [f"{r['name']} {r['runtime_s']}s" for r in records]
RECS = [{"name": "smoke", "runtime_s": 182.4}]
print("class :", ReportFormatter().format(RECS))
print("function:", format_report(RECS))class : ['smoke 182.4s']
function: ['smoke 182.4s']ReportFormatter() holds nothing. Creating an object to immediately call one
method on it is a function call with extra syntax.
The "manager" with no state, whose name is the tell — JobManager,
ResultHandler, FlowProcessor. If it has no attributes, it is a module of
functions (Chapter 13.5), and saying so makes it easier to test.
8. Predict, Run, Observe
from dataclasses import dataclass, asdict
@dataclass
class R:
name: str
seed: int
d = {"name": "smoke", "seed": 1234}
o = R(**d)
print("A:", o == R("smoke", 1234))
print("B:", asdict(o) == d)
print("C:", d == asdict(R(**d)))
d["extra"] = "unexpected field"
print("D:", sorted(d))
try:
R(**d)
except TypeError as e:
print("E:", e)A: True
B: True
C: True
D: ['extra', 'name', 'seed']
E: R.__init__() got an unexpected keyword argument 'extra'A, B and C — the round trip is lossless for a matching shape.
E is the trade, stated precisely. The dict accepted an extra field without complaint; the dataclass refused it. Whether that is a feature or an obstacle depends entirely on whether an unexpected field in your input is a problem or a newer tool version adding a column.
That is the whole decision in one error message. A dataclass is a commitment to a shape. Make it when the shape is yours to define; do not make it for data whose shape somebody else controls and keeps changing.
9. Exercises
Exercise 1 — three ways. Write the same four-field record as a dict, a dataclass and a plain class. Count the lines and compare the reprs.
Exercise 2 — strictly worse. Show the plain class failing at equality and repr where the dataclass succeeds. State the rule.
Exercise 3 — no behaviour. Write a record with no behaviour as a class, then as a dict. Say what the class version gained.
Exercise 4 — an invariant. Add a rule that must always hold, and show the dict version being violated with no error.
Exercise 5 — the round trip. Convert a list of dicts to dataclasses and back. Confirm nothing changed, then say what the conversion was for.
Exercise 6 — the distant typo. Build a four-function pipeline over dicts with a typo in the last one. Then convert to a dataclass and say where the failure moves to.
Exercise 7 — the three anti-patterns. Find or write one example of each from Section 7, and rewrite it the simpler way.
Exercise 8 — apply the table. For each, choose from Section 6: (a) a parsed log line used immediately (b) a result written to CSV and read back next week (c) a running tally inside one function (d) a simulator wrapper with a timeout rule (e) a config read from JSON and passed to one function.
10. Summary
- Most automation data is a record, and a record is a dictionary until it needs more.
- A class with only
__init__and no behaviour is a dataclass wearing a costume — and strictly worse, with a bad repr and broken equality. - The test is behaviour or an invariant. A class that has neither is holding values a dict already holds.
- Flow data arrives as dicts from JSON, CSV and tool output; converting to objects and back buys nothing if nothing in between needs the object.
- The threshold is distance: built and consumed within a few lines means a dict; passed through several functions or persisted means declare it.
- A
KeyErrorfour functions from the typo is what the four lines of dataclass buy. - A dataclass is a commitment to a shape — it refuses unexpected fields, which is a feature when the shape is yours and an obstacle when it is not.
- A getter/setter class protects nothing in a language with no privacy.
- A class with one method and no state is a function.
- A class named for an activity rather than a thing is usually a module of functions.
That closes Module 16, and with it the object-oriented arc of Part I. You can read a class, write one, build a hierarchy, and — most usefully — recognise the large number of cases where none of that is the right answer. Part I's remaining modules turn from the language to the job it is used for.
Where this fits
Part of the Python curriculum.
