One place decides what to do, and another place does it later or does it a different way each time. A function object carries the decision from the first place to the second.
A function object decouples the choice of function to call from the place that calls it. That decoupling is the goal of three patterns: Command, Strategy, and Chain of Responsibility. The call site names the signature it calls. The callable can come from anywhere. Design Patterns states that principle as “design the communication, not the parts.”
Each pattern defers something:
In Python a function is an object. You can name it, store it in a list, pass it as an argument, and return it. That makes all three patterns largely unnecessary. Where GoF Design Patterns builds a hierarchy, Python uses a function, and the pattern dissolves into the language.
A Command wraps an action so you can pass it around and run it later. In Python the action is a function. Here, a “macro” is a list of actions:
# command.py
from collections.abc import Callable
type Command = Callable[[], None]
def no_more() -> None:
print("This parrot is no more.")
def ceased() -> None:
print("It has ceased to be.")
def fjords() -> None:
print("It's pining for the fjords.")
macro: list[Command] = [no_more, ceased, fjords]
for command in macro:
command()
#: This parrot is no more.
#: It has ceased to be.
#: It's pining for the fjords.Command names the
signature every entry must have. Its definition,
type Command = Callable[[], None], describes an
entry that takes no arguments and returns None. The
classic object form turns that name into a base class and wraps
each action in a Command subclass with an
execute() method:
# command_pattern.py
from typing import override
class Command:
def execute(self) -> None:
raise NotImplementedError
class NoMore(Command):
@override
def execute(self) -> None:
print("This parrot is no more.")
class Ceased(Command):
@override
def execute(self) -> None:
print("It has ceased to be.")
class Fjords(Command):
@override
def execute(self) -> None:
print("It's pining for the fjords.")
class Macro:
def __init__(self) -> None:
self.commands: list[Command] = []
def add(self, command: Command) -> None:
self.commands.append(command)
def run(self) -> None:
for c in self.commands:
c.execute()
macro = Macro()
macro.add(NoMore())
macro.add(Ceased())
macro.add(Fjords())
macro.run()
#: This parrot is no more.
#: It has ceased to be.
#: It's pining for the fjords.The class version takes four classes and a wrapper to say what one list of functions says. GoF Design Patterns calls commands “an object-oriented replacement for callbacks.” Because in Python a callback is a function, the replacement is unnecessary.
A Command base class is worthwhile when the
commands share implementation. A second operation calls for a
type instead. The undo
discussion describes that type with a Protocol.
Halfway between the function form and the class form, a
bound method is a ready-made command.
account.deposit names a function with its instance
attached, so a macro can hold it alongside plain functions. The
bound instance carries the state that a Command
subclass would hold:
# bound_method.py
from collections.abc import Callable
from dataclasses import dataclass
type Command = Callable[[], None]
@dataclass
class Account:
balance: int
def deposit(self) -> None:
self.balance += 50
print(f"balance: {self.balance}")
def audit() -> None:
print("audit: checking balance")
account = Account(100)
macro: list[Command] = [
account.deposit, audit, account.deposit,
]
for command in macro:
command()
#: balance: 150
#: audit: checking balance
#: balance: 200Each call to
account.deposit reads and updates
account.balance through account, the
instance the bound method stores as its
__self__.
The entry is account.deposit, without
parentheses. account.deposit() calls the method
while Python builds the list, so the list stores the result,
None. The type checker rejects that list, because
None is not a Command.
An object can be callable. When a
class defines __call__(), its instances carry
state and still satisfy Command. Here,
Repeat is a record, so its
configuration stays the same after construction:
# callable_command.py
from collections.abc import Callable
from record import record
type Command = Callable[[], None]
@record
class Repeat:
text: str
times: int
def __call__(self) -> None:
for _ in range(self.times):
print(self.text)
def spam() -> None:
print("Spam, eggs, sausage, spam.")
macro: list[Command] = [
spam,
Repeat("Ni!", 3),
]
for command in macro:
command()
#: Spam, eggs, sausage, spam.
#: Ni!
#: Ni!
#: Ni!
print(macro[1])
#: Repeat(text='Ni!', times=3)Printing macro[1] shows the repr
that @record generates, so you can read the
command’s configuration from outside the object. A closure’s
repr shows its function’s name and a memory
address.
Command describes one
call. An undoable macro needs a second operation on each
command, undo(). A command’s type then has two
members, __call__() and undo(), and a
Protocol describes that type (see exercise 1). Memento
undoes the other way, by saving snapshots of the state, so no
action needs an inverse.
Building commands in a loop can produce Python’s best-known closure mistake:
# late_binding.py
from collections.abc import Callable
from functools import partial
type Command = Callable[[], None]
commands: list[Command] = [
lambda: print(f"step {n}") for n in range(3)
]
for command in commands:
command() # Every lambda sees the final n
#: step 2
#: step 2
#: step 2
bound: list[Command] = [
partial(print, f"step {n}") for n in range(3)
]
for command in bound:
command()
#: step 0
#: step 1
#: step 2The two comprehensions differ in
when they read n. A lambda’s body runs when you
call the command, not when you create it. All three lambdas
close over the same loop variable, which holds 2 by the time
anything calls them. The argument to functools.partial()
is an ordinary expression that Python evaluates where you write
it, so each command stores the finished string built from its
own iteration’s n. The older remedy binds the value
through a default argument,
lambda n=n: print(f"step {n}"). Python evaluates a
default once, when it creates the lambda. When commands built in
a loop all behave like the last one, the shared loop variable is
the cause.
A Strategy is an interchangeable algorithm. Three
algorithms below find a root of a function
f, a value where f(x) is zero. Each
takes the function and two hints and returns the root, or
None when it cannot find one.
Bisection reads the two hints as a bracket, an interval whose
ends lie on opposite sides of the root. The secant method reads
them as two starting points. Newton’s method averages them into
one. The secant and Newton methods are open: they need
somewhere to start, not a bracket. chain_demo.py, under
Chain of Responsibility, falls back on them when the
two hints lie on the same side of the root.
All three share one signature, so they are interchangeable,
and solve() runs whichever one the caller
passes:
# algorithms.py
from collections.abc import Callable
from typing import Final
type Fn = Callable[[float], float]
type RootFinder = Callable[[Fn, float, float], float | None]
TOLERANCE: Final[float] = 1e-12
MAX_ITER: Final[int] = 200
def bisection_tol(f: Fn, a: float, b: float,
tolerance: float) -> float | None:
if f(a) * f(b) > 0: # Endpoints must bracket a root
return None
while abs(b - a) > tolerance:
mid = (a + b) / 2
if f(a) * f(mid) <= 0:
b = mid
else:
a = mid
return (a + b) / 2
def bisection(f: Fn, a: float, b: float) -> float | None:
return bisection_tol(f, a, b, TOLERANCE)
def secant(f: Fn, a: float, b: float) -> float | None:
x0, x1 = a, b
for _ in range(MAX_ITER):
f0, f1 = f(x0), f(x1)
if f1 == f0: # Flat step: cannot continue
return None
x2 = x1 - f1 * (x1 - x0) / (f1 - f0)
if abs(x2 - x1) < TOLERANCE:
return x2
x0, x1 = x1, x2
return None
def newton(f: Fn, a: float, b: float) -> float | None:
x = (a + b) / 2 # Start between the hints
h = 1e-7
for _ in range(MAX_ITER):
# Approximate the derivative by central difference:
slope = (f(x + h) - f(x - h)) / (2 * h)
if slope == 0:
return None
step = f(x) / slope
x -= step
if abs(step) < TOLERANCE:
return x
return None
def solve(f: Fn, a: float, b: float,
finder: RootFinder) -> float:
root = finder(f, a, b)
if root is None:
raise ValueError(f"no root in [{a}, {b}]")
return rootsolve() is the part of the procedure that stays
the same. It runs a finder and turns a failed search into an
exception, so a caller receives a root or an exception, not a
None to check. Because each finder is a function
with the same signature, passing one to solve()
chooses the strategy:
# strategy.py
from algorithms import bisection, newton, secant, solve
def f(x: float) -> float:
return x * x - 2 # Root at the square root of 2
for finder in (bisection, newton, secant):
root = solve(f, 0.0, 2.0, finder)
print(f"{finder.__name__}: {root:.6f}")
#: bisection: 1.414214
#: newton: 1.414214
#: secant: 1.414214In the classic form, each algorithm becomes a class derived
from a FindRoot interface, with a
find() method. A “Context” class holds the chosen
algorithm. A parameter holds a strategy for the length of one
call, so you need a Context when the program must keep the
algorithm after the call returns.
In Smalltalk’s MVC, a view is that Context and its controller is the strategy. GoF Design Patterns names the pair as an example of Strategy. The controller decides how the view responds to input. Replacing the controller changes the response and leaves the display as it was. A controller that ignores input events disables the view. Observer covers MVC’s other half, where a model notifies its views.
Python uses
strategies-as-functions constantly without calling them a
pattern. The key argument passed to
sorted(), min(), and
max() is a strategy. It chooses the value the
algorithm compares for each item. The algorithm stays the same
(see exercise 3).
When a strategy needs configuration, use a closure. An outer function takes the settings and returns the strategy. Those settings stay available to the strategy after the outer function returns.
In algorithms.py,
bisection() calls bisection_tol(), a
bisection that stops once the bracket is narrower than
tolerance, and always passes it
TOLERANCE. RootFinder describes three
parameters and bisection_tol() has four, so a
closure supplies a tolerance of your choosing:
# configured_strategy.py
from algorithms import Fn, RootFinder, bisection_tol, solve
def bisection_within(tolerance: float) -> RootFinder:
def finder(f: Fn, a: float, b: float) -> float | None:
return bisection_tol(f, a, b, tolerance)
return finder
def f(x: float) -> float:
return x * x - 2 # Root at the square root of 2
for tolerance in (0.1, 1e-9):
finder = bisection_within(tolerance)
root = solve(f, 0.0, 2.0, finder)
print(f"tolerance {tolerance}: {root:.6f}")
#: tolerance 0.1: 1.406250
#: tolerance 1e-09: 1.414214Each call to
bisection_within() returns a new finder whose
closure holds that call’s tolerance. The finder built with
0.1 stops within a tenth and reports 1.406250. The
finder built with 1e-9 agrees with the true root to
six places. Because both satisfy RootFinder,
solve() accepts either unchanged. So does
first_root(), which the next section defines in chain.py.
Because
bisection_tol() takes the setting as an ordinary
parameter, functools.partial() can replace the
closure. partial() fills positional parameters from
the left, so bind a trailing setting by keyword:
# partial_bisection.py
from functools import partial
from algorithms import bisection_tol, solve
def f(x: float) -> float:
return x * x - 2 # Root at the square root of 2
for tolerance in (0.1, 1e-9):
finder = partial(bisection_tol, tolerance=tolerance)
root = solve(f, 0.0, 2.0, finder)
print(f"tolerance {tolerance}: {root:.6f}")
#: tolerance 0.1: 1.406250
#: tolerance 1e-09: 1.414214Each partial object satisfies
RootFinder, so solve() runs a
partial object as it runs a closure.
A caller must pass a positional-only
parameter by position, so binding one with
partial() means passing a Placeholder
in each position the caller fills.
Save the strategy class for an algorithm that carries several related methods or mutable state. Configuration alone is a closure’s job.
Chain of Responsibility tries a sequence of handlers
until one succeeds. GoF Design Patterns implements the
chain as a linked structure. Each handler holds a reference to
the next and decides whether to pass the request along. In
Python that chain is a list of functions.
first_root() returns the first root a finder in the
list reports:
# chain.py
from algorithms import Fn, RootFinder
def first_root(f: Fn, a: float, b: float,
chain: list[RootFinder]) -> float | None:
for finder in chain:
root = finder(f, a, b)
if root is not None:
return root
return NoneEach handler is a
Strategy function, chain is the list of
strategies, and a returned float signals success.
An exhausted chain returns None rather than raising
an exception. The caller decides what an empty result means.
Bisection needs the interval to bracket a root. The open
methods need only a place to start. chain_demo.py runs
bisection alone and then the chain on two intervals:
# chain_demo.py
from algorithms import RootFinder, bisection, newton, secant
from chain import first_root
def f(x: float) -> float:
return x * x - 2 # Root at the square root of 2
def show(root: float | None) -> str:
return "None" if root is None else f"{root:.6f}"
chain: list[RootFinder] = [bisection, secant, newton]
for a, b in ((0.0, 2.0), (1.0, 1.3)):
alone = show(bisection(f, a, b))
first = show(first_root(f, a, b, chain))
print(f"[{a}, {b}] bisection: {alone}, chain: {first}")
#: [0.0, 2.0] bisection: 1.414214, chain: 1.414214
#: [1.0, 1.3] bisection: None, chain: 1.414214The [1.0, 1.3] line shows the fall-through. Both
ends of that interval lie below the root, so bisection returns
None. The loop in first_root() then
calls secant(), which needs no bracket and finds
the root. Adding, removing, or reordering the finders means
editing the chain list.
The test in
first_root() is root is not None, not
if root. A finder returns 0.0 for a
function whose root is at zero. Because 0.0 is
falsy, a truthiness
test discards that correct answer and calls the next finder. The
hazard is the truthiness test, not the choice of failure value.
Whichever value marks failure, compare the result against it
with is.
None is the right failure value here because a
root is always a float, so
float | None says which result is which. When
None is a possible result, it cannot double as the
failure mark. A sentinel()
takes that role instead.
Each finder decides for itself whether it succeeded, and
reports that decision as its return value. secant()
and newton() report success when their latest step
shrinks below the tolerance. A step below the tolerance is not
quite the same as reaching a root, so a chain’s answer is as
trustworthy as the finder that gave it.
The test file confirms that:
None.None
too.# test_chain.py
from algorithms import (Fn, RootFinder, bisection,
newton, secant)
from chain import first_root
def f(x: float) -> float:
return x * x - 2 # Root at the square root of 2
def watched(finder: RootFinder,
tried: list[str]) -> RootFinder:
def recording(f: Fn, a: float,
b: float) -> float | None:
tried.append(finder.__name__) # type: ignore
return finder(f, a, b)
return recording
def test_first_successful_finder_wins() -> None:
tried: list[str] = []
chain = [watched(x, tried)
for x in (bisection, secant, newton)]
root = first_root(f, 0.0, 2.0, chain)
assert root is not None
assert abs(root - 2 ** 0.5) < 1e-6
assert tried == ["bisection"] # The rest never ran
def test_chain_falls_through_to_a_later_method() -> None:
assert f(1.0) * f(1.3) > 0 # No bracket
assert bisection(f, 1.0, 1.3) is None
tried: list[str] = []
chain = [watched(x, tried)
for x in (bisection, secant, newton)]
root = first_root(f, 1.0, 1.3, chain)
assert root is not None
assert abs(root - 2 ** 0.5) < 1e-6
assert tried == ["bisection", "secant"]
def test_empty_chain_returns_none() -> None:
assert first_root(f, 0.0, 2.0, []) is None
def test_all_fail_returns_none() -> None:
def g(x: float) -> float:
return x * x + 1 # No real root
chain: list[RootFinder] = [bisection, secant, newton]
assert first_root(g, 0.0, 2.0, chain) is Nonewatched() wraps a finder and records the
finder’s name each time the wrapper runs.
test_first_successful_finder_wins() and
test_chain_falls_through_to_a_later_method() wrap
every finder this way, so they assert which finders ran as well
as the root.
watched() carries a
# type: ignore because RootFinder is a
Callable, and a Callable declares no
__name__. The type checker is right about the type.
A partial object is a RootFinder, and
it has no __name__. The three finders here are
functions, and every function has one. A Protocol
that declares __name__ beside
__call__() states that requirement in the type, in
place of the # type: ignore.
All three patterns share one shape:
In every lane the call site stays the same short loop or call, and it depends on the signature alone. The patterns differ in how many of the chosen function objects the call site runs: all of them in turn, one per call, or the first that succeeds. That count, rather than a class structure, separates Command, Strategy, and Chain of Responsibility in Python.
Chain of Responsibility
keeps its handlers in a list and tries them in order. If you key
that structure by type instead of by position, you have an
event bus. The bus is a dict keyed by
event type. Each key maps to a list of handlers.
subscribe() appends a handler to the list under the
event type it handles. Publishing an event looks up its type and
calls every handler registered for that type. Calling every
handler is the second difference from the chain, which stops at
the first handler that succeeds.
The events are values, written as records. The handlers are
ordinary functions, so they need no base class. Registering one
is a single subscribe() call. Here,
Handler names their signature, not an
interface:
# event_bus.py
from collections import defaultdict
from collections.abc import Callable
from typing import Any
from record import record
type Handler[E] = Callable[[E], None]
@record
class Deposit:
amount: int
@record
class Withdraw:
amount: int
@record
class Closed:
reason: str
class EventBus:
def __init__(self) -> None:
self._handlers: defaultdict[
type, list[Handler[Any]]
] = defaultdict(list)
def subscribe[E](self, event_type: type[E],
handler: Handler[E]) -> None:
self._handlers[event_type].append(handler)
def publish(self, event: object) -> None:
for handler in self._handlers.get(type(event), []):
handler(event)subscribe() is
generic on the event type E, which appears in both
parameters. The type checker must therefore find one
E that satisfies the event type and the handler
together. No such E exists for a
Deposit paired with a handler that takes a
Withdraw, so the type checker reports a type error.
The check runs once, at registration. The stored
defaultdict, though, mixes handlers for every event
type in one structure. One annotation covers every list. No
single event class fits all of them, so the element type is
Handler[Any].
The demo subscribes two handlers for Deposit and
one for Withdraw, then publishes one event of each
of the three types:
# event_bus_demo.py
from event_bus import Closed, Deposit, EventBus, Withdraw
def on_deposit(event: Deposit) -> None:
print(f"+ deposit {event.amount}")
def audit(event: Deposit) -> None:
print(f" audit: a deposit of {event.amount}")
def on_withdraw(event: Withdraw) -> None:
print(f"- withdraw {event.amount}")
bus = EventBus()
bus.subscribe(Deposit, on_deposit)
# Two handlers for one event type
bus.subscribe(Deposit, audit)
bus.subscribe(Withdraw, on_withdraw)
bus.publish(Deposit(100))
#: + deposit 100
#: audit: a deposit of 100
bus.publish(Withdraw(30))
#: - withdraw 30
# No handler: nothing happens
bus.publish(Closed("inactivity"))subscribe() indexes self._handlers,
letting the defaultdict build each event type’s
list on first use. publish() reads with
.get(type(event), []) instead of indexing, because
indexing a defaultdict inserts an empty list as a
side effect. Indexing would leave a stray entry behind for every
published event type with no handler, such as
Closed.
The lookup uses
type(event), which matches the event’s exact class.
A subclass of Deposit published to this bus gets
the empty default list from .get(), as
Closed does. Walking
type(event).__mro__ and calling every handler along
it gives a subclass event its parent’s handlers. An event then
runs the handlers registered for its own type and for every
ancestor (see exercise 5).
The test file confirms that:
# test_event_bus.py
from event_bus import Closed, Deposit, EventBus, Withdraw
def test_every_handler_for_the_type_is_called() -> None:
seen: list[str] = []
bus = EventBus()
bus.subscribe(Deposit,
lambda e: seen.append(f"a{e.amount}"))
bus.subscribe(Deposit,
lambda e: seen.append(f"b{e.amount}"))
bus.publish(Deposit(5))
assert seen == ["a5", "b5"]
def test_handler_receives_only_its_event_type() -> None:
seen: list[str] = []
bus = EventBus()
bus.subscribe(Deposit,
lambda e: seen.append("deposit"))
bus.subscribe(Withdraw,
lambda e: seen.append("withdraw"))
bus.publish(Withdraw(1))
assert seen == ["withdraw"]
def test_unhandled_event_raises_no_exception() -> None:
bus = EventBus()
bus.publish(Closed("done"))
def test_unhandled_event_leaves_no_stray_entry() -> None:
bus = EventBus()
bus.publish(Closed("done"))
assert Closed not in bus._handlersIn event_bus.py, the events
are records, the handlers are functions, and the bus is a
dict. Each subscribe() call repeats
the event type that the handler’s annotation names. A second
version reads the annotation, so a handler names its event
once.
The second version gives each side
a decorator. Both decorators produce records.
@event registers its class in EVENTS.
@handler makes a class whose instances are function
objects, with fields that hold their configuration. It also
records in HANDLES the event type that the class’s
__call__() accepts.
Handler keeps its
Callable definition, which accepts a callable
object as Command accepts Repeat in callable_command.py.
subscribe() then takes one argument, a handler, and
looks up the handler’s event type in HANDLES.
publish() checks that each event’s class is in
EVENTS:
# tagged_bus.py
import inspect
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Final, dataclass_transform
EVENTS: Final[set[type]] = set()
HANDLES: Final[dict[type, type]] = {}
@dataclass_transform(frozen_default=True)
def event[E](cls: type[E]) -> type[E]:
built = dataclass(frozen=True, slots=True)(cls)
EVENTS.add(built)
return built
type Handler[E] = Callable[[E], None]
@dataclass_transform(frozen_default=True)
def handler[H](cls: type[H]) -> type[H]:
call = vars(cls).get("__call__")
if call is None:
raise TypeError(f"{cls.__name__} has no __call__")
sig = inspect.signature(call)
handled = list(sig.parameters.values())[1].annotation
if handled not in EVENTS:
raise TypeError(f"{cls.__name__}: not an @event")
built = dataclass(frozen=True, slots=True)(cls)
HANDLES[built] = handled
return built
class EventBus:
def __init__(self) -> None:
self._handlers: defaultdict[
type, list[Handler[Any]]
] = defaultdict(list)
def subscribe(self, handler: Handler[Any]) -> None:
handled = HANDLES.get(type(handler))
if handled is None:
name = type(handler).__name__
raise TypeError(f"{name} is not a @handler")
self._handlers[handled].append(handler)
def publish(self, event: object) -> None:
if type(event) not in EVENTS:
name = type(event).__name__
raise TypeError(f"{name} is not an @event")
for handler in self._handlers.get(type(event), []):
handler(event)handler() looks for
__call__() in vars(cls), the class’s
own namespace. hasattr(cls, "__call__") cannot make
that test, because it is true of every class. A class is
callable, and calling it builds an instance.
Each decorator registers
built, the class that dataclass()
returns, and not the cls it received. Python sets a
class’s slots when it creates the class, so
slots=True makes dataclass() build a
new class and return it. Registering cls puts the
original class in EVENTS. Every event is an
instance of built instead. publish()
then refuses every event. @handler refuses every
handler class, because the annotation it reads names the class
that dataclass() returned (see exercise 7).
A bank account’s events and handlers are classes under the two decorators:
# bank_events.py
from tagged_bus import event, handler
@event
class Deposit:
amount: int
@event
class Withdraw:
amount: int
@event
class Closed:
reason: str
@handler
class Announce:
prefix: str
def __call__(self, event: Deposit) -> None:
print(f"{self.prefix} deposit {event.amount}")
@handler
class Audit:
threshold: int
def __call__(self, event: Deposit) -> None:
if event.amount > self.threshold:
print(f" audit: large deposit {event.amount}")
@handler
class OnWithdraw:
def __call__(self, event: Withdraw) -> None:
print(f"- withdraw {event.amount}")dataclass_transform
tells the type checker that a class decorated with either
decorator is a frozen data class.
Audit(threshold=50) therefore has its generated
__init__(), and the type checker rejects
Audit(50).threshold = 1 as an assignment to a
read-only property, as it does for a class decorated with
@dataclass(frozen=True).
The two decorators fill EVENTS and
HANDLES at run time. The type checker reads the
source and never runs it, so it cannot check what those two
collections hold. @handler reads the annotation on
the first parameter after self in
__call__(), the same annotation the type checker
checks, so a handler names its event once.
The demo subscribes each handler with one argument, and the bus routes each event by its type:
# tagged_bus_demo.py
from bank_events import (Announce, Audit, Closed,
Deposit, OnWithdraw, Withdraw)
from exceptions import expect
from tagged_bus import EventBus
bus = EventBus()
bus.subscribe(Announce("+"))
bus.subscribe(Audit(threshold=50))
bus.subscribe(OnWithdraw())
bus.publish(Deposit(100))
#: + deposit 100
#: audit: large deposit 100
bus.publish(Deposit(10))
#: + deposit 10
bus.publish(Withdraw(30))
#: - withdraw 30
bus.publish(Closed("inactivity")) # An event, no handler
expect(TypeError, bus.publish, "Deposit")
#: [TypeError] str is not an @eventpublish() keeps its object
parameter, because no static type means “an instance of a class
decorated with @event”. The type checker therefore
accepts a stray string as the argument. publish()
rejects the string at run time, when it finds str
missing from EVENTS.
The tagged bus gives up the registration-time check of the
first version. subscribe(Deposit, on_withdraw)
fails under the type checker because no E fits both
arguments. The tagged subscribe() takes one
argument, typed Handler[Any], so the type checker
accepts any callable of that shape. An instance of an
undecorated class with the right __call__()
therefore passes the type checker. subscribe()
rejects it at run time, when it looks up the class in
HANDLES.
The test file confirms that:
publish() refuses an object that is not an
event.subscribe() refuses an instance of a class
without @handler.@handler refuses a class with no
__call__(), and one whose __call__()
annotates int instead of an @event
class.# test_tagged_bus.py
import pytest
from bank_events import Deposit, Withdraw
from tagged_bus import EventBus, handler
def test_handler_receives_its_event() -> None:
seen: list[int] = []
@handler
class Collect:
def __call__(self, event: Deposit) -> None:
seen.append(event.amount)
bus = EventBus()
bus.subscribe(Collect())
bus.publish(Deposit(5))
bus.publish(Withdraw(1))
assert seen == [5]
def test_publish_rejects_a_non_event() -> None:
with pytest.raises(TypeError, match="not an @event"):
EventBus().publish("Deposit")
def test_subscribe_rejects_an_undecorated_class() -> None:
class Plain:
def __call__(self, event: Deposit) -> None: ...
with pytest.raises(TypeError, match="not a @handler"):
EventBus().subscribe(Plain())
def test_handler_needs_a_call_on_an_event() -> None:
with pytest.raises(TypeError, match="has no __call__"):
@handler
class NoCall:
x: int
with pytest.raises(TypeError, match="not an @event"):
@handler
class WrongEvent:
def __call__(self, event: int) -> None: ...The bus is the Observer with one shared subject (Observer calls the subject a broadcaster and each handler a responder). Instead of every subject holding its own list, one bus holds every list. The event type selects the handlers.
On the bus, a type may have many
handlers. When each type needs exactly one, and a new type must
add its own without editing a central function, use
functools.singledispatch. The dispatcher calls the
function registered for the type of its first argument. Each new
type adds its own function with register(). Visitor
and Pattern
Refactoring both use singledispatch.
Stop at the first form that supports what you need:
command.py, strategy.py).account.deposit is a command with its
instance attached (bound_method.py).functools.partial(), when the state is a
configuration set at creation (configured_strategy.py, partial_bisection.py).repr (callable_command.py).undo(), or the several
related methods and mutable state the Strategy section
describes.A function that carries data in
its __dict__ would sit between entries 1 and 3. The
list leaves it out. The type checker reports each dotted
access to such an attribute, so configuration goes in a
closure or a callable object instead.
The GoF Design Patterns
forms of Command, Strategy, and Chain of
Responsibility all start at number 5. In the C++ of that
book, a class was the lightest form that could carry state. A
function pointer carried none, and closures did not exist yet.
The GoF form then gives that class a named operation,
execute(), where a callable object has
__call__(). The named operation puts the GoF form
at entry 5 rather than entry 4.
Try each exercise before opening its solution.
command.py. What do the
commands need to become, and is a function still enough, or do
you now want an object?chain.py so each finder
also reports why it failed, and first_root() prints
every attempt before returning the winner.sorted() with a key function
to sort a list of (name, score) tuples by score,
then by name. Explain why key is the
Strategy pattern.bisection_within(), add a
tolerance parameter to newton() in algorithms.py and build a
configured strategy from it two ways: with a closure, and with
functools.partial(). Confirm that chain.py’s
first_root() runs a chain holding either one, with
no change to first_root().EventBus.publish() looks up
type(event), a subclass of Deposit
finds no handler. Change publish() to walk
type(event).__mro__ and call every handler
registered along it, parents last. Then add
unsubscribe(). Which of the two changes can break
an existing caller, and why?for loop
(not a comprehension) with lambda: print(n). Call
them and explain the output. Fix the loop three ways: with a
default argument, with functools.partial(), and
with a factory function that takes n and returns
the command. Which one still works if you must compute the value
at call time rather than at build time?tagged_bus.py, change
event() to register cls in place of
built. Predict what importing bank_events.py then does,
and which line stops it. Run it to check.