Contents
Chapter 32

Multiple Dispatching

Two objects meet, and the right action depends on the type of each. Multiple Dispatching chooses the action from both types at once.

Code that combines several interacting types can end up testing a type by hand for every combination. Consider a system that parses and executes mathematical expressions. You want to say Number + Number, Number * Number, etc., where Number is the base class for a family of numerical objects. But when you say a + b, and you don’t know the exact type of either a or b, how can you get them to interact properly?

The answer starts with a fact about the language that rarely comes up. Python dispatches on one type at a time. When two objects of unknown type interact, a method call resolves its receiver’s type alone. You end up testing the other type by hand, writing out the dispatch the language performed for the receiver.

That dispatch is one form of polymorphism, which broadly means that a function accepts arguments of more than one type. Polymorphism takes three forms. Function overloading in C++ picks a function from the argument types. Generics write one body that works across many types. This chapter uses the third form, the runtime dispatch that inheritance provides. Runtime dispatch resolves on the type of one object, the one receiving the method call. That is why one method call can resolve only one unknown type.

The solution is Multiple Dispatching. To dispatch on two unknown types, you need two method calls. The first resolves the first type, and the second resolves the second. Two unknown types means two dispatches, which is double dispatching. In paper_scissors_rock.py, both interacting objects come from a single hierarchy, and the two dispatches go through methods named compete() and eval_*(). If two different type hierarchies interact, you need a dispatching method call for each hierarchy.

When the interaction is an operator, Python performs this second dispatch for you. a + b retries as b.__radd__(a) if a.__add__(b) declines. That retry is how the opening Number + Number question resolves. The rest of this chapter builds the general technique for an interaction that is not an operator, with a game of paper, scissors, rock as the working example.

The two versions of the game below, one dispatching through methods and one looking up a table, share one result type, an enumeration called Outcome: either WIN, LOSE, or DRAW. Outcome is a StrEnum, so each member is its string value and prints as win, lose, or draw:

# outcome.py
from enum import StrEnum

class Outcome(StrEnum):
    WIN = "win"
    LOSE = "lose"
    DRAW = "draw"

You also need two small helper functions, one to generate random pairs of items, and one to play a pair off and print the result:

# arena.py
import random
from collections.abc import Iterator
from typing import Any

def item_pair_gen[T](base: type[T],
                     n: int) -> Iterator[tuple[T, T]]:
    items = base.__subclasses__()
    for _ in range(n):
        yield random.choice(items)(), random.choice(items)()

def duel(item1: Any, item2: Any) -> None:
    print(f"{item1} <--> {item2} : {item1.compete(item2)}")

item_pair_gen() is generic over whichever base class it receives. __subclasses__() lists that base’s direct subclasses, as shape_name() does. duel() annotates both parameters Any because the two versions below define separate Item hierarchies, and this file must serve both.

Two Dispatches Through Methods

# paper_scissors_rock.py
import random
from typing import Any
from arena import duel, item_pair_gen
from outcome import Outcome

class Item:
    def __str__(self) -> str:
        return type(self).__name__

class Paper(Item):
    def compete(self, item: Any) -> Outcome:
        # First dispatch: self was Paper
        return item.eval_paper(self)
    def eval_paper(self, item: Any) -> Outcome:
        # Second dispatch: caller was Paper, and it draws
        return Outcome.DRAW
    def eval_scissors(self, item: Any) -> Outcome:
        # Second dispatch: caller was Scissors, and it wins
        return Outcome.WIN
    def eval_rock(self, item: Any) -> Outcome:
        # Second dispatch: caller was Rock, and it loses
        return Outcome.LOSE

class Scissors(Item):
    def compete(self, item: Any) -> Outcome:
        # First dispatch: self was Scissors
        return item.eval_scissors(self)
    def eval_paper(self, item: Any) -> Outcome:
        # Second dispatch: caller was Paper, and it loses
        return Outcome.LOSE
    def eval_scissors(self, item: Any) -> Outcome:
        # Second dispatch: caller was Scissors, and it draws
        return Outcome.DRAW
    def eval_rock(self, item: Any) -> Outcome:
        # Second dispatch: caller was Rock, and it wins
        return Outcome.WIN

