flower_visitors.py with
singledispatchRewrite
flower_visitors.pywithsingledispatch. Makepollinate()andeat()functions defined outside theFlowerhierarchy, withChrysanthemum’s toxicity a registered implementation ofeat(). Which classes and which methods disappear?
The
Pythonic Visitor: singledispatch adds an operation to a
fixed hierarchy from outside it. Decide which of
pollinate() and eat() answers
differently by flower type; only that one needs
@singledispatch and register. Then
list what the Visitor machinery in The Classic
Visitor exists to do, and what in it has no job left.
# The shape of exercise_1.py
from functools import singledispatch
class Flower:
def __str__(self) -> str:
...
class Gladiolus(Flower):
pass
class Ranunculus(Flower):
pass
class Chrysanthemum(Flower):
pass
def pollinate(flower: Flower, pollinator: str) -> str:
...
@singledispatch
def eat(flower: Flower, eater: str) -> str:
...
@eat.register
def _(flower: Chrysanthemum, eater: str) -> str:
...# exercise_1.py
from functools import singledispatch
class Flower:
def __str__(self) -> str:
return type(self).__name__
class Gladiolus(Flower):
pass
class Ranunculus(Flower):
pass
class Chrysanthemum(Flower):
pass
def pollinate(flower: Flower, pollinator: str) -> str:
return f"{flower} pollinated by {pollinator}"
@singledispatch
def eat(flower: Flower, eater: str) -> str:
return f"{flower} eaten by {eater}"
@eat.register
def _(flower: Chrysanthemum, eater: str) -> str:
return f"{flower} is toxic to {eater}"
for flower in (Ranunculus(), Chrysanthemum()):
print(pollinate(flower, "Bee"))
print(eat(flower, "Worm"))
#: Ranunculus pollinated by Bee
#: Ranunculus eaten by Worm
#: Chrysanthemum pollinated by Bee
#: Chrysanthemum is toxic to WormEverything on the visitor side disappears: the
Visitor base, Bug,
Pollinator, Predator,
Bee, Fly, and Worm, and
the two visit() methods. Flower loses
accept(), and with it the Any
annotation the chapter explains. Flower also loses
pollinate() and eat(), which become
functions outside the hierarchy. Chrysanthemum’s
eat() override becomes a registration. Two
functions and one registration remain.
Dispatch where the flower type matters. Only
eat() is a singledispatch function,
because eat() answers differently for one flower
type. pollinate() does the same thing for every
flower, so it stays an ordinary function.
The Bug classes hold no state.
Pollinator and Predator each exist to
name one operation, and Bee, Fly, and
Worm exist to be types the second dispatch can
resolve. Once the operation is a function, the call site names
it: pollinate(flower, "Bee") says what
flower.accept(bee) says with a class and a
method.
You lose one thing: holding a visitor in a variable and
passing it around as an object. When that matters, the function
is still a value. op = eat works, and a
dict[str, Callable[[Flower, str], str]] keyed by
operation name recovers the “choose an operation at runtime”
half of what the Visitor hierarchy provides,
without the classes.
Add a
Rosetovisitor_singledispatch.pywith abundant nectar and a strong fragrance, then add a third operation,thorns(), over all four flowers. Count the lines each change costs, and say which of the two changes@singledispatchmakes cheaper.
The
Pythonic Visitor: singledispatch builds each operation as
one @singledispatch function with a default and a
register per exception. A new class costs the class
plus one registration in every operation where its answer
differs from the default. A new operation costs one function
plus a registration for each flower that differs. Count both in
lines, then compare with One
Dispatch Is Enough.
# The shape of exercise_2.py
from functools import singledispatch
class Flower:
def __str__(self) -> str:
...
class Gladiolus(Flower):
pass
class Ranunculus(Flower):
pass
class Chrysanthemum(Flower):
pass
class Rose(Flower): # The new type: 2 lines
pass
@singledispatch
def nectar(flower: Flower) -> str:
...
@nectar.register
def _(flower: Gladiolus) -> str:
...
@nectar.register
def _(flower: Chrysanthemum) -> str:
...
@nectar.register
def _(flower: Rose) -> str: # 3 lines
...
@singledispatch
def fragrance(flower: Flower) -> str:
...
@fragrance.register
def _(flower: Ranunculus) -> str:
...
@fragrance.register
def _(flower: Rose) -> str: # 3 lines
...
@singledispatch # The new operation: 3 lines
def thorns(flower: Flower) -> str:
...
@thorns.register
def _(flower: Rose) -> str: # 3 lines
...If you add the Rose class without registering it
with nectar() and fragrance(), both
operations fall back to their defaults, and the demo prints
Rose: no nectar / faint / sharp. Neither the
interpreter nor the type checker reports the omission, so the
wrong answer goes unnoticed. The solution registers
Rose with every operation whose default is wrong
for a rose.
# exercise_2.py
from functools import singledispatch
class Flower:
def __str__(self) -> str:
return type(self).__name__
class Gladiolus(Flower):
pass
class Ranunculus(Flower):
pass
class Chrysanthemum(Flower):
pass
class Rose(Flower): # The new type: 2 lines
pass
@singledispatch
def nectar(flower: Flower) -> str:
return f"{flower}: no nectar"
@nectar.register
def _(flower: Gladiolus) -> str:
return f"{flower}: abundant nectar"
@nectar.register
def _(flower: Chrysanthemum) -> str:
return f"{flower}: a little nectar"
@nectar.register
def _(flower: Rose) -> str: # 3 lines
return f"{flower}: abundant nectar"
@singledispatch
def fragrance(flower: Flower) -> str:
return "faint"
@fragrance.register
def _(flower: Ranunculus) -> str:
return "strong"
@fragrance.register
def _(flower: Rose) -> str: # 3 lines
return "strong"
@singledispatch # The new operation: 3 lines
def thorns(flower: Flower) -> str:
return "none"
@thorns.register
def _(flower: Rose) -> str: # 3 lines
return "sharp"
rose = Rose()
print(nectar(rose), "/", fragrance(rose), "/", thorns(rose))
#: Rose: abundant nectar / strong / sharp
print(thorns(Gladiolus()))
#: noneAdd a type. Adding Rose costs
eight lines: two for the class and three for each of its two
registrations, one per operation whose default is wrong for a
rose.
Add an operation. Adding
thorns() costs six lines: three for the function
and three for the one flower that differs. Neither change edits
an existing line.
@singledispatch makes adding an
operation cheaper than adding a type, because an
operation is a whole function and lives in one place. Adding
thorns() is cheap because three of the four flowers
accept its default. Rose needs a distinct answer
from every operation, so it costs one registration per
operation, scattered across the file. That difference in cost is
the expression problem from Pattern
Matching: methods on a class make adding a type cheap,
functions over a hierarchy make adding an operation cheap, and
no arrangement makes both cheap at once.
Visits protocol in place of AnyRewrite
flower_visitors.pywith theVisitsprotocol in place ofAny, soaccept()declares what it needs. Then add aBeetle(Bug)with novisit()method and pass it toaccept(). Which version reports the mistake, and when?
The
Price of the Empty Base shows a Protocol with a
single visit() method as the type of the
accept() parameter. Because a Protocol
matches by structure, Bee needs no change. Run the
Beetle call through the type checker and then
through the interpreter, and note which one objects and at what
point.
# The shape of exercise_3.py
from typing import Protocol
from exceptions import expect
class Visits(Protocol):
def visit(self, flower: Flower) -> None: ...
class Flower:
def accept(self, visitor: Visits) -> None:
...
def pollinate(self, pollinator: Visitor) -> None:
...
def __str__(self) -> str:
...
class Gladiolus(Flower):
pass
class Visitor:
def __str__(self) -> str:
...
class Bug(Visitor):
pass
class Pollinator(Bug):
def visit(self, flower: Flower) -> None:
...
class Bee(Pollinator):
pass
class Beetle(Bug): # Inherits no visit()
passIf you annotate the accept() parameter as
Visitor instead of Visits,
ty reports an unresolved-attribute on
visitor.visit, because the empty
Visitor base declares no visit(). The
Beetle call then passes the checker, since
Beetle is a Visitor, and fails at
runtime with the same AttributeError as under
Any. The solution annotates the parameter with
Visits, which names the method
accept() calls.
# exercise_3.py
from typing import Protocol
from exceptions import expect
class Visits(Protocol):
def visit(self, flower: Flower) -> None: ...
class Flower:
def accept(self, visitor: Visits) -> None:
visitor.visit(self)
def pollinate(self, pollinator: Visitor) -> None:
print(self, "pollinated by", pollinator)
def __str__(self) -> str:
return type(self).__name__
class Gladiolus(Flower):
pass
class Visitor:
def __str__(self) -> str:
return type(self).__name__
class Bug(Visitor):
pass
class Pollinator(Bug):
def visit(self, flower: Flower) -> None:
flower.pollinate(self)
class Bee(Pollinator):
pass
class Beetle(Bug): # Inherits no visit()
pass
Gladiolus().accept(Bee())
#: Gladiolus pollinated by Bee
expect(AttributeError, Gladiolus().accept, Beetle()) # type: ignore
#: [AttributeError] 'Beetle' object has no attribute 'visit'Declare the visitor’s interface.
Visits names the one method accept()
calls, so the parameter declares what accept()
needs instead of accepting anything. Bee neither
mentions Visits nor inherits from it, because a
Protocol matches on structure: any class with a
compatible visit() satisfies Visits.
The Visitor classes keep the chapter’s form; the
listing keeps only the pollinating half of them.
The two versions report the Beetle mistake at
different times. Under Any, the type checker has
nothing to compare Beetle against, so the call
type-checks and the program dies at runtime with the
AttributeError above. Under Visits,
the type checker rejects the argument before the program runs,
because Beetle inherits no visit() and
so does not match the protocol.
Demonstrate the runtime failure. The
# type: ignore comment keeps the checker quiet
about the Beetle call so the listing can show the
runtime failure; without it, ty reports an
invalid-argument-type.
Losing the check on the visitor side is the price the chapter
names for keeping Any. The Any moves
an error a type checker can catch into the run. The chapter’s
version pays that price because its Visitor base is
empty. Either fix restores the check: declaring
visit() abstract on that base, as the classic
pattern does, or writing the Visits protocol
above.