A function object decouples the choice of function to call from the place that calls it. That decoupling is the goal of several patterns: Command, Strategy, and Chain of Responsibility.
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. These three patterns are largely unnecessary in Python. Where GoF Design Patterns builds a hierarchy, Python uses a function. The sections below show the function form first, then the classic object form for contrast.
A Command wraps an action so you can pass it around and run it later. In Python the action is just a function, and a “macro” is just 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: ...
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.” In Python a callback is just a
function, so the replacement is unnecessary. Use the object form
when a command must also carry state or support extra operations
such as undo. Halfway between the two, a bound method
is a ready-made command. account.deposit names a
function with its instance already attached, so it drops into a
command list alongside plain functions, carrying its state
without any Command class.
Building commands in a loop springs Python’s best-known closure trap:
# 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 the command
is called, not when it is created, and all three lambdas close
over the one loop variable, which holds 2 by the time anything
calls them. functools.partial (Foundations)
evaluates its arguments at construction time, so each command
captures the value n had on its own iteration. The
older spelling lambda n=n: ... does the same job
with a default argument. When commands built in a loop all
behave like the last one, this is why.
A Strategy is an interchangeable algorithm chosen at
runtime. For the following examples we will use three real
algorithms that 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. The hints are a
bracket for bisection and two starting points for the open
methods. They 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 mid
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 with a 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 simply 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.414214The classic form makes each algorithm a class deriving from a common interface, and adds a “Context” object to hold the current strategy:
# strategy_pattern.py
from typing import override
from algorithms import Fn, bisection, newton, secant
# The strategy interface:
class FindRoot:
def find(self, f: Fn, a: float, b: float) -> float | None:
raise NotImplementedError
# Each strategy wraps one algorithm from algorithms.py:
class Bisection(FindRoot):
@override
def find(self, f: Fn, a: float, b: float) -> float | None:
return bisection(f, a, b)
class Newton(FindRoot):
@override
def find(self, f: Fn, a: float, b: float) -> float | None:
return newton(f, a, b)
class Secant(FindRoot):
@override
def find(self, f: Fn, a: float, b: float) -> float | None:
return secant(f, a, b)
# The "Context" controls the strategy:
class RootSolver:
def __init__(self, strategy: FindRoot) -> None:
self.strategy = strategy
def solve(self, f: Fn, a: float, b: float) -> float | None:
return self.strategy.find(f, a, b)
def change_algorithm(self, new_algorithm: FindRoot) -> None:
self.strategy = new_algorithm
def f(x: float) -> float:
return x * x - 2
solver = RootSolver(Bisection())
for algorithm in (Bisection(), Newton(), Secant()):
solver.change_algorithm(algorithm)
root = solver.solve(f, 0.0, 2.0)
assert root is not None
print(f"{root:.6f}")
#: 1.414214
#: 1.414214
#: 1.414214We use strategies-as-functions constantly in Python without
naming it as 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 (Foundations), a function that manufactures the strategy with the settings baked in:
# 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 with its tolerance sealed inside. The coarse strategy
stops within a tenth and reports 1.406250. The fine one reports
the root to six places. Both satisfy RootFinder, so
either drops into solve(), or into the chain below,
unchanged. functools.partial does the same job when
the configurable version already exists with the setting as a
parameter. 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 it 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
# [1.0, 1.3] does not bracket it; bisection fails, secant finds it:
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. Adding,
removing, or reordering handlers means editing a list. We see
the fall-through when bisection cannot bracket a root. It
returns None, and the chain continues looking for a
method that can.
We test that the first finder that converges wins, a later
finder rescues one that fails, and an empty chain returns
None:
# test_chain.py
from algorithms import bisection, newton, secant
from chain import solve
def f(x: float) -> float:
return x * x - 2 # Root at the square root of 2
def test_first_successful_finder_wins() -> None:
root = solve(f, 0.0, 2.0, [bisection, secant, newton])
assert root is not None
assert abs(root - 2 ** 0.5) < 1e-6
def test_chain_falls_through_to_a_later_method() -> None:
# [1.0, 1.3] does not bracket the root: bisection fails
root = solve(f, 1.0, 1.3, [bisection, secant, newton])
assert root is not None
assert abs(root - 2 ** 0.5) < 1e-6
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 kept its handlers in a list and tried
them in order. If you key that structure by type instead of by
position, you have an event bus. This is a
dict from each event type to the functions that
care about it. The events are values, written as frozen data
classes. Publishing an event looks up its type and calls
every handler registered for it. The handlers are ordinary
functions, so there is no Handler interface to
implement and no registration ceremony:
# 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)
bus.subscribe(Deposit, audit) # Two handlers for one event type
bus.subscribe(Withdraw, on_withdraw)
bus.publish(Deposit(100))
#: + deposit 100
#: audit: a deposit of 100
bus.publish(Withdraw(30))
#: - withdraw 30
bus.publish(Closed("inactivity")) # No handler: nothing happenssubscribe is generic on the event type
E. The checker reads E from the first
argument and requires the handler to accept that exact type, so
subscribe(Deposit, on_withdraw) is a type error.
The safety check happens 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 the element type erases the parameter to
Handler[Any]. The generic guards the boundary. The
Any covers the heterogeneous storage behind it.
subscribe indexes self._handlers
directly, letting the defaultdict build each event
type’s list on first use. publish still calls
.get(type(event), []) instead of indexing. Indexing
on a read would insert an empty list as a side effect, leaving a
stray entry behind for every published event type that happens
to have no subscriber, such as Closed.
For testing, publishing calls every handler registered for a type, a handler hears only its own event type, and an event with no handler is a quiet no-op:
# 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:
EventBus().publish(Closed("done")) # Must not raiseThis is the Observer,
narrowed to a single subject. The subscribers are functions, and
the bus routes each event to them by its type. Here a type may
have many handlers. If you instead want a single handler per
type, chosen by the argument’s type and open to new types
without editing a central function, that is
functools.singledispatch, used by Visitor
and Pattern
Refactoring.
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().