class Rock(Item):
    def compete(self, item: Any) -> Outcome:
        # First dispatch: self was Rock
        return item.eval_rock(self)
    def eval_paper(self, item: Any) -> Outcome:
        # Second dispatch: caller was Paper, and it wins
        return Outcome.WIN
    def eval_scissors(self, item: Any) -> Outcome:
        # Second dispatch: caller was Scissors, and it loses
        return Outcome.LOSE
    def eval_rock(self, item: Any) -> Outcome:
        # Second dispatch: caller was Rock, and it draws
        return Outcome.DRAW

if __name__ == "__main__":
    random.seed(47)  # Reproducible pairs
    for item1, item2 in item_pair_gen(Item, 10):
        duel(item1, item2)
#: Scissors <--> Paper : win
#: Scissors <--> Rock : lose
#: Scissors <--> Rock : lose
#: Scissors <--> Scissors : draw
#: Rock <--> Scissors : win
#: Scissors <--> Rock : lose
#: Paper <--> Scissors : lose
#: Rock <--> Paper : lose
#: Paper <--> Paper : draw
#: Scissors <--> Scissors : draw
One duel’s two calls: scissors.compete(paper) knows only Scissors, so it calls paper.eval_scissors(scissors) with self and item swapped, which knows both types and returns WIN, the result for scissors

Follow one duel to see which competitor each result describes. scissors.compete(paper) resolves self to Scissors, the first dispatch, and calls paper.eval_scissors(...). That call is the second dispatch. It resolves paper and runs Paper.eval_scissors(), the one method that knows both types. Its class is Paper and its name says Scissors. Which competitor does that method’s result describe? Paper.eval_scissors() returns WIN. Scissors cut paper, so that is the outcome for the scissors that started the duel, not for the Paper whose code is running. The listing’s first line of output, Scissors <--> Paper : win, reports it that way. Every eval_*() method answers for the original caller, the type named in the method’s own name. If you read that convention the other way, every eval_*() result appears reversed.

That original caller arrives in each eval_*() method as its item argument: the same object compete() held as self before passing it along. A game whose outcome depends on the caller’s state reads that state through item. This game ignores the argument, since the outcome depends on the two types alone.

Those Any annotations turn off static checking. Item declares __str__() alone, so the parameter takes Any unless something declares all four methods: Item, as abstract methods, or a Protocol. With Any, classes that supply eight of the nine answers pass the type checker, and Python raises an AttributeError at the first duel that calls the ninth. Declaring the four methods restores the checking, and that declaration repeats every class’s method names.

The table version keeps the checking and declares each name once. Its answers are rows in one dictionary, so a class is complete the moment it inherits compete(). Its Item declares the one method the dispatch needs, compete(), so the opponent parameter takes Item.

One Lookup in a Table

In paper_scissors_rock.py, each Item type encodes the answers for its own combinations. Together they form a table spread across the classes. That table is hard to maintain if you expect to modify the behavior or to add a new Item class. Making the table explicit can be more sensible, like this:

# paper_scissors_rock_table.py
import random
from typing import Final
from arena import duel, item_pair_gen
from outcome import Outcome

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

type Table = dict[tuple[type[Item], type[Item]], Outcome]

OUTCOME: Final[Table] = {
    (Paper, Rock): Outcome.WIN,
    (Paper, Scissors): Outcome.LOSE,
    (Paper, Paper): Outcome.DRAW,
    (Scissors, Paper): Outcome.WIN,
    (Scissors, Rock): Outcome.LOSE,
    (Scissors, Scissors): Outcome.DRAW,
    (Rock, Scissors): Outcome.WIN,
    (Rock, Paper): Outcome.LOSE,
    (Rock, Rock): Outcome.DRAW,
}

if __name__ == "__main__":
    random.seed(47)  # Reproducible pairs
    for item1, item2 in item_pair_gen(Item, 10):
        duel(item1, item2)
#: Scissors <--> Paper : win
#: Scissors <--> Rock : lose
#: Scissors <--> Rock : lose
#: Scissors <--> Scissors : draw
#: Rock <--> Scissors : win
#: Scissors <--> Rock : lose
#: Paper <--> Scissors : lose
#: Rock <--> Paper : lose
#: Paper <--> Paper : draw
#: Scissors <--> Scissors : draw

