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 declares a callable of a given signature and nothing about where it came from, the communication-first design Design Patterns describes. The three differ in what they defer. Command defers what to do, so you can store the action and run it later. Strategy defers how to do a job the caller already has. Chain of Responsibility defers which handler takes the job, trying candidates until one accepts.
In Python a function is already 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, the dissolution that Design Patterns describes.
Command appears first as a function, then as the classic class-based form. Strategy’s function form gets the same fuller listing. Its classic form appears only in prose, at the larger scale it needs. Chain of Responsibility needs only the function form: its class version is the same idea with the list written as a linked chain. A closing section keys the chain’s handlers by event type instead of by position, and the list becomes an event bus.
A Command wraps an action so you can pass it around and run it later. In Python the action is a function, and a “macro” is a list of actions:
# command.py
from collections.abc import Callable
def loony() -> None:
print("You're a loony.")
def new_brain() -> None:
print("You might even need a new brain.")
def afford() -> None:
print("I couldn't afford a whole new brain.")
macro: list[Callable[[], None]] = [loony, new_brain, afford]
for command in macro:
command()
#: You're a loony.
#: You might even need a new brain.
#: I couldn't afford a whole new brain.The classic object form 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 Loony(Command):
@override
def execute(self) -> None:
print("You're a loony.")
class NewBrain(Command):
@override
def execute(self) -> None:
print("You might even need a new brain.")
class Afford(Command):
@override
def execute(self) -> None:
print("I couldn't afford a whole new brain.")
# An object that holds commands:
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(Loony())
macro.add(NewBrain())
macro.add(Afford())
macro.run()
#: You're a loony.
#: You might even need a new brain.
#: I couldn't afford a whole new brain.Both do the same thing. The class version is four classes and a wrapper to say what one list of functions says directly. GoF Design Patterns calls commands “an object-oriented replacement for callbacks.” Because in Python a callback is a function, the replacement is unnecessary. Use the object form when a command must support extra operations such as undo.
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 command list can hold it alongside plain
functions, and the method keeps its state without any
Command class:
# bound_method.py
from collections.abc import Callable
class Account:
def __init__(self, balance: int) -> None:
self.balance = balance
def deposit(self) -> None:
self.balance += 50
print(f"balance: {self.balance}")
def alert() -> None:
print("audit: checking balance")
account = Account(100)
macro: list[Callable[[], None]] = [
account.deposit, alert, account.deposit,
]
for command in macro:
command()
#: balance: 150
#: audit: checking balance
#: balance: 200account.deposit sits in the same list as
alert, a plain function, with no
Command class in sight. Each call still reads and
updates account.balance, the state the bound method
carries with it.
An object can be callable too. A class with
__call__() (Decorators)
produces instances that carry state and still satisfy
Callable[[], None]. Repeat below is a
frozen
data class, so its configuration cannot change after
construction:
# callable_command.py
from collections.abc import Callable
from dataclasses import dataclass
@dataclass(frozen=True)
class Repeat:
text: str
times: int
def __call__(self) -> None:
for _ in range(self.times):
print(self.text)
macro: list[Callable[[], None]] = [
Repeat("You're a loony.", 1),
Repeat("Say no more.", 2),
]
for command in macro:
command()
#: You're a loony.
#: Say no more.
#: Say no more.Repeat holds configuration and fits the same
list[Callable[[], None]] that held
loony, with no Command base class
above it. The classic form skips this middle step: it goes from
a plain function straight to a base class. A callable alone
cannot express a second operation, undo(). Because
Callable[[], None] describes one call and nothing
else, a list of commands that also undo needs a name for
“callable, plus undo()”. In Python that name is a
Protocol with both members. A Command
base class becomes worth writing when the commands also share
implementation.
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
commands: list[Callable[[], None]] = [
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
fixed: list[Callable[[], None]] = [
partial(print, f"step {n}") for n in range(3)
]
for command in fixed:
command()
#: step 0
#: step 1
#: step 2The two comprehensions look alike and 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 one loop variable, which holds 2 by the time anything calls
them. The argument to functools.partial (Functional
Foundations) is an ordinary expression, which Python
evaluates where you write it, so each command stores the string
built from that iteration’s n. Nothing remains to
look up later. The older form lambda n=n: ... does
the same job with a default argument. When commands built in a
loop all behave like the last one, the shared loop variable is
why.
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 straddle the
root. The secant method reads them as two starting points, and
Newton’s method averages them into one. The secant and Newton
methods are open: they need somewhere to start, not a
bracket, so the chain below can fall back on them. All three
share one signature, so they are interchangeable:
# 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(f: Fn, a: float, b: float) -> float | None:
if f(a) * f(b) > 0: # Endpoints must bracket a root
return None
for _ in range(MAX_ITER):
mid = (a + b) / 2
if abs(f(mid)) < TOLERANCE:
return mid
if f(a) * f(mid) <= 0:
b = mid
else:
a = mid
return None
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 NoneBecause each finder is a function with the same signature,
passing one to solve() chooses the strategy, and
the loop below tries each choice in turn:
# strategy.py
from algorithms import (Fn, RootFinder, bisection,
newton, secant)
def solve(f: Fn, a: float, b: float,
finder: RootFinder) -> float | None:
return finder(f, a, b)
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)
assert root is not None
print(f"{root:.6f}")
#: 1.414214
#: 1.414214
#: 1.414214Three identical lines are the point: the algorithm changes and the caller stays the same. The algorithms are not equivalent, though, and the chain below turns the difference between them into a fallback.
The classic form repeats the move command_pattern.py made,
at larger scale. Each algorithm becomes a class deriving from a
FindRoot interface, with a find()
method, and a “Context” class holds the chosen one. Those five
classes produce the same three lines that one function argument
produced. The Context becomes useful when something must hold
the current algorithm between calls, a job no parameter can do.
Until then, the pattern reduces to the finder
parameter.
Python uses strategies-as-functions constantly without
calling them a pattern. The key argument to
sorted(), min(), and
max() is a strategy. You provide a function that
decides how to compare.
When a strategy needs configuration, the next step is not yet a class. It is a closure (Functional Foundations): an outer function takes the settings and returns the strategy, and the strategy keeps reading those settings after the outer function returns:
# configured_strategy.py
from algorithms import Fn, RootFinder
def bisection_within(tolerance: float) -> RootFinder:
def finder(f: Fn, a: float, b: 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
return finder
def f(x: float) -> float:
return x * x - 2 # Root at the square root of 2
coarse = bisection_within(0.1)
fine = bisection_within(1e-9)
r1, r2 = coarse(f, 0.0, 2.0), fine(f, 0.0, 2.0)
assert r1 is not None and r2 is not None
print(f"{r1:.6f} {r2:.6f}")
#: 1.406250 1.414214Each call to bisection_within() returns a new
finder whose closure holds that call’s tolerance. The coarse
strategy stops within a tenth and reports 1.406250. The fine one
agrees with the true root to six places. Both satisfy
RootFinder, so solve() accepts either
unchanged, and so does the chain below.
When the algorithm takes the setting as an ordinary
parameter, functools.partial replaces 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 Fn
def bisection_tol(f: Fn, a: float, b: float,
tolerance: float) -> float | 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 f(x: float) -> float:
return x * x - 2 # Root at the square root of 2
coarse = partial(bisection_tol, tolerance=0.1)
fine = partial(bisection_tol, tolerance=1e-9)
print(f"{coarse(f, 0.0, 2.0):.6f}")
#: 1.406250
print(f"{fine(f, 0.0, 2.0):.6f}")
#: 1.414214bisection_tol takes tolerance as an
ordinary parameter, so partial binds it by keyword,
once per strategy, in place of bisection_within’s
closure. A positional-only parameter takes no keyword, so
binding one means passing a Placeholder (Functional
Foundations) in each position the caller will fill. 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 holding a reference to the next and deciding whether to pass the request along. In Python the chain is a list of functions, and the loop that walks the list makes that decision in one place. Bisection needs the interval to bracket a root. The open methods do not:
# chain.py
from algorithms import (Fn, RootFinder, bisection,
newton, secant)
def solve(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 None
def f(x: float) -> float:
return x * x - 2 # Root at the square root of 2
chain: list[RootFinder] = [bisection, secant, newton]
# [0, 2] brackets the root, so bisection succeeds first:
r1 = solve(f, 0.0, 2.0, chain)
print(f"{r1:.6f}" if r1 is not None else "no root")
#: 1.414214
# No bracket in [1.0, 1.3]: bisection fails, secant works:
print(bisection(f, 1.0, 1.3))
#: None
r2 = solve(f, 1.0, 1.3, chain)
print(f"{r2:.6f}" if r2 is not None else "no root")
#: 1.414214Each handler is a Strategy function, the chain is
the list, and success is a non-None return. The
second solve() call shows the fall-through: the
interval [1.0, 1.3] does not straddle the root, so
bisection declines by returning None and the loop
continues to a method that needs no bracket. Adding, removing,
or reordering handlers means editing a list.
The test is root is not None, not
if root. A finder returns 0.0 for a
function whose root is at zero, and 0.0 is falsy,
so a truthiness test would discard a correct answer and call the
next finder. Any sentinel-versus-value check on a numeric result
has this hazard.
The chain has no check of its own: each handler decides for
itself whether it failed, and reports that decision as its
return value. secant() and newton()
report success when their latest step shrinks below the
tolerance. That is not quite the same as reaching a root, so a
chain is no more reliable than its handlers.
The first two tests wrap each finder in
watched(), which records the finder’s name as it
runs, so they can assert not just the root but which
finders ran. The four tests check that the first finder to
converge returns the root while the rest never run, that a later
finder succeeds where an earlier one fails, that an empty chain
returns None, and that a chain whose finders all
fail returns None too:
# test_chain.py
from algorithms import bisection, newton, secant
from chain import Fn, RootFinder, solve
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 = solve(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:
# [1.0, 1.3] does not bracket the root: bisection fails
tried: list[str] = []
chain = [watched(x, tried)
for x in (bisection, secant, newton)]
root = solve(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 solve(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
assert solve(g, 0.0, 2.0, [bisection]) is NoneChain 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 from each event type to the functions that
care about that type. The events are values, written as frozen
data classes. Publishing an event looks up its type and
calls every handler registered for that type. The handlers are
ordinary functions, so they need no base class, and registering
one is a single subscribe() call.
Handler below names their signature, not an
interface:
# event_bus.py
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
type Handler[E] = Callable[[E], None]
@dataclass(frozen=True)
class Deposit:
amount: int
@dataclass(frozen=True)
class Withdraw:
amount: int
@dataclass(frozen=True)
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)
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 is generic on the event type
E, which appears in both parameters, so the type
checker must find one E that satisfies the event
type and the handler together. No such E exists for
subscribe(Deposit, on_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. Its lists cannot
name a single event class, so their element type is
Handler[Any], the parameter erased.
subscribe indexes self._handlers
directly, 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, and every published event type with no subscriber,
such as Closed, would leave a stray entry
behind.
The lookup uses type(event), which matches the
class and no ancestor. A subclass of Deposit
published to this bus matches no handler, so
publish() calls nothing, exactly as it does for
Closed. Walking type(event).__mro__
and calling every handler along it would give a subclass event
its parent’s handlers. An event would then run every handler
registered anywhere in its ancestry, not only the ones
registered for its own type.
The tests confirm that publishing calls every handler registered for a type, a handler receives only its own event type, an event with no handler calls nothing, and publishing an unhandled event leaves no stray entry behind:
# 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_only_the_matching_type_is_called() -> None:
calls: list[str] = []
bus = EventBus()
bus.subscribe(Deposit,
lambda e: calls.append("deposit"))
bus.subscribe(Withdraw,
lambda e: calls.append("withdraw"))
bus.publish(Withdraw(1))
assert calls == ["withdraw"]
def test_no_handler_is_a_noop() -> None:
bus = EventBus()
bus.publish(Closed("done")) # Must not raise
def test_get_leaves_no_stray_handler_list() -> None:
# publish() reads with .get(): no stray entry appears
bus = EventBus()
bus.publish(Closed("done"))
assert Closed not in bus._handlersThe bus is the Observer
with one shared subject: instead of every observable holding its
own list, one bus holds every list and the event type selects
the handlers. Here 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,
functools.singledispatch is the tool. Visitor
and Pattern
Refactoring both use it.
The alternatives this chapter showed form one list. Go down it and stop at the first form that supports what you need:
command.py, strategy.py).account.deposit is a command with its instance
attached.functools.partial, when the
state is a fixed configuration (configured_strategy.py).repr (callable_command.py).undo(), or the several related methods and
mutable state the Strategy section describes.The GoF Design Patterns forms of Command, Strategy, and Chain of Responsibility all start at the last entry, because the languages behind those forms have no entries above it.
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 handler
also reports why it failed, and the solver 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 both drop into chain.py’s
solve() with no change to
solve().EventBus.publish() looks up
type(event), so 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?