GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • 01Introduction
  • 02Tour
  • 03Containers
  • 04Control Flow
  • 05Functions
  • 06Modules and Packages
  • 07Classes
  • 08Static Types
  • 09Class Attributes
  • 10Cleanup
  • 11Testing
  • 12Data Classes as Types
  • 13Pattern Matching
  • 14Decorators

This view

  • Original page
  • New address

GeezerVison is showing thinkinginpython.com — Pattern Refactoring: Solutions · Thinking in Python

Index
Contents
  • exercise_1.py
  • exercise_2.py
  • exercise_3.py
  • exercise_4.py
  • exercise_5.py
Chapter 37 · Solutions

Pattern Refactoring: Solutions

1. Adding Plastic

Add a Plastic material to trash.py, then point recycle_dict.py at plastic.dat and run it. Confirm that sort_by_type() and parse_trash.py handle Plastic as written, 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?

Where to look

Let a Dictionary Do the Sorting shows the loop keying each bin on type(piece). The First Cut: Checking Every Type shows a match with no case for a new material. A subclass of Trash registers itself when its class statement runs, so compare what each script does with a new key. For the failing test, read what test_trash.py asserts about the registry.

The shape
# The shape of exercise_1.py
from collections import defaultdict
from typing import ClassVar
from record import record

type Bins = dict[type[Trash], list[Trash]]

@record
class Trash:
    weight: float
    value: ClassVar[float] = 0.0
    registry: ClassVar[dict[str, type[Trash]]] = {}

    def __init_subclass__(cls, **kwargs: object) -> None:
        ...

    @classmethod
    def create(cls, name: str, weight: float) -> Trash:
        ...

class Aluminum(Trash):
    value: ClassVar[float] = 1.67

class Plastic(Trash):
    value: ClassVar[float] = 0.15

def sum_value(contents: list[Trash]) -> float:
    ...
Done when

recycle_dict.py on plastic.dat prints Plastic: 9.00 beside the Glass and Aluminum totals, with dict_sorter.py and parse_trash.py as they were, and you can trace that 9.00 to the pieces plastic_dropped.py omits. uv run pytest test_trash.py shows one failing test, and you can say why its failure is correct.

Solution

A solution runs in its own folder and cannot import the chapter’s files, so the listing reproduces the result of the edit to trash.py in reduced form, with Plastic registered beside Aluminum. The listing bins a two-piece sample. On plastic.dat, the same binning yields the Plastic: 9.00 total that recycle_dict_plastic.py prints.

# exercise_1.py
from collections import defaultdict
from typing import ClassVar
from record import record

type Bins = dict[type[Trash], list[Trash]]

@record
class Trash:
    weight: float
    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: ClassVar[float] = 1.67

class Plastic(Trash):
    value: ClassVar[float] = 0.15

def sum_value(contents: list[Trash]) -> float:
    return sum(piece.weight * piece.value
               for piece in contents)

items = [Trash.create("Plastic", 10.0),
         Trash.create("Aluminum", 2.0)]
bins: Bins = defaultdict(list)
for t in items:
    bins[type(t)].append(t)
for kind, group in bins.items():
    print(kind.__name__, sum_value(group))
#: Plastic 1.5
#: Aluminum 3.34

Register the new material. The Plastic class is the only new Python code. __init_subclass__() registers it in Trash.registry the moment the class statement runs, so Trash.create("Plastic", weight) finds the class with no further code.

Bin each piece by its class. sort_by_type() handles Plastic as written, because its loop, bins[type(piece)].append(piece), keys on the class of each piece. Plastic is a new key for the dictionary, so defaultdict creates its bin the way it creates every other.

The one edit to recycle_dict.py is the filename, since the script hardcodes parse("trash.dat"). parse_trash.py handles Plastic as written, because it passes Trash.create(name, weight) the name it reads from the file and leaves the choice of class to the registry. If Plastic needs special handling, an optional @recycling_note.register function provides it.