Dictionary keys are flexible. A tuple works as a key, the same as a single object.

Both versions now hold the same nine answers, one for each pair of types:

The nine paper, scissors, rock answers drawn as a grid twice: the method version spreads them across the classes and picks a cell with two dispatches, the table version keeps them in OUTCOME and picks the same cell with one lookup

With the nine answers drawn as a grid, the versions differ in where each one keeps the grid. The method version cuts it into columns and gives one to each class, so finding a cell takes two dispatches. The table version keeps the grid whole in OUTCOME, and one lookup finds the same cell.

The dashed fourth item shows what growth costs each version. A new class forces a new eval_*() method into every existing class, while the table takes new rows and leaves the classes untouched.

The lookup shares two properties with the table-driven state machine. It matches classes exactly, so a subclass of Paper needs rows of its own. And a missing pair fails at the first duel that needs it, the fail-fast policy that suits a table you are still filling in (see exercise 1). Here the failure is the dictionary’s own KeyError, where the state machine’s engine raises NoTransition.

Exact matching surprises people. The lookup raises a KeyError for a subclass. Origami derives from Paper and inherits its compete(), but the table has no row for it:

# exact_match.py
from paper_scissors_rock_table import OUTCOME, Paper, Rock

class Origami(Paper):
    pass

print(OUTCOME[Paper, Rock])
#: win
try:
    Origami().compete(Rock())
except KeyError as e:
    missing = e.args[0]  # The tuple key that was not found
    print(type(e).__name__, [c.__name__ for c in missing])
#: KeyError ['Origami', 'Rock']

A dictionary probe compares keys by equality, so Origami is not Paper however closely the two relate. The lookup ignores the MRO.

One Type or Many

For dispatch on one argument’s type, functools.singledispatch gives you open, per-type functions, as Visitor shows. For dispatch on two or more types at once, the OUTCOME table is the idiomatic answer: a dict keyed by a tuple of types. Adding a new Item then means adding rows to the table, with no methods to edit across the classes.

singledispatch and the table match types differently. singledispatch resolves through the MRO, so a function registered for a base class serves every subclass, while the table matches the class exactly. Swapping one for the other changes which pairings the code covers, not just how many types it considers.

match with Class Patterns

A match statement with class patterns is a third option for a two-type decision. Like singledispatch, it matches subclasses. A class pattern tests with isinstance(), so a subclass matches the pattern its base would:

# match_dispatch.py
from outcome import Outcome
from paper_scissors_rock_table import Paper, Rock

class Origami(Paper):
    pass

def compete(a: object, b: object) -> Outcome:
    match a, b:
        case Paper(), Rock():
            return Outcome.WIN
        case Rock(), Paper():
            return Outcome.LOSE
        case _:
            raise ValueError(f"{a}, {b}")

print(compete(Origami(), Rock()))
#: win

Origami() matches the Paper() pattern, though the table lookup in exact_match.py raises a KeyError for the same subclass. Unlike singledispatch, every case sits together in one block, closed the way the table is. Adding an Item means adding cases, not registering a function elsewhere.

The singledispatchmethod Trap

functools.singledispatchmethod combines the two dispatches in one decorator. It dispatches once on self through ordinary method resolution, then again on its first argument through singledispatch. That is the pair of dispatches the eval_*() family writes out by hand. Like singledispatch, it matches on the MRO rather than exactly.

One mistake raises no error and prints a plausible answer. A @singledispatchmethod declared on a shared base gives every subclass one dispatcher, and the resolution on self then reaches that same dispatcher for every subclass, so each class needs its own @singledispatchmethod. Here is the mistake:

# singledispatch_trap.py
from functools import singledispatchmethod

class Item:
    @singledispatchmethod
    def compete(self, item: object) -> str:
        raise NotImplementedError

class Paper(Item):
    pass

class Rock(Item):
    pass

@Paper.compete.register  # type: ignore  # [1]
def _(self: Item, item: Rock) -> str:
    return "paper wins"

@Rock.compete.register  # type: ignore  # [2]
def _(self: Item, item: Rock) -> str:
    return "rock draws"

