22  Design Patterns with First-Class Functions

NoteCore idea

Classic OOP design patterns often become simpler or disappear entirely in Python because functions are first-class objects. The Strategy and Command patterns reduce to plain functions.

In this chapter you will learn to:

  1. Recognize the Strategy pattern in its classic OOP form.
  2. Refactor it into a function-oriented form using first-class functions.
  3. Build a discoverable strategy registry with a decorator.
  4. Recognize the Command pattern and replace it with callable instances or plain functions.

22.1 Strategy, the classic way

The textbook Strategy pattern uses an abstract base class, one subclass per algorithm, and a context that holds an instance:

from abc import ABC, abstractmethod
from collections import namedtuple
from decimal import Decimal

Customer = namedtuple("Customer", "name fidelity")
LineItem = namedtuple("LineItem", "product quantity price")

class Order:
    def __init__(self, customer, cart, promotion=None):
        self.customer = customer
        self.cart = list(cart)
        self.promotion = promotion

    def total(self):
        return sum(item.quantity * item.price for item in self.cart)

    def due(self):
        discount = Decimal(0) if self.promotion is None else self.promotion.discount(self)
        return self.total() - discount

class Promotion(ABC):
    @abstractmethod
    def discount(self, order: "Order") -> Decimal: ...

class FidelityPromo(Promotion):
    def discount(self, order):
        rate = Decimal("0.05")
        return order.total() * rate if order.customer.fidelity >= 1000 else Decimal(0)

class BulkItemPromo(Promotion):
    def discount(self, order):
        d = Decimal(0)
        for item in order.cart:
            if item.quantity >= 20:
                d += item.quantity * item.price * Decimal("0.1")
        return d

joe = Customer("John Doe", 0)
ann = Customer("Ann Smith", 1100)
cart = [LineItem("banana", 4, Decimal("0.5")),
        LineItem("apple", 10, Decimal("1.5"))]
Order(ann, cart, FidelityPromo()).due()
Decimal('16.150')

Walking through the parts of the textbook pattern:

  • Promotion(ABC) is the strategy interface — an abstract base class with one method (discount) that subclasses must implement. The @abstractmethod decorator stops anyone from instantiating Promotion directly.
  • FidelityPromo and BulkItemPromo are concrete strategies — one class per algorithm, each implementing discount its own way.
  • Order is the context. It holds a promotion and delegates the discount computation to it via self.promotion.discount(self). Order doesn’t care which strategy it has, only that whatever it has implements the interface.
  • The call Order(ann, cart, FidelityPromo()) instantiates the strategy and hands it in.

This is the pattern as it appears in Design Patterns (Gamma et al., 1994) — the form you’d write in Java or C++. Notice the boilerplate: each strategy is a class, and each class wraps a single function (discount).

flowchart LR
    subgraph OOP["OOP Strategy"]
        P["Promotion (ABC)"]
        P --> F1["FidelityPromo"]
        P --> F2["BulkItemPromo"]
        P --> F3["LargeOrderPromo"]
    end
    subgraph FN["Function Strategy"]
        f1["fidelity_promo"]
        f2["bulk_item_promo"]
        f3["large_order_promo"]
        M["best_promo = max(p(order) for p in promos)"]
    end
    OOP -.collapses to.-> FN

22.2 Strategy as plain functions

In Python a function is a callable. The whole class hierarchy collapses to one function per strategy:

def fidelity_promo(order):
    rate = Decimal("0.05")
    return order.total() * rate if order.customer.fidelity >= 1000 else Decimal(0)

def bulk_item_promo(order):
    d = Decimal(0)
    for item in order.cart:
        if item.quantity >= 20:
            d += item.quantity * item.price * Decimal("0.1")
    return d

def large_order_promo(order):
    distinct_items = {item.product for item in order.cart}
    return order.total() * Decimal("0.07") if len(distinct_items) >= 10 else Decimal(0)

class OrderF:
    def __init__(self, customer, cart, promotion=None):
        self.customer = customer
        self.cart = list(cart)
        self.promotion = promotion
    def total(self):
        return sum(item.quantity * item.price for item in self.cart)
    def due(self):
        discount = Decimal(0) if self.promotion is None else self.promotion(self)
        return self.total() - discount

OrderF(ann, cart, fidelity_promo).due()
Decimal('16.150')