plastic_dropped.py parses four pieces and bins two. The match in sort_by_match() holds four cases, one per original material. The twenty-pound and forty-pound pieces fall through it, and the loop moves on to the next piece. Sixty pounds at 0.15 a pound is the Plastic: 9.00 that recycle_dict_plastic.py prints and plastic_dropped.py omits. plastic_dropped.py’s report looks complete, and the two totals it prints are correct for the pieces they cover.

test_subclasses_self_register fails, because it asserts that the registry’s keys are exactly {"Aluminum", "Paper", "Glass", "Cardboard"} and Plastic is now a fifth entry. That failure is correct. The test exists to prove that defining a subclass registers it, so a new material changes the set the assertion compares. With Plastic defined, the test passes only if __init_subclass__() has stopped registering subclasses. Once you update the expected set, the test checks registration again.

Before you move on, name what separates your attempt from this one: a different design, a detail, or an equivalent alternative.

2. price() and heaviest()

Write a 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.

Where to look

One singledispatch Function per Operation uses singledispatch where behavior differs by type, and Choosing the Lightest Construct compares the options. Ask whether each operation reads anything that varies by class beyond the numbers every Trash carries. When the form is the same for every type, an ordinary function over the list is enough.

The shape
# The shape of exercise_2.py
from typing import ClassVar
from record import record

@record
class Trash:
    weight: float
    value: ClassVar[float] = 0.0
    registry: ClassVar[dict[str, type[Trash]]] = {}

    def __init_subclass__(cls, **kwargs: object) -> None:
        ...

    @classmethod
    def create(cls, name: str, weight: float) -> Trash:
        ...

class Aluminum(Trash):
    value: ClassVar[float] = 1.67

class Plastic(Trash):
    value: ClassVar[float] = 0.15

def price(items: list[Trash]) -> float:
    ...

def heaviest(items: list[Trash]) -> Trash:
    ...
Done when

for a 10-pound Plastic and a 2-pound Aluminum, price() returns 4.84 and heaviest() returns the Plastic object, not the number 10.0. uv run ty check passes on your file, and you can say for each function whether its body reads anything that differs by class.

Solution
# exercise_2.py
from typing import ClassVar
from record import record

@record
class Trash:
    weight: float
    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: ClassVar[float] = 1.67

class Plastic(Trash):
    value: ClassVar[float] = 0.15

items = [Trash.create("Plastic", 10.0),
         Trash.create("Aluminum", 2.0)]

def price(items: list[Trash]) -> float:
    return sum(t.weight * t.value for t in items)

def heaviest(items: list[Trash]) -> Trash:
    return max(items, key=lambda t: t.weight)

print(price(items))
#: 4.84
h = heaviest(items)
print(type(h).__name__, h.weight)
#: Plastic 10.0

If you call max(items) without the key= argument, heaviest() raises a TypeError: '>' not supported between instances of 'Aluminum' and 'Plastic'. ty reports the same call as an invalid-argument-type, because a record defines no ordering for max() to use.

Neither price() nor heaviest() needs singledispatch. Every Trash subclass carries weight and value, so the same code runs for Aluminum, Plastic, or any future material. Each is a single function, like sum_value().

singledispatch is for behavior that differs by type, such as recycling_note() giving Aluminum and Glass their own wording. When a calculation has the same form for every type and varies only in the numbers each type carries, write an ordinary function.

Before you move on, name what separates your attempt from this one: a different design, a detail, or an equivalent alternative.

3. recycling_note() as a singledispatchmethod

Replace the recycling_note() single-dispatch function with a singledispatchmethod on a Sorter class, and explain what changed.

Where to look

One singledispatch Function per Operation shows recycling_note() as a registered function. Move recycling_note() into a Sorter class with functools.singledispatchmethod, and register each overload with the base method’s register decorator. Dispatch still keys on the first argument after self, so consider what else the class now provides.

The shape
# The shape of exercise_3.py
from functools import singledispatchmethod
from typing import ClassVar
from record import record

@record
class Trash:
    weight: float
    value: ClassVar[float] = 0.0
    registry: ClassVar[dict[str, type[Trash]]] = {}

    def __init_subclass__(cls, **kwargs: object) -> None:
        ...

class Aluminum(Trash):
    value: ClassVar[float] = 1.67

class Paper(Trash):
    value: ClassVar[float] = 0.10