print(Paper().compete(Rock()))
#: rock draws
print(Rock().compete(Rock()))
#: rock draws

Both registrations attach to Item.compete, the attribute Paper and Rock both inherit, so the @register at [2] silently overwrites the entry [1] made for Rock. The singledispatch lookup ignores self’s type, so both duels return the same answer, although each registration went through its own class.

Methods or Table

The version most programmers write first is neither the methods nor the table. It is an isinstance() ladder inside compete(), testing the opponent’s type case by case. It works, and it keeps the method version’s cost without its benefit. The type tests repeat in every class, as the eval_*() methods do in the method version, and the programmer resolves by hand what dispatch would resolve on its own. Every new Item forces an edit to every ladder. Both patterns in this chapter replace the ladder.

The double-dispatch version puts eval_paper(), eval_scissors(), and eval_rock() on every class. It comes from languages where a table keyed by a pair of types is awkward to write. There, spreading the table across the classes is the easier form. A Python dict takes a tuple of classes as a key, so the table is both shorter and easier to maintain. A table cell can hold a function, so even elaborate behavior fits the table (see exercise 9).

Use the double-dispatch version when the behavior for a combination belongs to the class rather than to the pairing: when it reads the object’s own state, or when a subclass should be able to override one combination and inherit the rest. A combination has two sides, one for each order of the duel, so the subclass overrides compete() for its own side and one eval_*() method for the other:

# paper_scissors_rock_subclass.py
from typing import Any
from outcome import Outcome
from paper_scissors_rock import Paper, Rock, Scissors

class DampPaper(Paper):
    def compete(self, item: Any) -> Outcome:
        if isinstance(item, Rock):
            return Outcome.DRAW  # Too soggy to wrap
        return super().compete(item)
    def eval_rock(self, item: Any) -> Outcome:
        return Outcome.DRAW  # Rock's side of the same draw

print(DampPaper().compete(Rock()))
#: draw
print(Rock().compete(DampPaper()))
#: draw
print(DampPaper().compete(Scissors()))
#: lose

DampPaper’s own result against Rock comes from Rock.eval_paper(), a method DampPaper cannot change, so its compete() answers before making that call. Rock’s result against a DampPaper comes from DampPaper.eval_rock(), an ordinary override. Both overrides are necessary. Without eval_rock(), the inherited Paper.eval_rock() still reports that the rock loses, and the two orders of one duel disagree. Every other combination comes from Paper unchanged, through super().compete(item) and the inherited eval_*() methods.

The table version has no comparable override. The lookup matches classes exactly, so DampPaper inherits none of Paper’s rows. It needs seven rows of its own, five of them copies of Paper’s, and editing Paper’s rows instead changes the game for every Paper.

Testing Both Versions

The win/lose/draw result is pure logic, so tests validate it easily. The spread-out method version and the table version must return the same Outcome for every one of the nine combinations. If they diverge, one of them has a bug.

# test_paper_scissors.py
from types import ModuleType
from typing import Final
import paper_scissors_rock as methods
import paper_scissors_rock_table as table
import pytest
from outcome import Outcome

# (player, opponent): the player's result
EXPECTED: Final[dict[tuple[str, str], Outcome]] = {
    ("Paper", "Rock"): Outcome.WIN,
    ("Paper", "Scissors"): Outcome.LOSE,
    ("Paper", "Paper"): Outcome.DRAW,
    ("Scissors", "Paper"): Outcome.WIN,
    ("Scissors", "Rock"): Outcome.LOSE,
    ("Scissors", "Scissors"): Outcome.DRAW,
    ("Rock", "Scissors"): Outcome.WIN,
    ("Rock", "Paper"): Outcome.LOSE,
    ("Rock", "Rock"): Outcome.DRAW,
}

def compete(module: ModuleType, player: str,
            opponent: str) -> Outcome:
    result: Outcome = getattr(module, player)().compete(
        getattr(module, opponent)())
    assert isinstance(result, Outcome)
    return result

MATCHUPS: Final[list[tuple[str, str, Outcome]]] = [
    (p, o, r) for (p, o), r in EXPECTED.items()
]

