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.
# 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 : drawFollow 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.
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 : drawDictionary 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:
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.
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
PatternsA 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()))
#: winOrigami() 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.
singledispatchmethod Trapfunctools.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 drawsBoth 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.
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()))
#: loseDampPaper’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.
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) == expectedtest_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.
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.
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.
This chapter’s solutions give a hint, usually the shape of the code, and a full answer for each exercise.
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.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.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.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.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.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.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.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.”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.paper_scissors_rock_table.py.