Every exercise that adds Lizard uses the same
rule: Lizard beats Paper and Scissors, and loses to
Rock. Lizard versus Lizard is a draw.
Lizard to the table versionAdd a fourth
Item,Lizard, topaper_scissors_rock_table.py. Lizard beats Paper and Scissors, and loses to Rock. Lizard versus Lizard is a draw. Add the seven new entries thatOUTCOMEneeds: both orders of each mixed pair, plus Lizard versus Lizard.
One
Lookup in a Table shows that OUTCOME is a
dictionary keyed on a pair of classes. Adding a class touches no
method, only the dictionary, so compete() stays as
it is. Write one entry for each ordered pair of
Lizard with another item, and one for
Lizard against Lizard.
# The shape of exercise_1.py
from enum import StrEnum
from typing import Final
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Item:
def compete(self, item: Item) -> Outcome:
...
def __str__(self) -> str:
...
class Paper(Item):
pass
class Scissors(Item):
pass
class Rock(Item):
pass
class Lizard(Item):
pass
type Table = dict[tuple[type[Item], type[Item]], Outcome]
OUTCOME: Final[Table] = {
(Paper, Rock): Outcome.WIN,
(Paper, Scissors): Outcome.LOSE,
(Paper, Paper): Outcome.DRAW,
(Paper, Lizard): Outcome.LOSE,
(Scissors, Paper): Outcome.WIN,
(Scissors, Rock): Outcome.LOSE,
(Scissors, Scissors): Outcome.DRAW,
(Scissors, Lizard): Outcome.LOSE,
(Rock, Scissors): Outcome.WIN,
(Rock, Paper): Outcome.LOSE,
(Rock, Rock): Outcome.DRAW,
(Rock, Lizard): Outcome.WIN,
(Lizard, Paper): Outcome.WIN,
(Lizard, Scissors): Outcome.WIN,
(Lizard, Rock): Outcome.LOSE,
(Lizard, Lizard): Outcome.DRAW,
}If you write only one order of a mixed pair, say
(Lizard, Paper) without
(Paper, Lizard), the demo still prints
win win, since neither of its duels needs the
missing row. Paper().compete(Lizard()) then raises
a KeyError, at the first duel that asks for that
order. The table holds one answer per ordered pair, so the
solution writes both orders of each mixed pair.
# exercise_1.py
from enum import StrEnum
from typing import Final
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Item:
def compete(self, item: Item) -> Outcome:
return OUTCOME[type(self), type(item)]
def __str__(self) -> str:
return type(self).__name__
class Paper(Item):
pass
class Scissors(Item):
pass
class Rock(Item):
pass
class Lizard(Item):
pass
type Table = dict[tuple[type[Item], type[Item]], Outcome]
OUTCOME: Final[Table] = {
(Paper, Rock): Outcome.WIN,
(Paper, Scissors): Outcome.LOSE,
(Paper, Paper): Outcome.DRAW,
(Paper, Lizard): Outcome.LOSE,
(Scissors, Paper): Outcome.WIN,
(Scissors, Rock): Outcome.LOSE,
(Scissors, Scissors): Outcome.DRAW,
(Scissors, Lizard): Outcome.LOSE,
(Rock, Scissors): Outcome.WIN,
(Rock, Paper): Outcome.LOSE,
(Rock, Rock): Outcome.DRAW,
(Rock, Lizard): Outcome.WIN,
(Lizard, Paper): Outcome.WIN,
(Lizard, Scissors): Outcome.WIN,
(Lizard, Rock): Outcome.LOSE,
(Lizard, Lizard): Outcome.DRAW,
}
if __name__ == "__main__":
print(Lizard().compete(Paper()),
Rock().compete(Lizard()))
#: win winAnswer every ordered pair. Sixteen entries
cover the four types against each other (4 × 4), the same shape
as the original nine (3 × 3). Adding a fourth Item
costs one class declaration and seven new dictionary rows (the
six new ordered pairs Lizard forms with the other
three, plus (Lizard, Lizard)).
compete() needs no change.
Guard the demonstration. The
__main__ guard lets exercise 3 import this module
without running its demonstration, as the chapter’s own two
versions do.
Lizard to the double-dispatch versionAdd the same
Lizardtopaper_scissors_rock.py, the double-dispatch version, which means adding aneval_lizard()method to every existing class, plus aLizardclass with its owncompete()and foureval_*()methods. Compare how much code this took versus addingLizardto the table version.
Two
Dispatches Through Methods shows compete()
calling an eval_*() method on the opponent, so each
class answers for one type. A new class needs its own
compete() and four eval_*() methods,
and every existing class needs an eval_lizard().
Count the methods you wrote, then compare with the dictionary
rows in the previous solution.
# The shape of exercise_2.py
from enum import StrEnum
from typing import Any
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Item:
def __str__(self) -> str:
...
class Paper(Item):
def compete(self, item: Any) -> Outcome:
...
def eval_paper(self, item: Any) -> Outcome:
...
def eval_scissors(self, item: Any) -> Outcome:
...
def eval_rock(self, item: Any) -> Outcome:
...
def eval_lizard(self, item: Any) -> Outcome:
...
class Scissors(Item):
def compete(self, item: Any) -> Outcome:
...
def eval_paper(self, item: Any) -> Outcome:
...
def eval_scissors(self, item: Any) -> Outcome:
...
def eval_rock(self, item: Any) -> Outcome:
...
def eval_lizard(self, item: Any) -> Outcome:
...
class Rock(Item):
def compete(self, item: Any) -> Outcome:
...
def eval_paper(self, item: Any) -> Outcome:
...
def eval_scissors(self, item: Any) -> Outcome:
...
def eval_rock(self, item: Any) -> Outcome:
...
def eval_lizard(self, item: Any) -> Outcome:
...
class Lizard(Item):
def compete(self, item: Any) -> Outcome:
...
def eval_paper(self, item: Any) -> Outcome:
...
def eval_scissors(self, item: Any) -> Outcome:
...
def eval_rock(self, item: Any) -> Outcome:
...
def eval_lizard(self, item: Any) -> Outcome:
...If you forget eval_lizard() on one existing
class, say Rock, the type checker reports nothing,
because every item parameter takes
Any. The demo then raises an
AttributeError at
Lizard().compete(Rock()), the first duel that calls
the missing method. Nothing before that duel reports the gap, so
the solution adds eval_lizard() to all three
existing classes.
# exercise_2.py
from enum import StrEnum
from typing import Any
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Item:
def __str__(self) -> str:
return type(self).__name__
class Paper(Item):
def compete(self, item: Any) -> Outcome:
return item.eval_paper(self)
def eval_paper(self, item: Any) -> Outcome:
return Outcome.DRAW
def eval_scissors(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_rock(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_lizard(self, item: Any) -> Outcome:
return Outcome.WIN
class Scissors(Item):
def compete(self, item: Any) -> Outcome:
return item.eval_scissors(self)
def eval_paper(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_scissors(self, item: Any) -> Outcome:
return Outcome.DRAW
def eval_rock(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_lizard(self, item: Any) -> Outcome:
return Outcome.WIN
class Rock(Item):
def compete(self, item: Any) -> Outcome:
return item.eval_rock(self)
def eval_paper(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_scissors(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_rock(self, item: Any) -> Outcome:
return Outcome.DRAW
def eval_lizard(self, item: Any) -> Outcome:
return Outcome.LOSE
class Lizard(Item):
def compete(self, item: Any) -> Outcome:
return item.eval_lizard(self)
def eval_paper(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_scissors(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_rock(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_lizard(self, item: Any) -> Outcome:
return Outcome.DRAW
if __name__ == "__main__":
print(Lizard().compete(Paper()),
Lizard().compete(Scissors()),
Lizard().compete(Rock()),
Lizard().compete(Lizard()))
#: win win lose drawRetrofit every existing class. This version
costs far more to extend. Every existing class
(Paper, Scissors, Rock)
needs a new eval_lizard() method.
Give the new class both dispatches. The new
Lizard class needs a compete() plus
four eval_*() methods, one per opponent type
including its own. Those methods encode the same sixteen answers
that the table version’s OUTCOME dictionary holds,
spread across four classes instead of collected in one place.
The two implementations agree on all sixteen combinations.
Guard the demonstration. The
__main__ guard serves exercise 3, as in exercise
1.
The table costs one class and seven dictionary rows to extend. The method version costs one class and five new methods, plus retrofitting a method onto every existing class. That cost grows with each item type you add. The chapter therefore recommends the table by default, and reserves the method version for behavior that belongs to the class: a combination that reads the object’s own state, or one a subclass should override while inheriting the rest.
EXPECTEDIn
test_paper_scissors.py, addLizard’s seven matchups toEXPECTED, taking it from nine entries to sixteen, and confirm both versions still agree with each other and withEXPECTED.
Testing
Both Versions keeps one EXPECTED mapping and
checks both implementations against it. Add the seven
Lizard rows to that mapping, keyed on the player’s
and opponent’s class names. A helper looks each class up by name
with getattr() on whichever module it receives, so
one mapping drives both versions and a disagreement shows up as
a failure.
# The shape of exercise_3.py
from types import ModuleType
from typing import Final
import exercise_1 as table
import exercise_2 as methods
from exercise_1 import Outcome
EXPECTED: Final[dict[tuple[str, str], Outcome]] = {
("Paper", "Rock"): Outcome.WIN,
("Paper", "Scissors"): Outcome.LOSE,
("Paper", "Paper"): Outcome.DRAW,
("Paper", "Lizard"): Outcome.LOSE,
("Scissors", "Paper"): Outcome.WIN,
("Scissors", "Rock"): Outcome.LOSE,
("Scissors", "Scissors"): Outcome.DRAW,
("Scissors", "Lizard"): Outcome.LOSE,
("Rock", "Scissors"): Outcome.WIN,
("Rock", "Paper"): Outcome.LOSE,
("Rock", "Rock"): Outcome.DRAW,
("Rock", "Lizard"): Outcome.WIN,
("Lizard", "Paper"): Outcome.WIN,
("Lizard", "Scissors"): Outcome.WIN,
("Lizard", "Rock"): Outcome.LOSE,
("Lizard", "Lizard"): Outcome.DRAW,
}
def compete(module: ModuleType, player: str,
opponent: str) -> str:
...If you add the seven rows to EXPECTED and leave
the test importing the chapter’s
paper_scissors_rock and
paper_scissors_rock_table, every
Lizard case fails with an
AttributeError: fourteen of the 32 cases, seven per
module. Neither chapter module defines a Lizard, so
getattr() finds no class by that name. The solution
points both imports at exercises 1 and 2 instead.
# exercise_3.py
from types import ModuleType
from typing import Final
import exercise_1 as table
import exercise_2 as methods
from exercise_1 import Outcome
EXPECTED: Final[dict[tuple[str, str], Outcome]] = {
("Paper", "Rock"): Outcome.WIN,
("Paper", "Scissors"): Outcome.LOSE,
("Paper", "Paper"): Outcome.DRAW,
("Paper", "Lizard"): Outcome.LOSE,
("Scissors", "Paper"): Outcome.WIN,
("Scissors", "Rock"): Outcome.LOSE,
("Scissors", "Scissors"): Outcome.DRAW,
("Scissors", "Lizard"): Outcome.LOSE,
("Rock", "Scissors"): Outcome.WIN,
("Rock", "Paper"): Outcome.LOSE,
("Rock", "Rock"): Outcome.DRAW,
("Rock", "Lizard"): Outcome.WIN,
("Lizard", "Paper"): Outcome.WIN,
("Lizard", "Scissors"): Outcome.WIN,
("Lizard", "Rock"): Outcome.LOSE,
("Lizard", "Lizard"): Outcome.DRAW,
}
def compete(module: ModuleType, player: str,
opponent: str) -> str:
return getattr(module, player)().compete(
getattr(module, opponent)())
for module in (table, methods):
wrong = [pair for pair, result in EXPECTED.items()
if compete(module, *pair) != result]
print(module.__name__, len(EXPECTED), "agree:",
not wrong)
#: exercise_1 16 agree: True
#: exercise_2 16 agree: TrueCheck both versions against one mapping. The
two modules are exercise 1’s table and exercise 2’s methods, the
two versions that know Lizard.
compete() is the test’s helper: it looks each class
up by name on whichever module it receives, so one
EXPECTED drives both sets of classes.
Compare across separate enumerations. Each
module defines its own Outcome, and the comparison
still works, since a StrEnum member equals its
string value.
In test_paper_scissors.py,
the sixteen-entry EXPECTED is the one change to the
test, once its two imports name modules that include
Lizard. test_matches_expected()
hardcodes no number of item types. pytest
parametrizes it from MATCHUPS, which a
comprehension builds from EXPECTED, so the test
reports sixteen cases per module where it reported nine.
In
arena.py, giveitem_pair_gen()an optionalcounts: Counter[str] | None = Noneparameter, and have it update that counter in place with a tally of every item type it chooses. It still yields(item1, item2)pairs, so existing calls need no change. The counter fills only as you consume the generator, so pass in your ownCounter, iterate over all 100 pairs fromitem_pair_gen(Item, 100, counts), and then print how many timesLizardappeared.
In Two
Dispatches Through Methods, item_pair_gen() is
a generator, so its body runs only as you iterate. When the
caller passes no Counter, create a fresh one so the
body has a single path. Increment the count for each item inside
the loop, before the yield, and the caller’s own
Counter fills as the loop consumes the pairs.
# The shape of exercise_4.py
import random
from collections import Counter
from collections.abc import Iterator
from enum import StrEnum
from typing import Any, Final
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Item:
def compete(self, item: Item) -> Outcome:
...
def __str__(self) -> str:
...
class Paper(Item):
pass
class Scissors(Item):
pass
class Rock(Item):
pass
class Lizard(Item):
pass
type Table = dict[tuple[type[Item], type[Item]], Outcome]
OUTCOME: Final[Table] = {
(Paper, Rock): Outcome.WIN,
(Paper, Scissors): Outcome.LOSE,
(Paper, Paper): Outcome.DRAW,
(Paper, Lizard): Outcome.LOSE,
(Scissors, Paper): Outcome.WIN,
(Scissors, Rock): Outcome.LOSE,
(Scissors, Scissors): Outcome.DRAW,
(Scissors, Lizard): Outcome.LOSE,
(Rock, Scissors): Outcome.WIN,
(Rock, Paper): Outcome.LOSE,
(Rock, Rock): Outcome.DRAW,
(Rock, Lizard): Outcome.WIN,
(Lizard, Paper): Outcome.WIN,
(Lizard, Scissors): Outcome.WIN,
(Lizard, Rock): Outcome.LOSE,
(Lizard, Lizard): Outcome.DRAW,
}
def duel(item1: Any, item2: Any) -> None:
...
def item_pair_gen[T](base: type[T], n: int,
counts: Counter[str] | None = None
) -> Iterator[tuple[T, T]]:
...If you print counts["Lizard"] right after
calling item_pair_gen(Item, 100, counts), without
iterating, it prints 0. Calling a generator
function runs none of its body, so the generator has chosen no
item yet, let alone counted one. The solution’s loop consumes
all 100 pairs before it reads the count.
# exercise_4.py
import random
from collections import Counter
from collections.abc import Iterator
from enum import StrEnum
from typing import Any, Final
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Item:
def compete(self, item: Item) -> Outcome:
return OUTCOME[type(self), type(item)]
def __str__(self) -> str:
return type(self).__name__
class Paper(Item):
pass
class Scissors(Item):
pass
class Rock(Item):
pass
class Lizard(Item):
pass
type Table = dict[tuple[type[Item], type[Item]], Outcome]
OUTCOME: Final[Table] = {
(Paper, Rock): Outcome.WIN,
(Paper, Scissors): Outcome.LOSE,
(Paper, Paper): Outcome.DRAW,
(Paper, Lizard): Outcome.LOSE,
(Scissors, Paper): Outcome.WIN,
(Scissors, Rock): Outcome.LOSE,
(Scissors, Scissors): Outcome.DRAW,
(Scissors, Lizard): Outcome.LOSE,
(Rock, Scissors): Outcome.WIN,
(Rock, Paper): Outcome.LOSE,
(Rock, Rock): Outcome.DRAW,
(Rock, Lizard): Outcome.WIN,
(Lizard, Paper): Outcome.WIN,
(Lizard, Scissors): Outcome.WIN,
(Lizard, Rock): Outcome.LOSE,
(Lizard, Lizard): Outcome.DRAW,
}
def duel(item1: Any, item2: Any) -> None:
print(f"{item1} <--> {item2} : {item1.compete(item2)}")
random.seed(47)
def item_pair_gen[T](base: type[T], n: int,
counts: Counter[str] | None = None
) -> Iterator[tuple[T, T]]:
if counts is None:
counts = Counter()
items = base.__subclasses__()
for _ in range(n):
a, b = (random.choice(items)(),
random.choice(items)())
counts[type(a).__name__] += 1
counts[type(b).__name__] += 1
yield a, b
counts: Counter[str] = Counter()
for item1, item2 in item_pair_gen(Item, 100, counts):
pass # duel(item1, item2) in the real version
print(counts["Lizard"])
#: 53Keep existing calls working.
counts is an optional parameter with a default of
None, so every existing call such as
item_pair_gen(Item, 10) still works as before,
unpacking a plain (item1, item2) pair each
time.
Tally into the caller’s counter. Only a
caller that wants the tally needs to pass its own
Counter in. The generator then updates that same
object in place on every pair it produces, one increment per
item, so the caller can read counts["Lizard"] at
any point during or after the loop, without
item_pair_gen() needing to change what it
yields.
__sub__()
and __rsub__() on MetersGive
Metersa__sub__()and a__rsub__().__sub__()handles aMeters, anint, or afloat, and returnsNotImplementedfor anything else.__rsub__()needs only theintandfloatcases, since Python skips the reflected form when both operands areMeters. Subtraction does not commute, so the reflected form must undo the swap. Check that10 - Meters(3)producesMeters(7)rather thanMeters(-7). Then confirm that"ten" - Meters(3)raises aTypeErrorrather than producing aMeters.
Operators
Dispatch Twice shows __add__() returning
NotImplemented so Python retries with
__radd__() on the other operand. Use the same
protocol for subtraction, with an isinstance()
check per accepted type and NotImplemented for the
rest. In __rsub__(), self is the
right-hand operand, so compute the other value minus
self.n to undo the swap.
# The shape of exercise_5.py
from exceptions import expected
from record import record
@record
class Meters:
n: float
def __sub__(self, other: object) -> Meters:
...
def __rsub__(self, other: object) -> Meters:
...# exercise_5.py
from exceptions import expected
from record import record
@record
class Meters:
n: float
def __sub__(self, other: object) -> Meters:
if isinstance(other, Meters):
return Meters(self.n - other.n)
if isinstance(other, int | float):
return Meters(self.n - other)
return NotImplemented
def __rsub__(self, other: object) -> Meters:
if isinstance(other, int | float):
# Not self.n - other
return Meters(other - self.n)
return NotImplemented
print(Meters(10) - Meters(3), Meters(10) - 3)
#: Meters(n=7) Meters(n=7)
print(10 - Meters(3))
#: Meters(n=7)
with expected(TypeError):
"ten" - Meters(3)
#: [TypeError] unsupported operand type(s) for -: 'str' and
#: 'Meters'Handle each accepted type.
__sub__() is __add__() with the sign
changed, and the three cases line up the same way: a
Meters, a number, or NotImplemented
for anything else. __rsub__() needs only the
numeric case, because Python asks the left operand first, and
Meters.__sub__() answers
Meters(10) - Meters(3). Python tries the reflected
form only when the left operand declines, and for two
Meters the left operand accepts.
Undo the swap. The swap is where subtraction
differs from addition. Python calls
Meters.__rsub__(Meters(3), 10) for the expression
10 - Meters(3), so self is the right
operand and other is the left one. The method must
put them back in the order the source wrote them.
Meters(other - self.n) gives
Meters(7). Writing
Meters(self.n - other), the same body
__sub__() uses, gives Meters(-7): a
correct-looking method that quietly returns the negative of
every reflected subtraction. __radd__() hides that
swap because addition commutes, so the mistake costs nothing
there and costs the wrong answer here.
Decline an unsupported operand.
"ten" - Meters(3) finds no
str.__sub__, so Python goes straight to
Meters.__rsub__, which returns
NotImplemented for a str. With both
sides declining, Python raises the TypeError, and
the message names both types. Returning
NotImplemented rather than raising an exception
makes that message possible: an exception raised inside
__rsub__() reports Meters’s complaint
instead of Python’s account of which pair of types has no
defined subtraction.
Subclass
PaperasOrigamiand duel it againstRockin the table version, asexact_match.pydoes. Explain theKeyErrorin terms of how the lookup matches. Then make the table match subclasses by walking both operands’__mro__for the first pair that has a row, and say what becomes of each of the two properties the lookup shares with the table-driven state machine: exact matching, and failure at the first duel that needs a missing pair.
One
Lookup in a Table shows exact_match.py, where the
key uses type() and a dictionary probe compares
keys by equality, so a subclass finds no row. Every class has an
__mro__ tuple that lists it and then its bases in
lookup order. Loop over one operand’s __mro__ and,
inside it, the other’s, skip classes that are not an
Item (such as object), and return the
first pair that has a row in OUTCOME.
# The shape of exercise_6.py
from enum import StrEnum
from typing import Final
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Item:
def compete(self, item: Item) -> Outcome:
...
def __str__(self) -> str:
...
class Paper(Item):
pass
class Rock(Item):
pass
type Table = dict[tuple[type[Item], type[Item]], Outcome]
OUTCOME: Final[Table] = {
(Paper, Rock): Outcome.WIN,
(Rock, Paper): Outcome.LOSE,
}
class Origami(Paper):
pass
class TolerantItem(Item):
def compete(self, item: Item) -> Outcome:
...
class TolerantPaper(TolerantItem):
pass
class TolerantRock(TolerantItem):
pass
class TolerantOrigami(TolerantPaper):
passIf you walk only the left operand’s MRO and key on
type(item) as it is,
TolerantOrigami().compete(TolerantRock()) still
prints win, but
TolerantRock().compete(TolerantOrigami()) raises a
KeyError for
(TolerantRock, TolerantOrigami). A subclass can sit
on either side of a duel, so the solution walks both MROs.
# exercise_6.py
from enum import StrEnum
from typing import Final
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Item:
def compete(self, item: Item) -> Outcome:
return OUTCOME[type(self), type(item)]
def __str__(self) -> str:
return type(self).__name__
class Paper(Item):
pass
class Rock(Item):
pass
type Table = dict[tuple[type[Item], type[Item]], Outcome]
OUTCOME: Final[Table] = {
(Paper, Rock): Outcome.WIN,
(Rock, Paper): Outcome.LOSE,
}
class Origami(Paper):
pass
try:
Origami().compete(Rock())
except KeyError as e:
print(type(e).__name__, [c.__name__ for c in e.args[0]])
#: KeyError ['Origami', 'Rock']
class TolerantItem(Item):
def compete(self, item: Item) -> Outcome:
for left in type(self).__mro__:
for right in type(item).__mro__:
if not (issubclass(left, Item)
and issubclass(right, Item)):
continue # object is not an Item
if (left, right) in OUTCOME:
return OUTCOME[left, right]
raise KeyError((type(self), type(item)))
class TolerantPaper(TolerantItem):
pass
class TolerantRock(TolerantItem):
pass
class TolerantOrigami(TolerantPaper):
pass
OUTCOME[TolerantPaper, TolerantRock] = Outcome.WIN
OUTCOME[TolerantRock, TolerantPaper] = Outcome.LOSE
print(TolerantOrigami().compete(TolerantRock()))
#: winReproduce the exact-match failure. The
KeyError comes from a dictionary probe, which
compares keys by equality. Origami inherits from
Paper but is not Paper, so
(Origami, Rock) is not (Paper, Rock).
Inheritance plays no part in the lookup: a dict
hashes the key and compares, and neither step consults an MRO.
That is what the chapter’s “matches classes exactly” means, and
exact matching is the property singledispatch does
not share.
Fall back to an ancestor’s row. The tolerant
version walks both MROs and takes the first pair that has a row,
so TolerantOrigami finds
(TolerantPaper, TolerantRock) one step up on the
left. That version gives up the property the chapter names
first: the match is no longer exact. Three consequences follow,
and only the first is obvious.
The lookup is no longer one probe. It is a nested loop over two MROs, so a miss now costs the product of the two depths instead of a single hash. For a table consulted once per duel, that cost vanishes into the noise. In an inner loop, it matters.
Order now decides the answer.
(TolerantPaper, TolerantRock) and
(TolerantOrigami, TolerantItem) could both match.
Which one wins depends on the order the loops happen to walk,
not on anything a reader of the table can see. The exact version
has no such question: either the pair is in the table or it is
not.
The tolerant version also loses the failure that makes the
exact version safe, though only for a subclass of a concrete
item. An Origami(Paper) whose rows you forgot to
write no longer raises a KeyError. It silently
inherits Paper’s answers and plays as paper. A
Lizard(Item) still fails fast, since no row has
Item in its key and the MRO walk finds nothing to
inherit. Tolerance lets you skip rows, and in exchange it drops
the fail-fast policy the chapter recommends for a table under
construction. It drops that policy where the table is most
likely to mistake a new type for an old one.
Which behavior you want depends on whether a subclass is a
new competitor or a variation on an existing one.
Origami really is paper for the purposes of this
game. A WetPaper that loses to everything is a new
competitor.
Create a business-modeling environment with three types of
Inhabitant:Dwarf(for engineers),Elf(for marketers), andTroll(for managers). Now create a class calledProjectthat creates the different inhabitants and causes them tointeract()with each other. Single dispatch is enough here. The next exercise adds the second dispatch.
Two
Dispatches Through Methods starts with one dispatch, a
method call that resolves its receiver’s type. Give
Inhabitant an interact() method and
override it in Dwarf, Elf, and
Troll. Project creates the inhabitants
with a seeded random.Random and calls
interact() on neighboring pairs.
# The shape of exercise_7.py
import random
from typing import Any
class Inhabitant:
def interact(self, other: Any) -> str:
...
def __str__(self) -> str:
...
class Dwarf(Inhabitant):
def interact(self, other: Any) -> str:
...
class Elf(Inhabitant):
def interact(self, other: Any) -> str:
...
class Troll(Inhabitant):
def interact(self, other: Any) -> str:
...
class Project:
def __init__(self, seed: int = 0) -> None:
...
def gather(self, n: int) -> list[Inhabitant]:
...
def meet(self, n: int) -> None:
...# exercise_7.py
import random
from typing import Any
class Inhabitant:
def interact(self, other: Any) -> str:
raise NotImplementedError
def __str__(self) -> str:
return self.__class__.__name__
class Dwarf(Inhabitant):
def interact(self, other: Any) -> str:
return f"{self} (engineer) negotiates with {other}"
class Elf(Inhabitant):
def interact(self, other: Any) -> str:
return f"{self} (marketer) pitches to {other}"
class Troll(Inhabitant):
def interact(self, other: Any) -> str:
return f"{self} (manager) directs {other}"
class Project:
def __init__(self, seed: int = 0) -> None:
self.rng = random.Random(seed)
def gather(self, n: int) -> list[Inhabitant]:
kinds = [Dwarf, Elf, Troll]
return [self.rng.choice(kinds)() for _ in range(n)]
def meet(self, n: int) -> None:
team = self.gather(n)
for a, b in zip(team, team[1:]):
print(a.interact(b))
Project(seed=1).meet(4)
#: Dwarf (engineer) negotiates with Troll
#: Troll (manager) directs Dwarf
#: Dwarf (engineer) negotiates with ElfResolve the receiver’s type. Exercise 7 uses
single dispatch, not double: a.interact(b) resolves
on a’s type only, and interact()
interpolates other without inspecting its type. The
design becomes double dispatch once interact()’s
behavior must vary by other’s type too, and
exercise 8 adds that dependence.
Stage the meetings. Project
creates the inhabitants in gather() and makes
neighbors interact in meet().
Modify the previous exercise’s
Projectto make the interactions more detailed. EachInhabitantcan randomly produce aWeaponusingget_weapon(): aDwarfusesJargonorPlay, anElfusesInventFeatureorSellImaginaryProduct, and aTrollusesEdictorSchedule. You must decide which weapons “win” and “lose” in each interaction (as inpaper_scissors_rock.py). Add abattle()method toProjectthat takes twoInhabitants and matches them against each other. Now create ameeting()method forProjectthat creates groups ofDwarf,Elf, andTrolland battles the groups against each other until only members of one group remain. These are the “winners.”
Two
Dispatches Through Methods shows the compete()
and eval_*() pair that resolves both types. Give
Weapon the same pair for six weapon classes, and
let each Inhabitant kind hold a
WEAPONS tuple from which get_weapon()
picks one at random. Rank the weapons around a cycle,
remembering that an even count leaves each weapon an opposite
that draws, then have battle() compare two weapons
and meeting() repeat battles until one group
remains.
# The shape of exercise_8.py
import random
from enum import StrEnum
from typing import Any, ClassVar, override
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Weapon:
def compete(self, item: Any) -> Outcome:
...
def __str__(self) -> str:
...
class Jargon(Weapon):
@override
def compete(self, item: Any) -> Outcome:
...
def eval_jargon(self, item: Any) -> Outcome:
...
def eval_play(self, item: Any) -> Outcome:
...
def eval_invent_feature(self, item: Any) -> Outcome:
...
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
...
def eval_edict(self, item: Any) -> Outcome:
...
def eval_schedule(self, item: Any) -> Outcome:
...
class Play(Weapon):
@override
def compete(self, item: Any) -> Outcome:
...
def eval_jargon(self, item: Any) -> Outcome:
...
def eval_play(self, item: Any) -> Outcome:
...
def eval_invent_feature(self, item: Any) -> Outcome:
...
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
...
def eval_edict(self, item: Any) -> Outcome:
...
def eval_schedule(self, item: Any) -> Outcome:
...
class InventFeature(Weapon):
@override
def compete(self, item: Any) -> Outcome:
...
def eval_jargon(self, item: Any) -> Outcome:
...
def eval_play(self, item: Any) -> Outcome:
...
def eval_invent_feature(self, item: Any) -> Outcome:
...
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
...
def eval_edict(self, item: Any) -> Outcome:
...
def eval_schedule(self, item: Any) -> Outcome:
...
class SellImaginaryProduct(Weapon):
@override
def compete(self, item: Any) -> Outcome:
...
def eval_jargon(self, item: Any) -> Outcome:
...
def eval_play(self, item: Any) -> Outcome:
...
def eval_invent_feature(self, item: Any) -> Outcome:
...
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
...
def eval_edict(self, item: Any) -> Outcome:
...
def eval_schedule(self, item: Any) -> Outcome:
...
class Edict(Weapon):
@override
def compete(self, item: Any) -> Outcome:
...
def eval_jargon(self, item: Any) -> Outcome:
...
def eval_play(self, item: Any) -> Outcome:
...
def eval_invent_feature(self, item: Any) -> Outcome:
...
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
...
def eval_edict(self, item: Any) -> Outcome:
...
def eval_schedule(self, item: Any) -> Outcome:
...
class Schedule(Weapon):
@override
def compete(self, item: Any) -> Outcome:
...
def eval_jargon(self, item: Any) -> Outcome:
...
def eval_play(self, item: Any) -> Outcome:
...
def eval_invent_feature(self, item: Any) -> Outcome:
...
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
...
def eval_edict(self, item: Any) -> Outcome:
...
def eval_schedule(self, item: Any) -> Outcome:
...
class Inhabitant2:
WEAPONS: ClassVar[tuple[type[Weapon], ...]]
def __init__(self, rng: random.Random) -> None:
...
def get_weapon(self) -> Weapon:
...
class Dwarf2(Inhabitant2):
WEAPONS = (Jargon, Play)
class Elf2(Inhabitant2):
WEAPONS = (InventFeature, SellImaginaryProduct)
class Troll2(Inhabitant2):
WEAPONS = (Edict, Schedule)
class Project2:
def __init__(self, seed: int = 0) -> None:
...
def battle(
self, a: Inhabitant2, b: Inhabitant2
) -> Inhabitant2 | None:
...
def meeting(self, group_size: int) -> str:
...If you leave compete() out of
Weapon, the program prints the same two lines,
since every weapon class defines its own. The type checker does
not accept the change: ty reports an
unresolved-attribute at battle()’s
call, because get_weapon() returns a
Weapon, and an
invalid-explicit-override at each of the six
@override decorators. The solution declares
compete() on the base, which gives that call and
every @override a method to name.
The listing gives each Inhabitant kind two of
six weapon types, ranked around a cycle: each weapon beats the
previous two in the ranking and loses to the next two. Six is an
even number, so each weapon has one opponent left over: its
opposite, three steps around the circle, and neither beats the
other. That pair draws. paper_scissors_rock.py
needs no such case because three items leave nothing over: with
an odd count every weapon beats half the rest and loses to the
other half. An even count always leaves the opposite pair to
define.
# exercise_8.py
import random
from enum import StrEnum
from typing import Any, ClassVar, override
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Weapon:
def compete(self, item: Any) -> Outcome:
raise NotImplementedError
def __str__(self) -> str:
return type(self).__name__
class Jargon(Weapon):
@override
def compete(self, item: Any) -> Outcome:
return item.eval_jargon(self)
def eval_jargon(self, item: Any) -> Outcome:
return Outcome.DRAW
def eval_play(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_invent_feature(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
return Outcome.DRAW
def eval_edict(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_schedule(self, item: Any) -> Outcome:
return Outcome.LOSE
class Play(Weapon):
@override
def compete(self, item: Any) -> Outcome:
return item.eval_play(self)
def eval_jargon(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_play(self, item: Any) -> Outcome:
return Outcome.DRAW
def eval_invent_feature(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
return Outcome.WIN
def eval_edict(self, item: Any) -> Outcome:
return Outcome.DRAW
def eval_schedule(self, item: Any) -> Outcome:
return Outcome.LOSE
class InventFeature(Weapon):
@override
def compete(self, item: Any) -> Outcome:
return item.eval_invent_feature(self)
def eval_jargon(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_play(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_invent_feature(self, item: Any) -> Outcome:
return Outcome.DRAW
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
return Outcome.WIN
def eval_edict(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_schedule(self, item: Any) -> Outcome:
return Outcome.DRAW
class SellImaginaryProduct(Weapon):
@override
def compete(self, item: Any) -> Outcome:
return item.eval_sell_imaginary_product(self)
def eval_jargon(self, item: Any) -> Outcome:
return Outcome.DRAW
def eval_play(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_invent_feature(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
return Outcome.DRAW
def eval_edict(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_schedule(self, item: Any) -> Outcome:
return Outcome.WIN
class Edict(Weapon):
@override
def compete(self, item: Any) -> Outcome:
return item.eval_edict(self)
def eval_jargon(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_play(self, item: Any) -> Outcome:
return Outcome.DRAW
def eval_invent_feature(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
return Outcome.LOSE
def eval_edict(self, item: Any) -> Outcome:
return Outcome.DRAW
def eval_schedule(self, item: Any) -> Outcome:
return Outcome.WIN
class Schedule(Weapon):
@override
def compete(self, item: Any) -> Outcome:
return item.eval_schedule(self)
def eval_jargon(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_play(self, item: Any) -> Outcome:
return Outcome.WIN
def eval_invent_feature(self, item: Any) -> Outcome:
return Outcome.DRAW
def eval_sell_imaginary_product(self,
item: Any) -> Outcome:
return Outcome.LOSE
def eval_edict(self, item: Any) -> Outcome:
return Outcome.LOSE
def eval_schedule(self, item: Any) -> Outcome:
return Outcome.DRAW
class Inhabitant2:
WEAPONS: ClassVar[tuple[type[Weapon], ...]]
def __init__(self, rng: random.Random) -> None:
self.rng = rng
def get_weapon(self) -> Weapon:
return self.rng.choice(self.WEAPONS)()
class Dwarf2(Inhabitant2):
WEAPONS = (Jargon, Play)
class Elf2(Inhabitant2):
WEAPONS = (InventFeature, SellImaginaryProduct)
class Troll2(Inhabitant2):
WEAPONS = (Edict, Schedule)
class Project2:
def __init__(self, seed: int = 0) -> None:
self.rng = random.Random(seed)
def battle(
self, a: Inhabitant2, b: Inhabitant2
) -> Inhabitant2 | None:
outcome = a.get_weapon().compete(b.get_weapon())
if outcome is Outcome.WIN:
return a
if outcome is Outcome.LOSE:
return b
return None # Draw: no winner this round
def meeting(self, group_size: int) -> str:
kinds = {"Dwarf": Dwarf2, "Elf": Elf2,
"Troll": Troll2}
groups = {
name: [cls(self.rng) for _ in range(group_size)]
for name, cls in kinds.items()}
while sum(1 for g in groups.values() if g) > 1:
names = [n for n, g in groups.items() if g]
for i in range(len(names)):
for j in range(i + 1, len(names)):
n1, n2 = names[i], names[j]
if not groups[n1] or not groups[n2]:
continue
winner = self.battle(
groups[n1][0], groups[n2][0])
if winner is groups[n1][0]:
groups[n2].pop(0)
elif winner is groups[n2][0]:
groups[n1].pop(0)
survivors = [n for n, g in groups.items() if g]
return survivors[0]
if __name__ == "__main__":
print(Play().compete(Jargon()),
Jargon().compete(Play()),
Jargon().compete(SellImaginaryProduct()))
print(Project2(seed=3).meeting(group_size=5))
#: win lose draw
#: TrollDeclare the first dispatch on the base.
Weapon declares compete() so that
battle() can call it on the Weapon
that get_weapon() returns. The
eval_*() methods stay undeclared, and their
item parameters take Any, as the
chapter’s do.
Answer for the original caller. As in paper_scissors_rock.py,
each eval_*() method answers for the caller its
name identifies, so Jargon.eval_play() returns
WIN because play beats jargon.
Resolve both weapon types.
battle() starts the two dispatches.
a.get_weapon().compete(...) resolves the first
weapon’s type, and that class’s compete() calls the
eval_*() method named for it on the second weapon,
and that second call resolves the second type.
Six weapons take 42 methods, a compete() and six
eval_*() methods in each class, and more than a
hundred lines hold 36 answers. That length is the cost Methods
or Table weighs, at four times the chapter’s nine answers.
The answers grow with the square of the number of weapons. A
seventh weapon would add an eval_*() method to each
of the six classes, plus a new class of eight methods. Exercise
10 collects the same 36 answers in one place.
The weapon ranking is a genuine cycle: nothing dominates
everything, so no group can count on winning. At the group level
the same cycle appears one level up. An Elf beats a
Dwarf on three of their four weapon pairings, a
Troll beats an Elf on three of four,
and a Dwarf beats a Troll on three of
four, with the fourth pairing in each case the draw. Over two
hundred seeds all three kinds win meeting(5), so
the outcome depends on the random draws each round, as it does
in a real rock-paper-scissors tournament.
The chapter claims that a table cell can hold a function, so even elaborate behavior fits the table. Build that version. In
paper_scissors_rock_table.py, give everyOUTCOMEcell aCallable[[Item, Item], Outcome]in place of itsOutcome, and havecompete()call the cell it finds:OUTCOME[type(self), type(item)](self, item). The call site staysitem1.compete(item2). Write a helper that wraps a constantOutcomein a callable, so the seven unchanged cells stay one line each. Then givePaperawetattribute and make the(Paper, Rock)and(Rock, Paper)cells read it. Dry paper wraps the rock and wins, wet paper is too soggy and draws, whichever of the two callscompete(). The chapter gives two reasons for preferring the double-dispatch version. Say which one this change answers, and which one survives it.
Methods
or Table says a table cell can hold a function, and One
Lookup in a Table shows the lookup. Store a callable in
every cell and call what the lookup returns with both items. A
helper returns a closure that ignores its arguments to wrap each
constant Outcome, and the
(Paper, Rock) and (Rock, Paper) cells
each read wet from the item in their own
position.
# The shape of exercise_9.py
from collections.abc import Callable
from enum import StrEnum
from typing import Final
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Item:
def compete(self, item: Item) -> Outcome:
...
def __str__(self) -> str:
...
class Paper(Item):
def __init__(self, wet: bool = False) -> None:
...
def __str__(self) -> str:
...
class Scissors(Item):
pass
class Rock(Item):
pass
type Cell = Callable[[Item, Item], Outcome]
def always(outcome: Outcome) -> Cell:
...
def paper_vs_rock(item1: Item, item2: Item) -> Outcome:
...
def rock_vs_paper(item1: Item, item2: Item) -> Outcome:
...
OUTCOME: Final[
dict[tuple[type[Item], type[Item]], Cell]] = {
(Paper, Rock): paper_vs_rock,
(Paper, Scissors): always(Outcome.LOSE),
(Paper, Paper): always(Outcome.DRAW),
(Scissors, Paper): always(Outcome.WIN),
(Scissors, Rock): always(Outcome.LOSE),
(Scissors, Scissors): always(Outcome.DRAW),
(Rock, Scissors): always(Outcome.WIN),
(Rock, Paper): rock_vs_paper,
(Rock, Rock): always(Outcome.DRAW),
}If you read wet in the two cells without their
isinstance() tests, the demo prints the same five
lines, because the lookup reaches each cell with a
Paper in the position it reads. The type checker
still rejects the access: ty reports an
unresolved-attribute in each cell, since a cell’s
parameters are Items and Item has no
wet. The solution keeps the tests so that the type
checker sees a Paper before the attribute
access.
# exercise_9.py
from collections.abc import Callable
from enum import StrEnum
from typing import Final
class Outcome(StrEnum):
WIN = "win"
LOSE = "lose"
DRAW = "draw"
class Item:
def compete(self, item: Item) -> Outcome:
return OUTCOME[type(self), type(item)](self, item)
def __str__(self) -> str:
return type(self).__name__
class Paper(Item):
def __init__(self, wet: bool = False) -> None:
self.wet = wet
def __str__(self) -> str:
return "WetPaper" if self.wet else "Paper"
class Scissors(Item):
pass
class Rock(Item):
pass
type Cell = Callable[[Item, Item], Outcome]
def always(outcome: Outcome) -> Cell:
return lambda item1, item2: outcome
def paper_vs_rock(item1: Item, item2: Item) -> Outcome:
if isinstance(item1, Paper) and item1.wet:
return Outcome.DRAW # Too soggy to wrap a rock
return Outcome.WIN
def rock_vs_paper(item1: Item, item2: Item) -> Outcome:
if isinstance(item2, Paper) and item2.wet:
return Outcome.DRAW # The same soggy draw
return Outcome.LOSE
OUTCOME: Final[
dict[tuple[type[Item], type[Item]], Cell]] = {
(Paper, Rock): paper_vs_rock,
(Paper, Scissors): always(Outcome.LOSE),
(Paper, Paper): always(Outcome.DRAW),
(Scissors, Paper): always(Outcome.WIN),
(Scissors, Rock): always(Outcome.LOSE),
(Scissors, Scissors): always(Outcome.DRAW),
(Rock, Scissors): always(Outcome.WIN),
(Rock, Paper): rock_vs_paper,
(Rock, Rock): always(Outcome.DRAW),
}
for item1, item2 in [
(Paper(), Rock()),
(Paper(wet=True), Rock()),
(Rock(), Paper(wet=True)),
(Scissors(), Paper()),
(Rock(), Rock()),
]:
print(f"{item1} <--> {item2} : {item1.compete(item2)}")
#: Paper <--> Rock : win
#: WetPaper <--> Rock : draw
#: Rock <--> WetPaper : draw
#: Scissors <--> Paper : win
#: Rock <--> Rock : drawCall what the lookup returns.
compete() changes by one pair of parentheses. It
still finds the cell with a single probe keyed on both types,
and now calls what it finds instead of returning it. The call
site shows none of this: item1.compete(item2) reads
as it does in paper_scissors_rock.py,
where four method definitions per class stand behind it. A table
of callables keeps the method-call syntax.
Wrap the constant answers.
always() keeps the table readable. It returns a
closure over one Outcome that ignores both
operands, so the seven combinations with a fixed answer stay one
line each and still read as a table of answers. Only the cells
that need code look like code.
Read the state in both orders. The
(Paper, Rock) cell receives both items, so it can
consult item1.wet. The (Rock, Paper)
cell consults item2.wet, because one duel has two
orders and each order has its own cell. If the
(Rock, Paper) cell ignored item2.wet,
a rock that calls compete() would still beat wet
paper. Behavior that reads the object’s own state is the first
of the two reasons the chapter gives for preferring the
double-dispatch version, and a cell holding a function answers
that reason. Whatever Paper.eval_rock() can read,
paper_vs_rock() can read too, from the same two
objects.
The second reason survives. A subclass still cannot override
one combination and inherit the rest, because the lookup still
matches types exactly: an Origami(Paper) finds no
row, callable or not, and the fix is to write
Origami’s rows rather than to override one
combination. Changing a cell changes it for every
Item, since OUTCOME is one shared
dictionary. paper_scissors_rock_subclass.py’s
DampPaper gets its exception by overriding
compete() and eval_rock(), and this
version has nothing to override: Item defines
compete() once.
Narrow the operand’s type. One cost comes
with the change. paper_vs_rock() and
rock_vs_paper() take two Items,
because every cell must, so each recovers Paper
with an isinstance() test. That is the type test
about which the chapter warns in the ladder version. Here the
test sits inside one cell rather than running through every
class, so you write it once instead of editing it for every new
Item.
Modify exercise 8 to use the table lookup technique of
paper_scissors_rock_table.py.
One
Lookup in a Table replaces the eval_*() methods
with one dictionary keyed on type(self) and
type(item). Keep get_weapon() and the
inhabitant classes from the previous solution, and shrink the
weapons to empty classes. Write the 36 answers as a grid whose
rows and columns follow one ORDER, and build
OUTCOME from it with a comprehension.
# The shape of exercise_10.py
import random
from typing import ClassVar, Final
import exercise_8 as methods
from exercise_8 import Outcome
class Weapon:
def compete(self, item: Weapon) -> Outcome:
...
def __str__(self) -> str:
...
class Jargon(Weapon):
pass
class Play(Weapon):
pass
class InventFeature(Weapon):
pass
class SellImaginaryProduct(Weapon):
pass
class Edict(Weapon):
pass
class Schedule(Weapon):
pass
type Table = dict[
tuple[type[Weapon], type[Weapon]], Outcome]
W: Final[Outcome] = Outcome.WIN
L: Final[Outcome] = Outcome.LOSE
D: Final[Outcome] = Outcome.DRAW
ORDER: Final[tuple[type[Weapon], ...]] = (
Jargon, Play, InventFeature,
SellImaginaryProduct, Edict, Schedule)
GRID: Final[tuple[tuple[Outcome, ...], ...]] = (
(D, L, L, D, W, W), # Jargon
(W, D, L, L, D, W), # Play
(W, W, D, L, L, D), # InventFeature
(D, W, W, D, L, L), # SellImaginaryProduct
(L, D, W, W, D, L), # Edict
(L, L, D, W, W, D), # Schedule
)
OUTCOME: Final[Table] = {
(a, b): cell
for a, row in zip(ORDER, GRID, strict=True)
for b, cell in zip(ORDER, row, strict=True)
}
class Inhabitant2:
WEAPONS: ClassVar[tuple[type[Weapon], ...]]
def __init__(self, rng: random.Random) -> None:
...
def get_weapon(self) -> Weapon:
...
class Dwarf2(Inhabitant2):
WEAPONS = (Jargon, Play)
class Elf2(Inhabitant2):
WEAPONS = (InventFeature, SellImaginaryProduct)
class Troll2(Inhabitant2):
WEAPONS = (Edict, Schedule)
class Project2:
def __init__(self, seed: int = 0) -> None:
...
def battle(
self, a: Inhabitant2, b: Inhabitant2
) -> Inhabitant2 | None:
...
def meeting(self, group_size: int) -> str:
...
def by_methods(a: type[Weapon],
b: type[Weapon]) -> Outcome:
...If you drop strict=True and a row of
GRID comes up one cell short, the comprehension
builds 35 entries without complaint. The check then prints
35 pairs agree with exercise 8: True, since it
plays only the pairs the table holds, and the seeded meeting
raises a KeyError for the missing
(Jargon, Schedule). The solution passes
strict=True to both zip() calls, so
the short row raises a ValueError when the module
loads, before any duel.
# exercise_10.py
import random
from typing import ClassVar, Final
import exercise_8 as methods
from exercise_8 import Outcome
class Weapon:
def compete(self, item: Weapon) -> Outcome:
return OUTCOME[type(self), type(item)]
def __str__(self) -> str:
return type(self).__name__
class Jargon(Weapon):
pass
class Play(Weapon):
pass
class InventFeature(Weapon):
pass
class SellImaginaryProduct(Weapon):
pass
class Edict(Weapon):
pass
class Schedule(Weapon):
pass
type Table = dict[
tuple[type[Weapon], type[Weapon]], Outcome]
W: Final[Outcome] = Outcome.WIN
L: Final[Outcome] = Outcome.LOSE
D: Final[Outcome] = Outcome.DRAW
ORDER: Final[tuple[type[Weapon], ...]] = (
Jargon, Play, InventFeature,
SellImaginaryProduct, Edict, Schedule)
GRID: Final[tuple[tuple[Outcome, ...], ...]] = (
(D, L, L, D, W, W), # Jargon
(W, D, L, L, D, W), # Play
(W, W, D, L, L, D), # InventFeature
(D, W, W, D, L, L), # SellImaginaryProduct
(L, D, W, W, D, L), # Edict
(L, L, D, W, W, D), # Schedule
)
OUTCOME: Final[Table] = {
(a, b): cell
for a, row in zip(ORDER, GRID, strict=True)
for b, cell in zip(ORDER, row, strict=True)
}
class Inhabitant2:
WEAPONS: ClassVar[tuple[type[Weapon], ...]]
def __init__(self, rng: random.Random) -> None:
self.rng = rng
def get_weapon(self) -> Weapon:
return self.rng.choice(self.WEAPONS)()
class Dwarf2(Inhabitant2):
WEAPONS = (Jargon, Play)
class Elf2(Inhabitant2):
WEAPONS = (InventFeature, SellImaginaryProduct)
class Troll2(Inhabitant2):
WEAPONS = (Edict, Schedule)
class Project2:
def __init__(self, seed: int = 0) -> None:
self.rng = random.Random(seed)
def battle(
self, a: Inhabitant2, b: Inhabitant2
) -> Inhabitant2 | None:
outcome = a.get_weapon().compete(b.get_weapon())
if outcome is Outcome.WIN:
return a
if outcome is Outcome.LOSE:
return b
return None # Draw: no winner this round
def meeting(self, group_size: int) -> str:
kinds = {"Dwarf": Dwarf2, "Elf": Elf2,
"Troll": Troll2}
groups = {
name: [cls(self.rng) for _ in range(group_size)]
for name, cls in kinds.items()}
while sum(1 for g in groups.values() if g) > 1:
names = [n for n, g in groups.items() if g]
for i in range(len(names)):
for j in range(i + 1, len(names)):
n1, n2 = names[i], names[j]
if not groups[n1] or not groups[n2]:
continue
winner = self.battle(
groups[n1][0], groups[n2][0])
if winner is groups[n1][0]:
groups[n2].pop(0)
elif winner is groups[n2][0]:
groups[n1].pop(0)
survivors = [n for n, g in groups.items() if g]
return survivors[0]
def by_methods(a: type[Weapon],
b: type[Weapon]) -> Outcome:
return getattr(methods, a.__name__)().compete(
getattr(methods, b.__name__)())
wrong = [pair for pair, result in OUTCOME.items()
if by_methods(*pair) != result]
print(len(OUTCOME), "pairs agree with exercise 8:",
not wrong)
#: 36 pairs agree with exercise 8: True
print(Project2(seed=3).meeting(group_size=5))
#: TrollLook up both types at once. The weapons
shrink to six empty classes, and Weapon.compete()
looks up both types in one probe, as in paper_scissors_rock_table.py.
Lay the answers out as a grid. The listing
writes the 36 answers by hand, as a grid rather than as 36
dictionary rows. Each row of GRID holds one
weapon’s results as the caller, against the weapons in
ORDER, and the comprehension turns the grid into
the (caller, opponent) keys that
compete() looks up. A cell holds what one of
exercise 8’s eval_*() methods returns: row
Jargon, column Play, holds the
LOSE that Play.eval_jargon()
returns.
Reuse the game around the table.
Troll2, battle(), and
meeting() repeat exercise 8’s code, so the seeded
meeting draws the same weapons and Troll wins
again.
Check against the method version.
by_methods() plays each pair through exercise 8’s
classes, finding them by name as exercise 3 does, and all 36
answers agree.
The grid holds in six lines the answers that exercise 8
spreads across 42 methods. A seventh weapon adds an empty class,
a row, and a column, where exercise 8 needs a method in every
existing class and an eight-method class. Methods
or Table reaches the same conclusion: for a ruleset that is
a fixed set of answers, the table is shorter and easier to
maintain. Exercise 8’s version keeps one advantage. Its
eval_*() methods receive the competing objects, so
a weapon whose result depends on its own state fits there, while
this grid would need exercise 9’s callable cells.