class Glass(Trash):
    value: ClassVar[float] = 0.23

class Cardboard(Trash):
    value: ClassVar[float] = 0.79

class Plastic(Trash):
    value: ClassVar[float] = 0.15

class Sorter:
    @singledispatchmethod
    def recycling_note(self, t: Trash) -> str:
        ...

    @recycling_note.register
    def _(self, t: Aluminum) -> str:
        ...

    @recycling_note.register
    def _(self, t: Glass) -> str:
        ...

    @recycling_note.register
    def _(self, t: Cardboard) -> str:
        ...
Done when

looping over Trash.registry and calling your Sorter’s recycling_note() prints the four notes recycling_note.py prints, plus Plastic: no special handling if you define Plastic. If Aluminum gets no special handling, your dispatch is keying on the Sorter instance. You can say when the method form improves on the function.

Solution
# exercise_3.py
from functools import singledispatchmethod
from typing import ClassVar
from record import record

@record
class Trash:
    weight: float
    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

class Aluminum(Trash):
    value: ClassVar[float] = 1.67

class Paper(Trash):
    value: ClassVar[float] = 0.10

class Glass(Trash):
    value: ClassVar[float] = 0.23

class Cardboard(Trash):
    value: ClassVar[float] = 0.79

class Plastic(Trash):
    value: ClassVar[float] = 0.15

class Sorter:
    @singledispatchmethod
    def recycling_note(self, t: Trash) -> str:
        return "no special handling"

    @recycling_note.register
    def _(self, t: Aluminum) -> str:
        return "crush and bale"

    @recycling_note.register
    def _(self, t: Glass) -> str:
        return "sort by color, then crush"

    @recycling_note.register
    def _(self, t: Cardboard) -> str:
        return "flatten and bundle"

sorter = Sorter()
for cls in Trash.registry.values():
    note = sorter.recycling_note(cls(1.0))
    print(f"{cls.__name__}: {note}")
#: Aluminum: crush and bale
#: Paper: no special handling
#: Glass: sort by color, then crush
#: Cardboard: flatten and bundle
#: Plastic: no special handling

If you decorate the Sorter methods with @singledispatch instead of @singledispatchmethod, the loop prints “no special handling” for all five materials, Aluminum included, and the type checker accepts the class. singledispatch dispatches on the first argument, which in a method call is the Sorter instance. In the @singledispatch version, the one registration that matches a Sorter is the base method’s registration, so the base method handles every call.

Route each piece by its type. The dispatch is the same as in the function version. singledispatchmethod routes on the type of the first argument after self.

Move the operation onto an object. What changes is where you define the operation.

The method form matters if Sorter holds state of its own alongside the dispatch: a log of notes issued, a configuration, statistics. When Sorter holds only the dispatch, as here, the function in recycling_note.py is simpler and does the same job. Use singledispatchmethod when the operation needs to read or update an object’s state.

The method form also brings the trap that Multiple Dispatching describes. A subclass of Sorter shares this one dispatcher, so a registration made through the subclass changes what Sorter’s method returns too.

Before you move on, name what separates your attempt from this one: a different design, a detail, or an equivalent alternative.

4. Exact-type bins against MRO dispatch

Derive CrushedAluminum from Aluminum in trash.py, add it to the data recycle_dict.py reads, then run recycle_dict.py and recycling_note.py. Explain why CrushedAluminum gets its own bin but not its own note. Then change sort_by_type() so a subclass shares its parent’s bin, without naming any material class in the sorting loop.

Where to look

Let a Dictionary Do the Sorting keys each bin on type(piece), an exact-class lookup, while singledispatch in One singledispatch Function per Operation walks the MRO. To let a subclass share its parent’s bin, give Trash a class variable that holds the bin’s key, and have __init_subclass__() default that variable for each class. A subclass that shares its parent’s bin overrides the variable, and the loop indexes by that attribute instead of type(t).

The shape
# The shape of exercise_4.py
from collections import defaultdict
from functools import singledispatch
from typing import ClassVar
from record import record