@pytest.mark.parametrize("module", [table, methods])
@pytest.mark.parametrize(
    "player, opponent, expected", MATCHUPS)
def test_versions_match_expected(module: ModuleType,
                                 player: str, opponent: str,
                                 expected: Outcome) -> None:
    assert compete(module, player, opponent) == expected

test_paper_scissors.py imports the two modules, not their classes. getattr(module, player) looks the class up on whichever module pytest passed to test_versions_match_expected(), so one dictionary of nine expected answers, EXPECTED, drives two independent sets of Paper, Scissors, and Rock classes. Each module guards its demonstration loop with if __name__ == "__main__", so running a module as a script executes the loop, and importing a module for the test defines its classes and runs no duels.

compete() also checks the result’s type, since a StrEnum member equals its string, and a version that returned the bare string "win" would pass the == alone.

Operators Dispatch Twice

Python’s own operators dispatch twice, which answers the Number + Number question that opens this chapter. a + b first tries type(a).__add__(a, b). If that returns the special value NotImplemented, Python then tries type(b).__radd__(b, a), the reflected form of __add__(). NotImplemented is a sentinel value, not the lookalike NotImplementedError exception, and returning it hands the operation to the other operand for the interpreter to try next. The first call dispatches on a’s type, the fallback on b’s. That is double dispatching, built into the language. The fallback is how a type written decades after int can add itself to an int on the left.

Every binary arithmetic and bitwise operator has a reflected form, named by inserting an r before the operator’s name: __rsub__(), __rmul__(), __rtruediv__(). The in-place forms, __iadd__() and its siblings, are a separate family. They serve +=, not a + b. Here is the machinery, with each dispatch traced:

# radd_dispatch.py
from exceptions import expected
from record import record

@record
class Meters:
    n: float

    def __add__(self, other: object) -> Meters:
        print(f"__add__({self!r}, {other!r})")
        if isinstance(other, Meters):
            return Meters(self.n + other.n)
        if isinstance(other, int | float):
            return Meters(self.n + other)
        return NotImplemented

    def __radd__(self, other: object) -> Meters:
        print(f"__radd__({self!r}, {other!r})")
        if isinstance(other, int | float):
            return Meters(other + self.n)
        return NotImplemented

print(Meters(3) + Meters(4))
#: __add__(Meters(n=3), Meters(n=4))
#: Meters(n=7)
print(Meters(3) + 4)  # The left operand handles it
#: __add__(Meters(n=3), 4)
#: Meters(n=7)
# int.__add__() declines; Meters.__radd__() handles it
print(4 + Meters(3))
#: __radd__(Meters(n=3), 4)
#: Meters(n=7)
with expected(TypeError):
    Meters(3) + "four"  # Both sides decline
#: __add__(Meters(n=3), 'four')
#: [TypeError] unsupported operand type(s) for +: 'Meters'
#: and 'str'

The first two additions resolve inside __add__(). One of the left operand’s isinstance() tests matches the right operand. 4 + Meters(3) calls int.__add__() first, which returns NotImplemented for a Meters operand. The sentinel is a decline rather than an error, so Python tries Meters.__radd__() next, whose trace line shows the operands in swapped order. The last case shows why the sentinel exists. Meters.__add__() runs and declines the string, and str defines no __radd__(). Python raises the TypeError once both sides have declined.

Raising a TypeError inside __add__() ends the expression there, since the exception propagates immediately. Only a returned sentinel makes Python try the right operand’s __radd__().

When both operands have the same type, Python tries __add__() alone, so __add__() resolves Meters + Meters. Adding two instances of a class that implements __radd__() alone raises a TypeError. One case reverses the order. When the right operand’s type is a subclass of the left’s and overrides the reflected method, Python tries that reflected method first, so the subclass’s method runs before the base’s.

Both methods declare -> Meters although each can return NotImplemented. That is the standard convention rather than a shortcut. Typeshed annotates timedelta.__add__() as returning timedelta, not a union. It can do that because it gives NotImplemented a type that inherits from Any. Returning the sentinel then satisfies any declared return type. Writing the union out, Meters | NotImplementedType, makes ty reject (Meters(1) + Meters(2)).n, since the sentinel branch has no n. Pyright and mypy accept the access, because that inheritance from Any makes any attribute access on the sentinel branch type-check. The sentinel is a signal to the interpreter, and no + expression evaluates to it, so an annotation that names it describes the wrong thing. Widening the return to Any describes nothing and turns off checking for every caller.