Walking through what changed and what didn’t:

  • Each strategy is now a plain def. fidelity_promo(order) does the same arithmetic the class-based FidelityPromo.discount did — but there’s no class wrapping it.
  • OrderF differs from Order in one line: self.promotion(self) instead of self.promotion.discount(self). We’re calling the promotion directly, because functions are callable.
  • The call site OrderF(ann, cart, fidelity_promo) passes the function itself — no (), no instantiation. Compare to the OOP version that needed FidelityPromo().

Same answer; one-third the code; no abstract classes; no instantiation overhead. The general lesson: when a “class” exists only to hold one method, in Python that class can simply be the method.

A “best-of” combinator is a one-liner:

promos = [fidelity_promo, bulk_item_promo, large_order_promo]
def best_promo(order):
    return max(promo(order) for promo in promos)

OrderF(ann, cart, best_promo).due()
Decimal('16.150')

22.3 Discovering strategies automatically

promos = [...] is a hand-maintained list. Two ways to make it self-updating.

By inspection — find every function in a module whose name ends _promo:

import inspect
import promotions_module

promos = [func for name, func in inspect.getmembers(promotions_module, inspect.isfunction)
          if name.endswith("_promo") and name != "best_promo"]

By decorator registration — even cleaner:

from collections.abc import Callable

Promotion = Callable[["OrderF"], Decimal]
promos: list[Promotion] = []

def promotion(promo: Promotion) -> Promotion:
    promos.append(promo)
    return promo

@promotion
def fidelity_promo(order):
    rate = Decimal("0.05")
    return order.total() * rate if order.customer.fidelity >= 1000 else Decimal(0)

@promotion
def bulk_item_promo(order):
    d = Decimal(0)
    for item in order.cart:
        if item.quantity >= 20:
            d += item.quantity * item.price * Decimal("0.1")
    return d

def best_promo(order):
    return max(promo(order) for promo in promos)

[p.__name__ for p in promos]
['fidelity_promo', 'bulk_item_promo']

Walking through the registry trick:

  • Promotion = Callable[["OrderF"], Decimal] is a type alias for “a function that takes an OrderF and returns a Decimal.” It documents the protocol the strategies must satisfy. (The forward reference "OrderF" is in quotes because the class is defined in a different cell.)
  • promos: list[Promotion] = [] is the registry — initially empty, populated as decorators run.
  • promotion(promo) is the decorator. It appends promo to promos for its side effect, then returns promo unchanged. That last detail matters: a decorator that returns the original function leaves the call site behavior alone — we get the registration plus the function callable as before.
  • @promotion above def fidelity_promo is sugar for fidelity_promo = promotion(fidelity_promo). Since promotion returns its argument, the binding fidelity_promo still names the same function — but promos now contains it.
  • The decorator runs at import time (see Chapter 21), so adding a new strategy is one @promotion line. The best_promo function picks it up automatically — no list to maintain.

The general pattern: a registry decorator is a function that returns its argument and keeps a side-effect record of every function it sees. It’s the universal recipe for plugin-style discovery.

22.4 The Command pattern

The classic Command pattern wraps each action in an object with an execute method, so commands can be queued, logged, or undone. In Python, any callable already meets that interface — you don’t need a class.

class MacroCommand:
    """Run a sequence of commands in order."""
    def __init__(self, commands):
        self.commands = list(commands)
    def __call__(self):
        for command in self.commands:
            command()

def open_file():  print("opening file")
def save_file():  print("saving file")
def close_app():  print("closing app")

macro = MacroCommand([open_file, save_file, close_app])
macro()
opening file
saving file
closing app

Walking through the collapse:

  • Each “command” is a plain function (open_file, save_file, close_app). In Java each would be a class implementing Command.execute().
  • MacroCommand.__init__ stores the list of commands. list(commands) copies — the macro owns its sequence even if the caller mutates theirs later.
  • __call__(self) makes the instance callable (see Chapter 19): macro() invokes MacroCommand.__call__(macro), which loops and calls each command in turn.
  • Calling command() works uniformly because every “command” is just a callable — function, lambda, class with __call__, or another MacroCommand.

The general pattern: where Java needs an interface (Command { void execute(); }) and one class per action, Python lets the callable protocol play that role. Replace command objects with callables; replace strategy objects with strategy functions. The pattern doesn’t disappear — it becomes idiomatic Python.

TipWhy this matters

The Strategy and Command design patterns encode behavior in classes because in languages like Java, functions are not first-class objects. In Python, behavior is a function. Replace strategy objects with strategy functions. Replace command objects with callables. The pattern disappears — you’re left with cleaner code.

