This chapter follows one problem through several designs. A first solution solves it, then you ask “what will change?” and reshape the design to absorb that change cheaply. This is the spirit of Martin Fowler’s Refactoring, applied to patterns rather than single statements.
It is also a Python lesson. Many patterns in GoF Design
Patterns work around the limitations of statically typed
languages: single dispatch, closed classes, and types that are
not values. Python’s classes stay open, its types are values,
and functools.singledispatch adds an operation from
outside a class, so some of those patterns become unnecessary.
This chapter points out each one as the example reaches it.
The example is a trash sorting simulation, and it evolves across the chapter: one design, then a requirement that breaks it, then a reshaping that absorbs the change, then a second axis of change the reshaped design does not touch. Read that evolution as a template for your own designs, which can start as an adequate fit for one problem and grow into a flexible fit for a class of problems.
Trash arrives at the recycling plant mixed together. The program must sort it by material and report the total value of each kind. The trash starts out as an undifferentiated pile, and you must recover the type of each piece to sort it.
In the Trash hierarchy, each material carries a
per-pound value. The base class keeps a
registry of its subclasses, which
__init_subclass__() fills automatically, and a
create() method builds an instance from a material
name, the dictionary factory from Factory:
# trash.py
from dataclasses import dataclass
from typing import ClassVar
type Bins = dict[type[Trash], list[Trash]]
@dataclass(frozen=True)
class Trash:
weight: float
# Dollars per pound (per subclass)
value: ClassVar[float] = 0.0
registry: ClassVar[dict[str, type[Trash]]] = {}
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
Trash.registry[cls.__name__] = cls
@classmethod
def create(cls, name: str, weight: float) -> Trash:
return cls.registry[name](weight)
class Aluminum(Trash):
value = 1.67
class Paper(Trash):
value = 0.10
class Glass(Trash):
value = 0.23
class Cardboard(Trash):
value = 0.79
def sum_value(items: list[Trash]) -> float:
total = sum(t.weight * t.value for t in items)
print(f"Total value = {total:.2f}")
return totalBins names the shape the sorting sections use, a
dictionary from a material’s class to the pieces made of that
material. The
type Statement introduces this alias form. A
type statement’s right side evaluates lazily, so
the alias can name Trash several lines before the
class statement that defines it.
Python implicitly makes __init_subclass__()
a classmethod, so it needs no @classmethod
decorator and its first parameter is the new subclass. It runs
once per subclass, immediately after Python creates that
subclass, so each one can register itself in
Trash.registry automatically. create()
is a class method reading cls.registry. Factory
warns that this form can mislead:
Aluminum.create("Paper", 1.0) is legal and returns
a Paper. The lookup is safe here because every
subclass writes to Trash.registry and none defines
a registry of its own, so cls.registry
always resolves to that one table. Call it as
Trash.create().
@dataclass builds __init__() from
the bare weight: float annotation alone: the two
ClassVar attributes belong to the class, so they
stay out of it (Data
Classes as Types). Each subclass’s value = ...
line creates a class attribute of its own, separate from
Trash.value and from its siblings’. The subclasses
omit the annotation because the name and its type carry over
from the base declaration; restating
ClassVar[float] would also keep the type checker’s
guard on the override (Class
Attributes).
Adding a new recyclable type is a single class definition. It
registers itself, and create() builds it.
sum_value() is an ordinary function. It reads
t.value and t.weight polymorphically,
and never asks what type a piece is.
The tests confirm that each subclass registers itself,
create() builds one by name, and
sum_value() totals weight times the per-pound
value:
# test_trash.py
import pytest
from trash import Aluminum, Paper, Trash, sum_value
def test_subclasses_self_register() -> None:
assert set(Trash.registry) == {
"Aluminum", "Paper", "Glass", "Cardboard"}
def test_create_builds_by_name() -> None:
t = Trash.create("Aluminum", 2.0)
assert isinstance(t, Aluminum)
assert t.weight == 2.0
def test_sum_value_totals_weight_times_value() -> None:
items: list[Trash] = [Aluminum(2.0), Paper(5.0)]
# 2*1.67 + 5*0.10
assert sum_value(items) == pytest.approx(3.84)A data file describes the trash to process, one
Name:weight line per piece:
# trash.dat
Glass:54
Paper:22
Paper:11
Glass:17
Aluminum:89
Paper:88
Aluminum:76
Cardboard:96
Aluminum:25
Aluminum:34
Glass:11
Glass:68
Glass:43
Aluminum:27
Cardboard:44
Aluminum:18
Paper:91
Glass:63
Glass:50
Glass:80
Aluminum:81
Cardboard:12
Parsing it into Trash objects goes through the
registry, so the parser never mentions a concrete material. If
you add a new kind of trash, the parser keeps working
unchanged:
# parse_trash.py
from pathlib import Path
from trash import Trash
def parse(filename: str | Path) -> list[Trash]:
items: list[Trash] = []
for line in Path(filename).read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
name, weight = line.split(":")
items.append(
Trash.create(name.strip(), float(weight)))
return itemsThe test parses a small temporary file, so it does not depend
on trash.dat:
# test_parse_trash.py
from pathlib import Path
from parse_trash import parse
def test_parse_reads_and_skips_comments(
tmp_path: Path,
) -> None:
data = tmp_path / "trash.dat"
data.write_text("""\
# header
Aluminum:2.0
Glass:3.0
""")
items = parse(data)
assert [type(t).__name__ for t in items] == [
"Aluminum", "Glass"]
assert items[0].weight == 2.0
assert items[1].weight == 3.0The most obvious way to sort is to look at each piece and
discover its type using match (the
rtti in the file name is run-time type
identification, the C++ name for discovering a type at
runtime):
# recycle_rtti.py
from collections import defaultdict
from parse_trash import parse
from trash import (Aluminum, Bins, Cardboard, Glass,
Paper, sum_value)
bins: Bins = defaultdict(list)
for t in parse("trash.dat"):
match t:
case Aluminum():
bins[Aluminum].append(t)
case Paper():
bins[Paper].append(t)
case Glass():
bins[Glass].append(t)
case Cardboard():
bins[Cardboard].append(t)
for kind, items in bins.items():
print(f"--- {kind.__name__} ---")
sum_value(items)
#: --- Glass ---
#: Total value = 88.78
#: --- Paper ---
#: Total value = 21.20
#: --- Aluminum ---
#: Total value = 584.50
#: --- Cardboard ---
#: Total value = 120.08recycle_rtti.py satisfies
the requirement, but it has a classic flaw. It tests for every
type in the system. When a new material joins the system, say
Plastic, you must find every case
statement that enumerates specific types. Each one you miss
silently drops trash on the floor. Readers of Composite and
Interpreter may expect assert_never() to make
the type checker catch the missed case. Exhaustiveness checking
works on a closed union, and Trash is
deliberately open, which is the point of the registry, so
assert_never() has nothing to check against here.
This match runs over an open set, which Pattern
Matching warns against. A sorter over an open set must find
the bin without naming any type, and the next section builds
one. Testing for one type, or a small subset that needs special
handling, is fine. Testing for all of them means you do
dispatch’s job by hand. A case _: wildcard could
catch what the named cases miss:
case _: raise ValueError(f"unsorted {type(t).__name__}")
turns the silent drop into a crash. That is worth doing, but it
does not remove the flaw. Every new material still means editing
this match, where bins[type(t)] needs
no edit at all.
That is the argument. Here is the requirement that makes it concrete. The plant starts accepting plastic, which arrives as a new material class and some new lines in the data:
# plastic.dat
Glass:10
Plastic:20
Aluminum:30
Plastic:40
# plastic_dropped.py
from collections import defaultdict
from parse_trash import parse
from trash import (
Aluminum,
Bins,
Cardboard,
Glass,
Paper,
Trash,
sum_value,
)
class Plastic(Trash):
value = 0.15
pieces = parse("plastic.dat")
bins: Bins = defaultdict(list)
for t in pieces:
match t:
case Aluminum():
bins[Aluminum].append(t)
case Paper():
bins[Paper].append(t)
case Glass():
bins[Glass].append(t)
case Cardboard():
bins[Cardboard].append(t)
for kind, items in bins.items():
print(f"--- {kind.__name__} ---")
sum_value(items)
binned = sum(len(v) for v in bins.values())
print(f"parsed {len(pieces)}, binned {binned}")
#: --- Glass ---
#: Total value = 2.30
#: --- Aluminum ---
#: Total value = 50.10
#: parsed 4, binned 2Nothing failed. The parser built two Plastic
objects, the sorter matched neither, and the report totals the
trash it recognized. Two of four pieces reached a bin, and the
sixty pounds of plastic never appeared in the totals the plant
uses. “Silently drop trash on the floor” means a number that is
wrong and looks right, not an exception to debug. The leak is in
the match. The registry accepted
Plastic the moment its class statement
ran, and without that class statement,
create() would have raised a KeyError
at the first Plastic: line, loudly, at parse time.
The match alone loses trash silently.
You can use a dictionary keyed by type:
# recycle_dict.py
from collections import defaultdict
from parse_trash import parse
from trash import Bins, sum_value
bins: Bins = defaultdict(list)
for t in parse("trash.dat"):
bins[type(t)].append(t) # Bin chosen by the trash piece
for kind, items in bins.items():
print(f"--- {kind.__name__} ---")
sum_value(items)
#: --- Glass ---
#: Total value = 88.78
#: --- Paper ---
#: Total value = 21.20
#: --- Aluminum ---
#: Total value = 584.50
#: --- Cardboard ---
#: Total value = 120.08type(t) is the perfect key because it adapts to
new types, including ones added at runtime. Nothing needs
maintaining, and nothing gets forgotten. The key is the
exact class. That is the same dictionary-probe dispatch
as the tables in State
Machines and Multiple
Dispatching, and it first appeared in Function
Objects’s event bus. If you derive
CrushedAluminum from Aluminum, it
sorts into its own bin rather than its parent’s: usually what a
sorter needs, but keep it in mind before you subclass a
material. Subclasses are another place where the two sorters
disagree: case Aluminum() matches any subclass, so
recycle_rtti.py files a
CrushedAluminum under Aluminum.
Swapping the match for the dictionary is a
redesign, not a rename.
The defaultdict(list) creates a bin the first
time a material turns up. Bins is an alias for a
plain dict, so a type checker accepts
bins: Bins = {} too, and that version raises a
KeyError on the first piece of trash.
Point this sorter at plastic.dat, the file that
defeated the match in plastic_dropped.py. The
listing defines Plastic the same way plastic_dropped.py
did:
# recycle_dict_plastic.py
from collections import defaultdict
from parse_trash import parse
from trash import Bins, Trash, sum_value
class Plastic(Trash):
value = 0.15
pieces = parse("plastic.dat")
bins: Bins = defaultdict(list)
for t in pieces:
bins[type(t)].append(t) # Bin chosen by the trash piece
for kind, items in bins.items():
print(f"--- {kind.__name__} ---")
sum_value(items)
binned = sum(len(v) for v in bins.values())
print(f"parsed {len(pieces)}, binned {binned}")
#: --- Glass ---
#: Total value = 2.30
#: --- Plastic ---
#: Total value = 9.00
#: --- Aluminum ---
#: Total value = 50.10
#: parsed 4, binned 4Every piece reaches a bin, plastic included:
parsed 4, binned 4. Defining Plastic
and naming the new data file are the only changes to the
program’s logic. The sorting loop needed no edit, unlike the
match in recycle_rtti.py and plastic_dropped.py.
So far the chapter has made new types cheap. The other axis of change is adding new operations, and a design that makes new types cheap ordinarily makes new operations expensive: that trade is the expression problem from Pattern Matching.
Here is the requirement that makes the second axis concrete. The plant already prints a recycling instruction for each material. Now the safety officer wants a disposal hazard printed beside it. That is a second operation that varies by material, and the obvious home for it is a method on each material class:
# note_methods.py
from dataclasses import dataclass
from typing import ClassVar
@dataclass(frozen=True)
class Trash:
weight: float
value: ClassVar[float] = 0.0
def note(self) -> str:
return f"{type(self).__name__}: nothing special"
# New requirement, so a new method here
def hazard(self) -> str:
return "none"
class Aluminum(Trash):
value = 1.67
def note(self) -> str:
return "Aluminum: crush and bale"
def hazard(self) -> str:
return "sharp edges"
class Glass(Trash):
value = 0.23
def note(self) -> str:
return "Glass: sort by color, then crush"
def hazard(self) -> str:
return "sharp edges"
class Cardboard(Trash):
value = 0.79
def note(self) -> str:
return "Cardboard: flatten and bundle"
def hazard(self) -> str:
return "none"
materials = [Aluminum, Glass, Cardboard]
for cls in materials:
t = cls(1.0)
print(f"{t.note()} | hazard: {t.hazard()}")
edited = [c for c in materials if "hazard" in c.__dict__]
print(f"classes edited for one operation: {len(edited)}")
#: Aluminum: crush and bale | hazard: sharp edges
#: Glass: sort by color, then crush | hazard: sharp edges
#: Cardboard: flatten and bundle | hazard: none
#: classes edited for one operation: 3Both operations answer correctly, and the cost is the last
line. One new question cost an edit to all three material
classes, and the question after it costs three more edits. Those
edits sit in each class body, as note_methods.py shows; in
the real program they would go in trash.py. A method belongs
in the body of its own class by design: you can assign a
function onto a class from outside, but behavior scattered that
way is unmaintainable. A plant that buys its material classes
from a supplier has no class body to edit.
The method form is not a strawman. This hierarchy is small
and the book owns every subclass, so note() on each
material is a real option here. The method wins while you own
the hierarchy and the operations stay few: each subclass answers
for itself, with no separate table to keep in step with the
class list. It loses once you do not own the hierarchy, or once
operations start to outnumber materials.
Visitor is the
classic escape, and it is elaborate: a visitor class, an
accept() method on every element, and double
dispatch to reach the right overload, all to work around a
language that cannot add a method to a class from outside.
functools.singledispatch reaches the same
implementation in one call, and any module can register an
implementation for a new type.
In Python, a single-dispatch function implements Visitor:
# recycling_note.py
from functools import singledispatch
from trash import Aluminum, Cardboard, Glass, Trash
@singledispatch
def recycling_note(t: Trash) -> str:
return f"{type(t).__name__}: no special handling"
@recycling_note.register
def _(t: Aluminum) -> str:
return "Aluminum: crush and bale"
@recycling_note.register
def _(t: Glass) -> str:
return "Glass: sort by color, then crush"
@recycling_note.register
def _(t: Cardboard) -> str:
return "Cardboard: flatten and bundle"
for cls in Trash.registry.values():
print(recycling_note(cls(1.0)))
#: Aluminum: crush and bale
#: Paper: no special handling
#: Glass: sort by color, then crush
#: Cardboard: flatten and bundleEach implementation above takes the name _. Visitor
explains that placeholder. recycling_note() is a
new operation that lives outside the Trash
hierarchy. Paper has no registered note, so it
falls through to the base function. That fallback is also the
risk: a material nobody registers gets the default answer, with
no exception at runtime and no complaint from the type checker.
Here “no special handling” is a genuine answer, so the fallback
earns its keep. When no default makes sense, the Visitor chapter
advises making the base function raise
NotImplementedError, so a forgotten registration
fails at the first call.
Now give the safety officer’s question the same treatment. It arrives as its own file, and edits no material class:
# disposal_hazard.py
from functools import singledispatch
from trash import Aluminum, Glass, Trash
@singledispatch
def hazard(t: Trash) -> str:
return "none"
@hazard.register
def _(t: Aluminum) -> str:
return "sharp edges"
@hazard.register
def _(t: Glass) -> str:
return "sharp edges"
for cls in Trash.registry.values():
print(f"{cls.__name__}: {hazard(cls(1.0))}")
edited = [c for c in Trash.registry.values()
if "hazard" in c.__dict__]
print(f"classes edited for one operation: {len(edited)}")
#: Aluminum: sharp edges
#: Paper: none
#: Glass: sharp edges
#: Cardboard: none
#: classes edited for one operation: 0The counter reads zero. hazard() reaches every
material through the registry, and trash.py is the file that
did not change. A third question and a fourth cost one more file
each, where note_methods.py charges
one edit per material every time. Adding a Plastic
material means defining the class, plus one registration for
each operation that must answer differently for plastic. Python
still has the expression problem, but both sides now cost a line
instead of an edit spread across classes.
singledispatch is for behavior that differs by
type. The earlier sum_value() does the same thing
for every type, so it stays an ordinary function. For an
operation that belongs on an object and still varies by type, functools.singledispatchmethod
provides the same dispatch in method form.
The chapter now holds two kinds of dispatch that disagree
about subclasses. bins[type(t)] keys on the exact
class, so a CrushedAluminum derived from
Aluminum gets a bin of its own.
singledispatch resolves through the MRO, so that
same piece answers with Aluminum’s note. Each is
right for its job. Multiple
Dispatching draws the same distinction between a table keyed
by class and dispatch that follows inheritance.
Design patterns are about separating things that change from
things that stay the same. Polymorphism is one way to do that,
but not the only one. The deeper skill is spotting the
vector of change (Design
Patterns), here new types versus new operations, and
choosing the lightest construct that isolates it. This chapter
met each vector through a concrete requirement: plastic for new
types, and the disposal hazard for new operations. Each vector
costs a single line at the point of use:
bins[type(t)] absorbs a new material, and one
@recycling_note.register teaches an existing
operation about it. Neither is a pattern in the GoF sense. In
Python the lightest construct is often a language feature, not a
multi-class pattern. A pattern is worth keeping only when it is
still useful once the language does part of the work.
Plastic material to trash.py, then point recycle_dict.py at
plastic.dat and run it. Confirm that its sorting
loop and parse_trash.py need no
other changes, then account for every pound of plastic that plastic_dropped.py loses.
Which test in test_trash.py fails, and
why is that failure correct?price() operation as a function over a
list of Trash, and a heaviest()
operation that returns the single heaviest piece. Decide for
each whether it needs singledispatch.recycling_note() single-dispatch
function with a singledispatchmethod on a
Sorter class, and explain what changed.CrushedAluminum from
Aluminum, add it to the data recycle_dict.py reads,
then run that and recycling_note.py. Explain
why it gets its own bin but not its own note. Then change recycle_dict.py so a
subclass shares its parent’s bin, without naming any material in
the sorting loop.