Composite and Interpreter builds the expression system that opens this chapter, using __add__() and __radd__() to let Python’s own parser assemble the tree.

Turning One Unknown Type Into a Second Dispatch

Three techniques in this chapter answer the same question: what do you do with a type the first dispatch could not resolve? The eval_*() family and __add__() with __radd__() answer it with a second dispatch, a second method call that resolves the type by running a method lookup. The OUTCOME table answers it differently. Item defines compete() once and every subclass inherits it, so OUTCOME[type(self), type(item)] is one dictionary lookup keyed on both types at once, not a second method resolution. The methods perform the second dispatch through a second method call that you write, and distribute the answers across the classes. The table replaces both dispatches with a single lookup, and collects the answers in one place. The operators are the one case where Python performs the second dispatch. Everywhere else you choose between writing a second dispatch in methods and replacing both dispatches with one lookup in data.

Exercises

This chapter’s solutions give a hint, usually the shape of the code, and a full answer for each exercise.

  1. Add a fourth Item, Lizard, to paper_scissors_rock_table.py. Lizard beats Paper and Scissors, and loses to Rock. Lizard versus Lizard is a draw. Add the seven new entries that OUTCOME needs: both orders of each mixed pair, plus Lizard versus Lizard.
  2. Add the same Lizard to paper_scissors_rock.py, the double-dispatch version, which means adding an eval_lizard() method to every existing class, plus a Lizard class with its own compete() and four eval_*() methods. Compare how much code this took versus adding Lizard to the table version.
  3. In test_paper_scissors.py, add Lizard’s seven matchups to EXPECTED, taking it from nine entries to sixteen, and confirm both versions still agree with each other and with EXPECTED.
  4. In arena.py, give item_pair_gen() an optional counts: Counter[str] | None = None parameter, 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 own Counter, iterate over all 100 pairs from item_pair_gen(Item, 100, counts), and then print how many times Lizard appeared.
  5. Give Meters a __sub__() and a __rsub__(). __sub__() handles a Meters, an int, or a float, and returns NotImplemented for anything else. __rsub__() needs only the int and float cases, since Python skips the reflected form when both operands are Meters. Subtraction does not commute, so the reflected form must undo the swap. Check that 10 - Meters(3) produces Meters(7) rather than Meters(-7). Then confirm that "ten" - Meters(3) raises a TypeError rather than producing a Meters.
  6. Subclass Paper as Origami and duel it against Rock in the table version, as exact_match.py does. Explain the KeyError in 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.
  7. Create a business-modeling environment with three types of Inhabitant: Dwarf (for engineers), Elf (for marketers), and Troll (for managers). Now create a class called Project that creates the different inhabitants and causes them to interact() with each other. Single dispatch is enough here. The next exercise adds the second dispatch.
  8. Modify the previous exercise’s Project to make the interactions more detailed. Each Inhabitant can randomly produce a Weapon using get_weapon(): a Dwarf uses Jargon or Play, an Elf uses InventFeature or SellImaginaryProduct, and a Troll uses Edict or Schedule. You must decide which weapons “win” and “lose” in each interaction (as in paper_scissors_rock.py). Add a battle() method to Project that takes two Inhabitants and matches them against each other. Now create a meeting() method for Project that creates groups of Dwarf, Elf, and Troll and battles the groups against each other until only members of one group remain. These are the “winners.”
  9. 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 every OUTCOME cell a Callable[[Item, Item], Outcome] in place of its Outcome, and have compete() call the cell it finds: OUTCOME[type(self), type(item)](self, item). The call site stays item1.compete(item2). Write a helper that wraps a constant Outcome in a callable, so the seven unchanged cells stay one line each. Then give Paper a wet attribute 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 calls compete(). The chapter gives two reasons for preferring the double-dispatch version. Say which one this change answers, and which one survives it.
  10. Modify exercise 8 to use the table lookup technique of paper_scissors_rock_table.py.