22.5 Build: a CLI command dispatcher with @command

A common pattern in tooling: a script exposes several subcommands (mytool greet alice, mytool tally), and dispatch happens by name. The textbook OOP version is the Command pattern with a base class and one subclass per command. In Python, a registry dict + a decorator is the whole thing.

Step 1: a basic registry decorator. A dict keyed by function name; @command registers and returns the function unchanged so it can still be called directly:

COMMANDS = {}

def command(func):
    COMMANDS[func.__name__] = func
    return func

@command
def greet(name):
    return f"Hello, {name}!"

@command
def shout(text):
    return text.upper() + "!"

[greet("Alice"), shout("ok"), list(COMMANDS)]
['Hello, Alice!', 'OK!', ['greet', 'shout']]

COMMANDS[func.__name__] = func is the side effect; return func keeps the call site behavior unchanged. Both greet("Alice") and COMMANDS["greet"]("Alice") work — the registration is additive. This is the same @register pattern from the decorators chapter, applied to dispatch.

Step 2: parameterise to allow custom names. Some commands want a friendlier name than the Python identifier — bye instead of farewell. A parameterised decorator factory adds the configuration:

def command(name=None):
    def decorate(func):
        COMMANDS[name or func.__name__] = func
        return func
    return decorate

COMMANDS.clear()

@command()                       # uses func.__name__
def greet(name):
    return f"Hello, {name}!"

@command(name="bye")             # custom name
def farewell(name):
    return f"Goodbye, {name}."

list(COMMANDS)
['greet', 'bye']

@command() (with empty parens) calls the factory first; command() returns decorate, which then wraps greet. name or func.__name__ falls back to the function’s own name when the caller doesn’t override — the truthy-default idiom from chapter 2.

Step 3: a dispatch function that calls the right command by name. Looking up by string + raising KeyError for unknown commands gives you a usable mini-CLI in five lines:

def dispatch(name, *args):
    if name not in COMMANDS:
        raise KeyError(f"unknown command: {name!r} (have: {sorted(COMMANDS)})")
    return COMMANDS[name](*args)

[dispatch("greet", "Alice"), dispatch("bye", "Bob")]
['Hello, Alice!', 'Goodbye, Bob.']
dispatch("nope", "Alice")
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[9], line 1
----> 1 dispatch("nope", "Alice")

Cell In[8], line 3, in dispatch(name, *args)
      1 def dispatch(name, *args):
      2     if name not in COMMANDS:
----> 3         raise KeyError(f"unknown command: {name!r} (have: {sorted(COMMANDS)})")
      4     return COMMANDS[name](*args)

KeyError: "unknown command: 'nope' (have: ['bye', 'greet'])"

dispatch(name, *args) looks up the function in COMMANDS, then calls it with the forwarded args. The error message includes sorted(COMMANDS) so a typo gets a helpful “did you mean one of these?” trace. In a real CLI you’d plug argparse (chapter 8) on top: take the first positional arg as the command name, forward the rest.

The build is the chapter in motion: each command is a plain function, not a Command-pattern subclass; registration is a side effect of import-time decorator runs; dispatch is dict lookup; and the whole thing is twenty lines without a single ABC.

22.6 Exercises

  1. Add a strategy. Write a weekend_promo function that gives 5% off if the order is placed on a Saturday or Sunday. Register it with @promotion and confirm best_promo finds it without other changes.

  2. Strategy with state. Suppose a strategy needs configuration (a discount rate, an enabled flag). How would you represent it as a function? As a class with __call__? Compare.

  3. Discoverable Command. Apply the registry-decorator idea from @promotion to commands: a @command decorator that registers each command in a global list. Implement run_all().

  4. Where the OOP version wins. Name a real situation in which the class-based Strategy pattern is better than the function form. (Hint: think about state, inheritance, or testing.)

  5. MacroCommand undo. Add an undo method to MacroCommand that runs the commands in reverse. Then convert MacroCommand itself to a plain function — what’s lost?

22.7 Summary

This chapter closes Part II. Many of the Gang of Four patterns are workarounds for languages without first-class functions; in Python, those patterns shrink or vanish. The lesson is not that classes are bad — it’s that you should reach for the simplest tool that solves the problem, and in Python that’s often a function.

Part III turns to the case where classes do earn their keep. We start in Chapter 23 with the canonical Vector2d: a single class that becomes printable, hashable, formattable, and slottable as we add the right special methods.