@record
class Trash:
    weight: float
    value: ClassVar[float] = 0.0
    bin: ClassVar[type[Trash]]

    def __init_subclass__(cls, **kwargs: object) -> None:
        ...

class Aluminum(Trash):
    value: ClassVar[float] = 1.67

class CrushedAluminum(Aluminum):
    value: ClassVar[float] = 1.67
    bin: ClassVar[type[Trash]] = Aluminum

class Glass(Trash):
    value: ClassVar[float] = 0.23

@singledispatch
def recycling_note(t: Trash) -> str:
    ...

@recycling_note.register
def _(t: Aluminum) -> str:
    ...
Done when

recycle_dict.py reports a CrushedAluminum: total of its own while recycling_note.py prints CrushedAluminum: crush and bale, and you can name the lookup behind each. After your change, the CrushedAluminum: line is gone, its weight adds to the Aluminum total, every other material keeps its own bin, and the sorting loop takes each bin’s key from the piece.

Solution

A solution runs in its own folder and cannot import the chapter’s files, so the listing reproduces the result of the edit to trash.py and trash.dat, with CrushedAluminum derived from Aluminum and a pieces list in place of the data file.

# exercise_4.py
from collections import defaultdict
from functools import singledispatch
from typing import ClassVar
from record import record

@record
class Trash:
    weight: float
    value: ClassVar[float] = 0.0
    bin: ClassVar[type[Trash]]

    def __init_subclass__(cls, **kwargs: object) -> None:
        super().__init_subclass__(**kwargs)
        if "bin" not in cls.__dict__:
            cls.bin = cls

class Aluminum(Trash):
    value: ClassVar[float] = 1.67

class CrushedAluminum(Aluminum):
    value: ClassVar[float] = 1.67
    bin: ClassVar[type[Trash]] = Aluminum

class Glass(Trash):
    value: ClassVar[float] = 0.23

@singledispatch
def recycling_note(t: Trash) -> str:
    return "no special handling"

@recycling_note.register
def _(t: Aluminum) -> str:
    return "crush and bale"

pieces: list[Trash] = [
    Aluminum(30.0), CrushedAluminum(20.0), Glass(10.0)]

exact: dict[type[Trash], list[Trash]] = defaultdict(list)
for t in pieces:
    exact[type(t)].append(t)
print(sorted(k.__name__ for k in exact))
#: ['Aluminum', 'CrushedAluminum', 'Glass']

crushed = CrushedAluminum(1.0)
print(f"{type(crushed).__name__}: "
      f"{recycling_note(crushed)}")
#: CrushedAluminum: crush and bale

shared: dict[type[Trash], list[Trash]] = defaultdict(list)
for t in pieces:
    shared[t.bin].append(t)
print(sorted(k.__name__ for k in shared))
#: ['Aluminum', 'Glass']

If you set cls.bin = cls in __init_subclass__() without checking cls.__dict__ first, the shared sort still prints three bins, ['Aluminum', 'CrushedAluminum', 'Glass']. __init_subclass__() runs after the class body, so the assignment overwrites the bin = Aluminum that CrushedAluminum declares.

Key the bins on the exact class. exact[type(t)] is a dictionary probe on the exact class, so CrushedAluminum is a new key and gets a bin of its own.

Resolve the note through the MRO. singledispatch resolves through the MRO. It walks from CrushedAluminum to Aluminum and uses Aluminum’s registration.

Both behaviors are deliberate, and neither is a fallback. The sorter must know the exact class that arrived. The note uses the nearest registration along the MRO.

Give each class a default bin. To share a parent’s bin without specifying a material in the loop, choose your own key instead of accepting type(t). A bin class variable supplies that key. __init_subclass__() sets each class’s bin to that class by default, so every material keeps a bin of its own.

Let a subclass share its parent’s bin. CrushedAluminum restates the ClassVar annotation the way the chapter’s subclasses restate value’s annotation. The type checker treats that restatement as optional.

Sort by the declared key. The sorting loop, shared[t.bin].append(t), takes each key from the piece, as the exact-class loop does.

type(t) is a convenient key, not an inevitable one. A design that declares its own key can group classes that type(t) keeps apart, as bin puts CrushedAluminum in the Aluminum bin.

