Contents
Chapter 28

Function Objects

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.

Command: Choosing the Operation at Runtime

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: 200

account.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 2

The 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.

Strategy: Choosing the Algorithm at Runtime

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 None

Because 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.414214

Three 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.414214

Each 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.414214

bisection_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: Choosing the Handler at Runtime

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.414214

Each 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 None

An Event Bus: Handlers Keyed by Type

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 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._handlers

The 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.

Choosing the Lightest Callable

The alternatives this chapter showed form one list. Go down it and stop at the first form that supports what you need:

  1. A plain function, when the behavior needs no state of its own (command.py, strategy.py).
  2. A bound method, when the state already belongs to an object. account.deposit is a command with its instance attached.
  3. A closure or a functools.partial, when the state is a fixed configuration (configured_strategy.py).
  4. A callable object, when that configuration needs a name and a repr (callable_command.py).
  5. A class, when one call is not enough: a second operation such as 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.

Exercises

  1. Add an “undo” capability to command.py. What do the commands need to become, and is a function still enough, or do you now want an object?
  2. Rewrite chain.py so each handler also reports why it failed, and the solver prints every attempt before returning the winner.
  3. Use 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.
  4. Following 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().
  5. 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?
  6. Build a list of three commands in a 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?