Python Part II · Module 34 · From Script to Maintainable Tool
When a Class Earns Its Complexity
This is the one stage of the refactoring that a flow tool can legitimately skip, and the only honest way to decide is to make the question answerable. 'State and behaviour travel together' is the usual advice and it is correct; it is also unfalsifiable as stated, so it gets used to justify whatever was already written. The comment in the canonical annotation turns out to be a measurement, and applying it to two real classes gives two numbers that are not close.
Advanced22 min readPythonPart IIAutomationClassesDesign
Part II · Module 34 · Chapter 34.4 · When a Class Earns Its Complexity
1. The Question, Made Answerable
ADVICE = [
("use a class when it models a thing", "no", "every noun is a thing"),
("use a class to group related functions", "no", "a module does that"),
("use a class when there is state", "no", "a dict has state"),
("use a class to avoid passing arguments", "no", "that is a hidden input"),
("use a class when state and behaviour travel together", "yes",
"measurable — §4"),
]
print(f"{'the advice':<52} {'usable':<8} why")
for advice, usable, why in ADVICE:
print(f"{advice:<52} {usable:<8} {why}")
print()
print("pieces of advice that decide anything:", 1)
print("and it decides anything only once :", "it is a number")the advice usable why
use a class when it models a thing no every noun is a thing
use a class to group related functions no a module does that
use a class when there is state no a dict has state
use a class to avoid passing arguments no that is a hidden input
use a class when state and behaviour travel together yes measurable — §4
pieces of advice that decide anything: 1
and it decides anything only once : it is a numberThe fourth row is the one that does active harm. "A class so I don't have to
pass config to six functions" sounds like tidiness and is Chapter 33.2
Section 10's hidden input with a different syntax: the six functions still
depend on config, they just no longer say so in their signatures.
2. The Annotation
class Regression: # config, run dir and results that act together
def run(self) -> Summary: ...The comment is the specification and the code is an illustration. Three pieces
of state are named — config, the run directory, and the results — and the
claim is that they act together: no operation touches one without touching
another.
That claim is checkable, and Section 4 checks it. The -> Summary is the
second half of the annotation and says something quieter: the class's one
public method returns a value, so the object is not a place where results
accumulate invisibly. Chapter 34.1's RESULTS bug is what the alternative
looks like.
3. A Premature Class
Here is a class that a reasonable person writes, and that should not exist:
class LogParser:
"""Parses a UVM simulation log. Four methods, one constructor."""
def __init__(self, text):
self.text = text
self.severities = ("UVM_FATAL", "UVM_ERROR", "UVM_WARNING")
def count(self):
n = {s: 0 for s in self.severities}
for line in self.text.splitlines():
for s in self.severities:
if line.startswith(s) and f"{s} :" not in line:
n[s] += 1
return n
def classify(self, counts):
if "TEST DONE" not in self.text:
return "TIMEOUT"
if counts["UVM_FATAL"]:
return "FATAL"
if counts["UVM_ERROR"]:
return "FAIL"
return "PASS"
def to_csv_row(self, result):
return f"{result['status']},{result['errors']}"
def pass_rate(self, passed, total):
return 100.0 * passed / total if total else 0.0
LOG = ("UVM_INFO a\nUVM_ERROR drv.sv(88) x\nUVM_ERROR : 1\nTEST DONE\n")
p = LogParser(LOG)
counts = p.count()
print("count() :", counts)
print("classify() :", p.classify(counts))
print("to_csv_row() :", p.to_csv_row({"status": "FAIL", "errors": 1}))
print("pass_rate() :", p.pass_rate(9, 12))
print()
print("it works:", True)
print("the question is not whether it works, but:",
"what the class is contributing")count() : {'UVM_FATAL': 0, 'UVM_ERROR': 1, 'UVM_WARNING': 0}
classify() : FAIL
to_csv_row() : FAIL,1
pass_rate() : 75.0
it works: True
the question is not whether it works, but: what the class is contributingNothing about that is obviously wrong, which is the point. It runs, it is readable, the methods are short, and the name is honest. Section 4 is what is wrong with it.
4. The Cohesion Test
"State and behaviour travel together" becomes a number by asking, for every pair of methods, whether they touch a common piece of state:
import ast
import itertools
SOURCE = '''
class LogParser:
def __init__(self, text):
self.text = text
self.severities = ("UVM_FATAL", "UVM_ERROR", "UVM_WARNING")
def count(self):
n = {s: 0 for s in self.severities}
for line in self.text.splitlines():
for s in self.severities:
if line.startswith(s) and f"{s} :" not in line:
n[s] += 1
return n
def classify(self, counts):
if "TEST DONE" not in self.text:
return "TIMEOUT"
if counts["UVM_FATAL"]:
return "FATAL"
if counts["UVM_ERROR"]:
return "FAIL"
return "PASS"
def to_csv_row(self, result):
return f"{result['status']},{result['errors']}"
def pass_rate(self, passed, total):
return 100.0 * passed / total if total else 0.0
'''
def self_attrs(fn):
"""Every `self.X` in a function body, attribute or method alike."""
return {n.attr for n in ast.walk(fn)
if isinstance(n, ast.Attribute)
and isinstance(n.value, ast.Name) and n.value.id == "self"}
def cohesion(source):
cls = next(n for n in ast.parse(source).body
if isinstance(n, ast.ClassDef))
methods = [n for n in cls.body if isinstance(n, ast.FunctionDef)]
init = next(m for m in methods if m.name == "__init__")
# DATA attributes only: the ones __init__ assigns. Counting every
# `self.X` would also count method CALLS and inflate the score.
data = self_attrs(init)
pairs = [(m.name, self_attrs(m) & data)
for m in methods if m.name != "__init__"]
combos = list(itertools.combinations(pairs, 2))
shared = sum(1 for (_, a), (_, b) in combos if a & b)
return {
"class": cls.name,
"data attributes": sorted(data),
"methods": pairs,
"stateless methods": [n for n, t in pairs if not t],
"pairs sharing state": shared,
"pairs total": len(combos),
"cohesion": round(shared / len(combos), 2) if combos else 0.0,
"constructor args": len(init.args.args) - 1,
}
r = cohesion(SOURCE)
print("class :", r["class"])
print("data attributes :", r["data attributes"])
print()
print(f" {'method':<12} data attributes it touches")
for name, touched in r["methods"]:
print(f" {name:<12} {sorted(touched) or '— none —'}")
print()
print("methods ignoring state :",
f"{len(r['stateless methods'])}/{len(r['methods'])}",
r["stateless methods"])
print("method pairs sharing :", f"{r['pairs sharing state']}/{r['pairs total']}")
print("cohesion :", r["cohesion"])
print("constructor arguments :", r["constructor args"])class : LogParser
data attributes : ['severities', 'text']
method data attributes it touches
count ['severities', 'text']
classify ['text']
to_csv_row — none —
pass_rate — none —
methods ignoring state : 2/4 ['to_csv_row', 'pass_rate']
method pairs sharing : 1/6
cohesion : 0.17
constructor arguments : 1Cohesion 0.17, and two of four methods never read self at all. One pair
of methods out of six shares any state, so the class is not holding things that
travel together — it is holding one string that two methods read and two
methods ignore.
to_csv_row and pass_rate are the clearest evidence. They are functions. By
putting them in this class they acquired a self parameter they do not use and
a requirement that the caller first construct a LogParser with a log — so
computing a pass rate now needs a log it will never look at.
The constructor-argument count is the practical cost. Every test of pass_rate
has to build a LogParser first, which is Chapter 33.7's setup burden arriving
by design rather than by accident.
5. What It Should Have Been
import io
import unittest
from dataclasses import dataclass
SEVERITIES = ("UVM_FATAL", "UVM_ERROR", "UVM_WARNING")
def count_severities(text):
n = dict.fromkeys(SEVERITIES, 0)
for line in text.splitlines():
for s in SEVERITIES:
if line.startswith(s) and f"{s} :" not in line:
n[s] += 1
return n
def classify(counts, text):
if "TEST DONE" not in text:
return "TIMEOUT"
if counts["UVM_FATAL"]:
return "FATAL"
if counts["UVM_ERROR"]:
return "FAIL"
return "PASS"
def to_csv_row(result):
return f"{result['status']},{result['errors']}"
def pass_rate(passed, total):
return 100.0 * passed / total if total else 0.0
@dataclass(frozen=True)
class Result:
"""Data that travels together and has no behaviour of its own."""
test: str
seed: int
errors: int
status: str
LOG = "UVM_INFO a\nUVM_ERROR drv.sv(88) x\nUVM_ERROR : 1\nTEST DONE\n"
counts = count_severities(LOG)
res = Result("axi_burst_test", 4121, counts["UVM_ERROR"],
classify(counts, LOG))
print("Result :", res)
print("to_csv_row :", to_csv_row({"status": res.status,
"errors": res.errors}))
print("pass_rate(9, 12) :", pass_rate(9, 12))
print("pass_rate(0, 0) :", pass_rate(0, 0))
print()
print("objects you must construct to test pass_rate:", 0)
print("dataclass gives you __eq__ free :",
Result("a", 1, 0, "PASS") == Result("a", 1, 0, "PASS"))
print("and __repr__ free :", "shown above")
print("and it is frozen, so a test cannot mutate it:", True)
try:
res.errors = 99
except Exception as exc:
print(" assigning to a field raises:", type(exc).__name__)Result : Result(test='axi_burst_test', seed=4121, errors=1, status='FAIL')
to_csv_row : FAIL,1
pass_rate(9, 12) : 75.0
pass_rate(0, 0) : 0.0
objects you must construct to test pass_rate: 0
dataclass gives you __eq__ free : True
and __repr__ free : shown above
and it is frozen, so a test cannot mutate it: True
assigning to a field raises: FrozenInstanceErrorFour functions and one frozen dataclass, and pass_rate can now be tested
with two integers. The LogParser class contributed a constructor requirement
and nothing else; removing it removed the requirement and changed no logic —
the same trade Chapter 34.2 Section 10 measured for the module split.
The @dataclass(frozen=True) is doing the job people reach for a class to do.
It carries data that genuinely travels together — a test name, a seed, a count,
a verdict are meaningless apart — and it has no behaviour, so there is no
cohesion question to ask. It also supplies __eq__ and __repr__, which is
why Chapter 33.3's assertEqual on whole results reads well, and frozen=True
makes the leak in Chapter 33.7 Section 9 impossible.
6. A Class That Earns It
import ast
import itertools
SOURCE = '''
class Regression:
def __init__(self, config, run_root):
self.config = config
self.run_root = run_root
self.results = []
self.started = False
def prepare(self):
self.run_root.mkdir(parents=True, exist_ok=True)
self.started = True
def run_one(self, test, seed):
d = self.run_root / f"{test}_{seed}"
rc, text = self.config.backend(self.config.sim, d)
r = {"test": test, "seed": seed, "rc": rc}
self.results.append(r)
return r
def run(self):
if not self.started:
self.prepare()
for test in self.config.tests:
for seed in self.config.seeds:
self.run_one(test, seed)
return self.summary()
def summary(self):
failed = [r for r in self.results if r["rc"]]
return {"total": len(self.results), "failed": len(failed),
"started": self.started}
'''
def self_attrs(fn):
return {n.attr for n in ast.walk(fn)
if isinstance(n, ast.Attribute)
and isinstance(n.value, ast.Name) and n.value.id == "self"}
cls = next(n for n in ast.parse(SOURCE).body if isinstance(n, ast.ClassDef))
methods = [n for n in cls.body if isinstance(n, ast.FunctionDef)]
init = next(m for m in methods if m.name == "__init__")
data = self_attrs(init)
pairs = [(m.name, self_attrs(m) & data)
for m in methods if m.name != "__init__"]
combos = list(itertools.combinations(pairs, 2))
shared = sum(1 for (_, a), (_, b) in combos if a & b)
print("class :", cls.name)
print("data attributes :", sorted(data))
print()
print(f" {'method':<10} data attributes it touches")
for name, touched in pairs:
print(f" {name:<10} {sorted(touched)}")
print()
print("methods ignoring state :", len([n for n, t in pairs if not t]),
"of", len(pairs))
print("method pairs sharing :", f"{shared}/{len(combos)}")
print("cohesion :", round(shared / len(combos), 2))
print("constructor arguments :", len(init.args.args) - 1)
print()
print("LogParser cohesion :", 0.17)
print("Regression cohesion :", round(shared / len(combos), 2))
print("the two are not close :", True)class : Regression
data attributes : ['config', 'results', 'run_root', 'started']
method data attributes it touches
prepare ['run_root', 'started']
run_one ['config', 'results', 'run_root']
run ['config', 'started']
summary ['results', 'started']
methods ignoring state : 0 of 4
method pairs sharing : 6/6
cohesion : 1.0
constructor arguments : 2
LogParser cohesion : 0.17
Regression cohesion : 1.0
the two are not close : TrueCohesion 1.00: every pair of methods shares at least one data attribute, and
no method ignores state. That is the annotation's comment, confirmed —
config, run_root and results genuinely act together, and started
exists because the object has a lifecycle: it can be prepared or not, and
run behaves differently depending on which.
A lifecycle is the thing a class has that a function cannot. prepare changes
what a later run does; run_one changes what a later summary reports.
Threading that through four functions means passing and returning the same
growing tuple of state at every call, which is a class written out longhand.
Note run returns self.summary() rather than leaving the caller to ask. That
is the annotation's -> Summary, and it is what keeps Chapter 34.1's failure
mode out: the one public entry point hands back a value, so a caller cannot
accidentally report on state it never collected.
7. What the Earned Class Costs
Cohesion 1.00 is not a free pass. State that travels together also persists together:
class Regression:
def __init__(self):
self.results = []
def run_one(self, test):
self.results.append(test)
return len(self.results)
def summary(self):
return {"total": len(self.results),
"tests": list(self.results)}
print("--- one instance, two suites")
shared = Regression()
for suite in (["axi_burst_test", "axi_lite_test"], ["axi_wrap_test"]):
for t in suite:
shared.run_one(t)
print(f" after {suite} -> {shared.summary()}")
print()
print("--- a fresh instance per suite")
for suite in (["axi_burst_test", "axi_lite_test"], ["axi_wrap_test"]):
fresh = Regression()
for t in suite:
fresh.run_one(t)
print(f" after {suite} -> {fresh.summary()}")
print()
print("the second shared summary reports :", 3, "runs")
print("the second suite actually ran :", 1)
print("the bug is :", "not in any method")
print("it is in :", "reusing the object")
print("and 33.7 §9 called this :", "shared state across tests")--- one instance, two suites
after ['axi_burst_test', 'axi_lite_test'] -> {'total': 2, 'tests': ['axi_burst_test', 'axi_lite_test']}
after ['axi_wrap_test'] -> {'total': 3, 'tests': ['axi_burst_test', 'axi_lite_test', 'axi_wrap_test']}
--- a fresh instance per suite
after ['axi_burst_test', 'axi_lite_test'] -> {'total': 2, 'tests': ['axi_burst_test', 'axi_lite_test']}
after ['axi_wrap_test'] -> {'total': 1, 'tests': ['axi_wrap_test']}
the second shared summary reports : 3 runs
the second suite actually ran : 1
the bug is : not in any method
it is in : reusing the object
and 33.7 §9 called this : shared state across testsThree runs reported where one happened. No method is wrong; the arithmetic is right in both columns. The defect is that an object whose purpose is to accumulate results was used twice, and accumulation is exactly what it was built to do.
That is the genuine, permanent cost of the shape, and it is the same cost
Chapter 33.7 Section 9 measured when setUpClass shared a directory: state
that travels together persists together, and the only defence is a rule about
lifetime — one Regression per suite, constructed at the point of use and
never stored in a module global.
A function cannot have this bug, which is the honest argument for Section 5's shape wherever it fits. The reason to accept the cost here is that the lifecycle is real: there is genuinely a before and an after, and pretending otherwise means threading four values through four functions.
8. Three Shapes
SHAPES = [
("a function", "no state", "none", "nothing to construct",
"count_severities, classify, pass_rate"),
("a frozen dataclass", "data, no behaviour", "none",
"__eq__ and __repr__ free", "Result, Config"),
("a class", "state + lifecycle", "cohesion ~1.0",
"a reused instance accumulates", "Regression"),
]
print(f"{'shape':<20} {'holds':<20} {'test to apply':<16} {'cost':<28} in our flow")
for shape, holds, test, cost, example in SHAPES:
print(f"{shape:<20} {holds:<20} {test:<16} {cost:<28} {example}")
print()
print("functions in the refactored flow :", 9)
print("frozen dataclasses :", 2)
print("classes :", 1)
print()
print("the decision order:")
print(" 1. a function, unless state must survive between calls")
print(" 2. a frozen dataclass, if it is data with no behaviour")
print(" 3. a class, only if cohesion is near 1 and a lifecycle is real")
print()
print("and the module's own verdict:",
"this stage can be skipped — many flow tools need no class at all")shape holds test to apply cost in our flow
a function no state none nothing to construct count_severities, classify, pass_rate
a frozen dataclass data, no behaviour none __eq__ and __repr__ free Result, Config
a class state + lifecycle cohesion ~1.0 a reused instance accumulates Regression
functions in the refactored flow : 9
frozen dataclasses : 2
classes : 1
the decision order:
1. a function, unless state must survive between calls
2. a frozen dataclass, if it is data with no behaviour
3. a class, only if cohesion is near 1 and a lifecycle is real
and the module's own verdict: this stage can be skipped — many flow tools need no class at allNine functions, two dataclasses, one class. That ratio is the honest shape of a flow tool, and the last line is the chapter's own conclusion: Chapter 34.1's diagram marked this stage conditional, and a tool whose work is "run these, parse those, write a report" often has no lifecycle at all.
The cost column for a dataclass says __eq__ and __repr__ free rather
than listing a drawback, which is not an oversight. A frozen dataclass has
essentially no downside for carrying grouped data, which is why it is the
default answer so much more often than a class is.
9. A Class Is a Namespace With No Spell-Checker
Section 7's cost is about lifetime. There is a second cost that belongs to the shape itself, and it is the same defect Chapter 34.1 Section 6 found, arriving from the other direction:
class Regression:
def __init__(self):
self.results = []
def record(self, row):
self.resluts = [row] # a typo. Read it twice.
return len(self.results)
r = Regression()
print("before : results =", r.results)
n = r.record("axi_burst_test")
print("record() : returned", n)
print("results :", r.results, "<- unchanged")
print("resluts :", getattr(r, "resluts", None), "<- a new attribute")
print("attributes :", sorted(vars(r)))
print("errors :", "none")
print()
print("the class now has :", len(vars(r)), "attributes")
print("the author intended :", 1)
print("what reports the mistake :", "nothing")before : results = []
record() : returned 0
results : [] <- unchanged
resluts : ['axi_burst_test'] <- a new attribute
attributes : ['resluts', 'results']
errors : none
the class now has : 2 attributes
the author intended : 1
what reports the mistake : nothingTwo attributes where the author meant one, and record returned 0 after
recording something. Assigning to self.anything creates the attribute, so a
misspelling is not an error — it is a new piece of state that nothing reads.
That is Chapter 34.1 Section 6's bug with the direction reversed. There,
RESULTS was read and never written, so the report was empty. Here
resluts is written and never read, so the report is empty for the same
reason — and a plain class detects neither, because both are legal Python.
Two declarations close it:
from dataclasses import dataclass, field, FrozenInstanceError
class Slotted:
"""__slots__ fixes the set of attributes at class-definition time."""
__slots__ = ("results",)
def __init__(self):
self.results = []
def record(self, row):
self.resluts = [row]
return len(self.results)
@dataclass(frozen=True)
class FrozenResult:
"""A frozen dataclass rejects any assignment at all."""
rows: tuple = ()
s = Slotted()
try:
s.record("axi_burst_test")
print("__slots__ : no error")
except AttributeError as exc:
print("__slots__ : AttributeError")
print(" ", str(exc)[:56])
f = FrozenResult()
try:
f.resluts = ("axi_burst_test",)
print("frozen dataclass: no error")
except FrozenInstanceError as exc:
print("frozen dataclass: FrozenInstanceError —", exc)
plain = type("Plain", (), {})()
plain.resluts = ("x",)
print("plain class : no error, attribute created")
print()
print("shapes that catch the typo:", 2)
print("shapes that do not :", 1)
print("the cost of __slots__ :",
"no new attributes anywhere, including ones you wanted")__slots__ : AttributeError
'Slotted' object has no attribute 'resluts' and no __dic
frozen dataclass: FrozenInstanceError — cannot assign to field 'resluts'
plain class : no error, attribute created
shapes that catch the typo: 2
shapes that do not : 1
the cost of __slots__ : no new attributes anywhere, including ones you wantedTwo of three shapes catch it at the moment of assignment. __slots__
declares the complete attribute set, so anything else raises AttributeError;
a frozen dataclass rejects every assignment after construction, so the typo and
a legitimate late write are refused together.
The last line is the honest trade and it decides where each belongs. __slots__
forbids attributes you did want to add later, which is fine for a small
Result and annoying on an object that grows during a run — so the frozen
dataclass of Section 5 is the right place for strictness, and the earned class
of Section 6 stays a plain class because its whole purpose is to accumulate.
What protects the earned class instead is Section 6's measurement. Cohesion
1.00 means every attribute is touched by more than one method, so an attribute
that only one method ever mentions is visible as a cohesion drop — and
resluts, touched once and read never, is exactly that shape. The metric that
decides whether the class should exist also detects a typo inside it.
10. Predict, Run, Observe
import ast
import itertools
from dataclasses import dataclass, FrozenInstanceError
SRC = '''
class Thing:
def __init__(self, a):
self.a = a
def uses_a(self):
return self.a
def uses_nothing(self, x):
return x * 2
'''
def attrs(fn):
return {n.attr for n in ast.walk(fn)
if isinstance(n, ast.Attribute)
and isinstance(n.value, ast.Name) and n.value.id == "self"}
cls = next(n for n in ast.parse(SRC).body if isinstance(n, ast.ClassDef))
ms = [n for n in cls.body if isinstance(n, ast.FunctionDef)]
data = attrs(next(m for m in ms if m.name == "__init__"))
pairs = [(m.name, attrs(m) & data) for m in ms if m.name != "__init__"]
# A
print("A:", data, pairs)
# B
combos = list(itertools.combinations(pairs, 2))
print("B:", sum(1 for (_, x), (_, y) in combos if x & y), "/", len(combos))
@dataclass(frozen=True)
class R:
status: str
# C
print("C:", R("PASS") == R("PASS"), R("PASS") is R("PASS"))
# D
try:
R("PASS").status = "FAIL"
print("D:", "mutated")
except FrozenInstanceError:
print("D:", "FrozenInstanceError")The verified output:
A: {'a'} [('uses_a', {'a'}), ('uses_nothing', set())]
B: 0 / 1
C: True False
D: FrozenInstanceErrorA — one data attribute, and uses_nothing touches none of it. Section 4's
measurement on the smallest possible class.
B — cohesion 0/1, which is the lowest score a two-method class can get. This class is a function and a constant.
C — two frozen dataclasses with equal fields are == and are not the
same object. __eq__ comes free and compares by value, which is what makes
Chapter 33.3's whole-result assertions readable; is still asks about
identity, which is Chapter 13.3 Section 7's distinction.
D — and frozen=True means a test cannot quietly mutate a fixture it was
handed, which is the Chapter 33.7 leak closed at the type level rather than by
discipline.
11. Exercises
Exercise 1 — unfalsifiable advice. For each of Section 1's five pieces of advice, say what it would forbid.
Exercise 2 — the hidden input. Find a class in your code whose purpose is to avoid passing an argument, and name the argument.
Exercise 3 — read the annotation as a spec. Write out what its comment claims, as a sentence that could be false.
Exercise 4 — the second half. Say what -> Summary rules out.
Exercise 5 — a premature class of your own. Find one that works and is readable.
Exercise 6 — measure it. Run the cohesion function on it and record all five numbers.
Exercise 7 — the stateless methods. List its methods that never read
self.
Exercise 8 — what they acquired. For one of them, say what the class gave it that it did not want.
Exercise 9 — count data attributes only. Explain what goes wrong if the metric counts method calls too.
Exercise 10 — the inflated score. Predict what Section 6's class would score under the broken metric.
Exercise 11 — the constructor cost. Count what a test of your stateless method has to build first.
Exercise 12 — functions plus a dataclass. Rewrite your premature class that way and confirm the logic is unchanged.
Exercise 13 — free methods. Confirm your dataclass gives you __eq__ and
__repr__.
Exercise 14 — freeze it. Add frozen=True and try to mutate a field.
Exercise 15 — data that travels together. Name three values in your flow that are meaningless apart.
Exercise 16 — a class that earns it. Find or write one and measure its cohesion.
Exercise 17 — the lifecycle. Name the before-and-after that makes a class right, and say which attribute records it.
Exercise 18 — longhand. Write the four-function version of your earned class and count the values threaded through.
Exercise 19 — the entry point returns. Confirm your class's public method hands back a value.
Exercise 20 — reuse it. Run two suites through one instance and record both summaries.
Exercise 21 — no method is wrong. Explain why the accumulation bug is not in any method.
Exercise 22 — a rule about lifetime. Write down where your instance is constructed and how long it lives.
Exercise 23 — never a module global. Check that no instance of it is stored at module level.
Exercise 24 — the three shapes. Classify everything in one of your modules as function, dataclass or class.
Exercise 25 — the ratio. Count them and compare with nine, two, one.
Exercise 26 — the decision order. Apply the three steps to the next thing you are about to write.
Exercise 27 — skip the stage. Decide honestly whether your tool needs a class, and write down the reason either way.
Exercise 28 — the typo. Misspell an attribute inside a method and confirm no error is raised.
Exercise 29 — two attributes. Print vars(obj) and count what the class actually has.
Exercise 30 — both directions. State Chapter 34.1 Section 6's bug and this one as a pair.
Exercise 31 — __slots__. Add it to a small class and confirm the typo now raises.
Exercise 32 — the message. Read the AttributeError text and say what it tells you.
Exercise 33 — frozen refuses everything. Explain why that catches the typo and a late write together.
Exercise 34 — where strictness belongs. Decide which of your classes should have __slots__ and which should not.
Exercise 35 — cohesion finds it too. Work out what resluts does to the cohesion score.
12. Summary
- Four of the five common justifications for a class decide nothing, and
"a class so I don't have to pass
config" actively hides an input the functions still depend on. - The annotation's comment is a measurement. "State and behaviour travel together" becomes cohesion: for each pair of methods, do they touch a common data attribute?
- The premature class scored 0.17, with one pair of six sharing any state
and two of four methods never reading
self. - Those two methods are functions that acquired an unused
selfand a constructor requirement — computing a pass rate needed a log it never reads. - Count only the attributes
__init__assigns. Counting everyself.Xalso counts method calls, which rewards a class whose methods call each other and inflates exactly the wrong design. - Four functions and a frozen dataclass replaced it with no change to the
logic, and
pass_ratebecame testable with two integers. - A frozen dataclass is what people reach for a class to do: grouped data,
no behaviour,
__eq__and__repr__free, and immutable so a test cannot leak a mutation into the next one. - The earned class scored 1.00 — every method pair shares state, none ignores it — and its fourth attribute exists because the object has a real lifecycle.
- A lifecycle is what a function cannot have. The alternative is threading the same growing tuple through four calls, which is a class written longhand.
runreturns the summary, so a caller cannot report on state it never collected — Chapter 34.1's bug, excluded by the signature.- Cohesion 1.00 is not a free pass: a reused instance accumulated, reporting three runs where one happened, with no method at fault.
- So the earned class needs a rule about lifetime — one per suite, constructed where it is used, never a module global.
- Nine functions, two dataclasses, one class is the honest ratio, and the decision order is function, then frozen dataclass, then class.
- This stage can be skipped. Many flow tools have no lifecycle, and
- Assigning to a misspelled attribute creates it — two attributes where one was meant,
recordreturning 0 after recording, and no error anywhere. - That is Chapter 34.1 Section 6's bug reversed: a global read and never written there, an attribute written and never read here, and a plain class detects neither.
__slots__andfrozen=Trueboth catch it at the assignment, and__slots__'s cost is forbidding attributes you did want to add later.- The cohesion metric detects it too — an attribute only one method mentions shows up as a drop, so the measurement that decides whether the class should exist also finds a typo inside it. Chapter 34.1's diagram marked it conditional for that reason.
The next chapter removes the seven machine-specific facts Chapter 34.1 measured, which is the change that lets another team run the tool at all.
Where this fits
Part of the Python curriculum.