Before you move on, name what separates your attempt from this one: a different design, a detail, or an equivalent alternative.

5. A base function that refuses to answer

Define Plastic, whose disposal hazard is toxic fumes, and leave it out of disposal_hazard.py’s registrations. What does hazard() answer for a piece of plastic? Then write strict_hazard(), whose base function raises NotImplementedError, and call it on the same piece. What does the strict form require for the materials whose hazard is “none”?

Where to look

One singledispatch Function per Operation builds hazard() with a base function that answers for any unregistered type. For strict_hazard(), make the base function raise NotImplementedError with a message naming the type, then register each material whose hazard you know, including those whose answer is "none". When you forget a registration, compare what goes wrong with a silent default and with a stopped program.

The shape
# The shape of exercise_5.py
from functools import singledispatch
from typing import ClassVar
from exceptions import expect
from record import record

@record
class Trash:
    weight: float
    value: ClassVar[float] = 0.0

class Aluminum(Trash):
    value: ClassVar[float] = 1.67

class Paper(Trash):
    value: ClassVar[float] = 0.10

class Plastic(Trash):
    value: ClassVar[float] = 0.15

@singledispatch
def hazard(t: Trash) -> str:
    ...

@hazard.register
def _(t: Aluminum) -> str:
    ...

@singledispatch
def strict_hazard(t: Trash) -> str:
    ...

@strict_hazard.register
def _(t: Aluminum) -> str:
    ...

@strict_hazard.register
def _(t: Paper) -> str:
    ...
Done when

hazard() returns none for a Plastic piece, and strict_hazard() on the same piece raises a NotImplementedError whose message names Plastic. Your strict_hazard() returns "none" for Paper and sharp edges for Aluminum, and you can say what your file needed for Paper that hazard() gets from its base function.

Solution

A solution runs in its own folder and cannot import the chapter’s files, so the listing reproduces the result of the edit: Plastic defined beside a copy of disposal_hazard.py’s hazard() reduced to its Aluminum registration.

# exercise_5.py
from functools import singledispatch
from typing import ClassVar
from exceptions import expect
from record import record

@record
class Trash:
    weight: float
    value: ClassVar[float] = 0.0

class Aluminum(Trash):
    value: ClassVar[float] = 1.67

class Paper(Trash):
    value: ClassVar[float] = 0.10

class Plastic(Trash):
    value: ClassVar[float] = 0.15

@singledispatch
def hazard(t: Trash) -> str:
    return "none"

@hazard.register
def _(t: Aluminum) -> str:
    return "sharp edges"

print(hazard(Plastic(1.0)))
#: none

@singledispatch
def strict_hazard(t: Trash) -> str:
    raise NotImplementedError(
        f"no hazard rule for {type(t).__name__}")

@strict_hazard.register
def _(t: Aluminum) -> str:
    return "sharp edges"

@strict_hazard.register
def _(t: Paper) -> str:
    return "none"

print(strict_hazard(Paper(1.0)))
#: none
expect(NotImplementedError, strict_hazard, Plastic(1.0))
#: [NotImplementedError] no hazard rule for Plastic

If you register Aluminum for strict_hazard() and skip Paper, strict_hazard(Paper(1.0)) raises a NotImplementedError reading no hazard rule for Paper. In that version the program stops before it reaches the plastic. The strict base function raises an exception for every unregistered type, including a harmless one like Paper, whose answer is "none".

Fall back to a default answer. hazard() returns "none" for the plastic, whose hazard is toxic fumes. That wrong answer looks like every correct "none" beside it. The forgotten registration passes both the run and the type checker.

Refuse an unregistered type. strict_hazard() raises a NotImplementedError that names the material at the first call.

Register every material. The strict form requires one registration for every material, including each one whose answer is "none". Paper needs three lines to return the "none" that hazard() gets from its base function.

Choose by which mistake does more harm. A default is right when it is a true answer for most types and a forgotten registration does little harm. A base function that raises an exception is right when a wrong answer is worse than a stopped program, as it is for a safety report.

Before you move on, name what separates your attempt from this one: a different design, a detail, or an equivalent alternative.

You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.