CMPUT-274 · Lesson 6 of 12 · 29 min

Design patterns across paradigms

A design pattern is a named, reusable solution to a problem that keeps recurring in software design. The idea came from the architect Christopher Alexander, who catalogued patterns for buildings and towns, and reached programming through the 1994 book Design Patterns by Gamma, Helm, Johnson and Vlissides, often called the Gang of Four. Their 23 patterns were written for C++ and Smalltalk, languages without first-class functions. Many of the patterns are workarounds for exactly that gap, and in a language with closures they shrink to a few lines. This lesson studies a handful of the most useful patterns, shows each in its classic object-oriented form, and then shows what it becomes in functional style. The lesson is about recognizing the problem each pattern solves; CMPUT-301 covers patterns in the context of large, team-built systems.

What a pattern is and is not

A pattern has four parts: a name, the problem it addresses, the solution's structure, and the consequences, including trade-offs. The name is surprisingly valuable, because it lets designers communicate quickly: saying use an observer here conveys a whole structure in three words. A pattern is not a piece of code you copy. It is a design idea that you adapt to the language and the problem. Forcing patterns into code that does not need them produces needless indirection, a mistake so common it has its own joke: the program with an AbstractSingletonProxyFactoryBean.

Seven common patterns and what they become with first-class functions
PatternCategoryProblem it solvesFunctional counterpart
StrategyBehaviouralChoose an algorithm at run timePass a function
ObserverBehaviouralNotify many dependants when something changesA list of callback functions; reactive streams
CommandBehaviouralTreat a request as a value: queue, log, undo itA closure or a data value describing the action
FactoryCreationalCreate objects without naming concrete classesA function that returns values or closures
DecoratorStructuralAdd behaviour to an object without changing its classA higher-order function wrapping another
AdapterStructuralMake an existing interface fit the one a client expectsA small wrapping function
IteratorBehaviouralTraverse a collection without exposing its structureGenerators and lazy sequences

Strategy

The strategy pattern lets a client choose one of several algorithms at run time. In the classic form, each algorithm is a class implementing a common interface, and the client holds a reference to one of them. Lesson 5's `Notifier` used strategies for channels and policies. When a strategy has a single method and no state, a function is all you need: the strategy object becomes a function value, and the interface becomes a function signature.

Strategy as classes and as functions give the same results
from abc import ABC, abstractmethod

# Classic object-oriented strategy
class Pricing(ABC):
    @abstractmethod
    def price(self, base): ...
class Regular(Pricing):
    def price(self, base): return base
class Student(Pricing):
    def price(self, base): return round(base * 0.85, 2)
class Weekend(Pricing):
    def __init__(self, surcharge): self.surcharge = surcharge
    def price(self, base): return base + self.surcharge

def checkout_oo(base, strategy: Pricing):
    return strategy.price(base)

# Functional strategy: the strategy IS a function
regular = lambda base: base
student = lambda base: round(base * 0.85, 2)
def weekend(surcharge): return lambda base: base + surcharge   # closure holds the state

def checkout_fn(base, strategy):
    return strategy(base)

for oo, fn in [(Regular(), regular), (Student(), student), (Weekend(3), weekend(3))]:
    print(checkout_oo(14.0, oo), checkout_fn(14.0, fn))
Output
14.0 14.0
11.9 11.9
17.0 17.0

The functional version needs no abstract class and no subclass per algorithm, and the `weekend` closure carries its surcharge just as the `Weekend` object did. The class version still has a place when a strategy has several related methods, needs configuration and inspection, or must be documented as a named type in a large codebase. In Python, `sorted(items, key=...)` is the strategy pattern built into the standard library: the key function is the interchangeable algorithm.

Observer

The observer pattern connects a subject, which has some state, to any number of observers that want to know when the state changes. Observers register with the subject, and the subject calls each of them when something happens. The subject does not need to know what the observers do, which keeps it decoupled from the rest of the program. Graphical user interfaces are built on this pattern: a button is a subject, and click handlers are observers.

A subject box labelled Thermostat in the middle with arrows going out to three observer boxes: Display, Logger and Alarm. A dashed arrow from each observer back to the subject is labelled subscribe.
Observer: observers subscribe once; the subject notifies every subscriber on each change
Observers as callback functions, with unsubscribe returned as a closure
class Thermostat:
    def __init__(self):
        self._observers = []
        self._temp = 20.0
    def subscribe(self, callback):
        self._observers.append(callback)
        return lambda: self._observers.remove(callback)   # returns an unsubscribe function
    def set_temp(self, t):
        old, self._temp = self._temp, t
        for cb in list(self._observers):
            cb(old, t)

log = []
th = Thermostat()
th.subscribe(lambda old, new: print(f"display: {new:.1f} C"))
stop_log = th.subscribe(lambda old, new: log.append((old, new)))
th.subscribe(lambda old, new: new > 26 and print("alarm: too hot"))

th.set_temp(22.5)
th.set_temp(27.0)
stop_log()                         # unsubscribe the logger
th.set_temp(21.0)
print("log:", log)
Output
display: 22.5 C
display: 27.0 C
alarm: too hot
display: 21.0 C
log: [(20.0, 22.5), (22.5, 27.0)]

Two details matter in real systems. The subject iterates over a copy of the observer list, `list(self._observers)`, so that an observer can unsubscribe during notification without disturbing the loop. And forgetting to unsubscribe is a common cause of memory leaks, because the subject keeps observers alive. Returning an unsubscribe function, as here, makes clean-up easy. Lesson 11 shows how reactive programming generalizes observers into streams of events that can be filtered, mapped and combined.

Worked problem

Choosing between polling and observer

A dashboard shows the status of 200 servers. Option A: the dashboard asks every server for its status every 5 seconds. Option B: each server notifies the dashboard when its status changes. Status changes happen about 10 times per minute in total. Compare the number of messages per minute.

Show solution
  1. Polling: 200 requests every 5 seconds is 200 × 12 = 2,400 requests per minute, each with a reply.
  2. Observer: about 10 notifications per minute, one per change.
  3. Polling also delays updates by up to 5 seconds; notifications arrive as soon as a change happens.
  4. Observer needs the servers to know how to reach the dashboard and requires handling of lost notifications, for example with an occasional full refresh.

Answer: About 2,400 requests per minute for polling versus about 10 notifications for the observer approach, with lower latency. Polling is simpler and more robust to lost messages; many systems combine the two.

Command

The command pattern turns a request into a value. Instead of calling a method directly, you create a command object that knows how to perform the action and, often, how to undo it. Commands can be stored in a list, sent to another thread, logged for auditing, replayed or reversed. Text editors implement undo and redo this way. In functional style, a command can be a pair of closures, one to do and one to undo, or simply an immutable record describing the action, interpreted by a single function.

Commands as do/undo pairs of closures support unlimited undo
class Editor:
    def __init__(self):
        self.text, self.undo_stack = "", []
    def run(self, do, undo):
        do(self); self.undo_stack.append(undo)
    def undo(self):
        if self.undo_stack:
            self.undo_stack.pop()(self)

def insert(s):
    def do(ed): ed.text += s
    def undo(ed): ed.text = ed.text[:-len(s)]
    return do, undo

def upper():
    saved = {}
    def do(ed): saved["before"] = ed.text; ed.text = ed.text.upper()
    def undo(ed): ed.text = saved["before"]
    return do, undo

ed = Editor()
for cmd in [insert("hello"), insert(" world"), upper()]:
    ed.run(*cmd)
    print(repr(ed.text))
while ed.undo_stack:
    ed.undo(); print("undo ->", repr(ed.text))
Output
'hello'
'hello world'
'HELLO WORLD'
undo -> 'hello world'
undo -> 'hello'
undo -> ''

Factory

A factory creates objects without the caller naming their concrete classes. The caller asks for a shape from a description, or a parser for a file type, and the factory decides which class to instantiate. This keeps knowledge of concrete classes in one place, so adding a new kind means changing only the factory. In Python, classes are themselves callable values, so a factory is often just a dictionary from names to classes or constructor functions.

A factory as a registry of functions, extended by a registering decorator
import json, csv, io

def parse_json(text): return json.loads(text)
def parse_csv(text):  return list(csv.DictReader(io.StringIO(text)))

PARSERS = {".json": parse_json, ".csv": parse_csv}     # the factory's registry

def parser_for(filename):
    for ext, parser in PARSERS.items():
        if filename.endswith(ext):
            return parser
    raise ValueError(f"no parser for {filename}")

def register(ext):                                     # plug-ins add themselves
    def wrap(fn):
        PARSERS[ext] = fn; return fn
    return wrap

@register(".kv")
def parse_kv(text):
    return dict(line.split("=", 1) for line in text.splitlines() if line)

print(parser_for("a.json")('{"x": 1}'))
print(parser_for("b.csv")("name,age\nAna,30\nBen,25"))
print(parser_for("c.kv")("host=db1\nport=5432"))
Output
{'x': 1}
[{'name': 'Ana', 'age': '30'}, {'name': 'Ben', 'age': '25'}]
{'host': 'db1', 'port': '5432'}

The `register` function is itself a small pattern: a decorator that adds the decorated function to a registry. Web frameworks use the same idea to map URLs to handler functions. Adding the `.kv` format required no change to `parser_for`; that is the open–closed principle from lesson 5 achieved with a dictionary and a higher-order function.

Decorator

The decorator pattern adds behaviour to an object by wrapping it in another object with the same interface. The wrapper does extra work, such as logging, caching or checking permissions, and delegates the main work to the wrapped object. Python's decorator syntax, `@name` above a function definition, applies the functional version: a higher-order function that takes a function and returns a wrapped one. The two ideas share a name because they solve the same problem.

Decorators stack: timing wraps memoization, which wraps the original function
import functools, time

def timed(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        wrapper.last_ms = (time.perf_counter() - start) * 1000
        return result
    return wrapper

def memoize(fn):
    cache = {}
    @functools.wraps(fn)
    def wrapper(n):
        if n not in cache:
            cache[n] = fn(n)
        return cache[n]
    wrapper.cache = cache
    return wrapper

@timed
@memoize
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(80), "| cached values:", len(fib.__wrapped__.cache))
print("name preserved by wraps:", fib.__name__)
Output
23416728348467685 | cached values: 81
name preserved by wraps: fib

The memoized Fibonacci runs in linear time because each value is computed once, a preview of the dynamic programming in CMPUT-304. Memoization is only safe for pure functions, the point made in lesson 2. The standard library provides `functools.cache` and `functools.lru_cache` for exactly this purpose. `functools.wraps` copies the wrapped function's name and documentation onto the wrapper and records the original in `__wrapped__`, so tools and error messages still show the right name.

Worked problem

When a pattern is overkill

A colleague proposes, for a script that converts one CSV file to JSON, an abstract ConverterFactory, a ConverterStrategy interface with one implementation, and an Observer to report progress. Evaluate the proposal.

Show solution
  1. There is one input format, one output format and one consumer of progress: nothing varies.
  2. Each pattern adds indirection that a reader must trace, with no flexibility gained.
  3. A function convert(path) that reads, transforms and writes, plus a print for progress, does the job in a few lines.
  4. If more formats appear later, a registry dictionary or a strategy function can be introduced then, when the variation is real.

Answer: The proposal is over-engineered. Introduce patterns in response to actual variation or change, not in anticipation of imagined needs; a pattern's consequences include its complexity.

Common mistakes

  • MistakeTreating patterns as code templates to copy into every program.

    InsteadPatterns are design ideas; adapt them to the language and use them only when the problem they solve is present.

  • MistakeWriting a one-method strategy class hierarchy in a language with first-class functions.

    InsteadPass a function; reserve classes for strategies with several methods or significant state.

  • MistakeForgetting to unsubscribe observers.

    InsteadReturn or store an unsubscribe handle; lingering observers keep objects alive and receive events they no longer want.

  • MistakeMemoizing a function that has side effects or depends on external state.

    InsteadOnly cache pure functions; otherwise cached results can be wrong and effects are skipped.

  • MistakeWriting a decorator without functools.wraps.

    InsteadUse wraps so that the wrapper keeps the original name, docstring and a link to the wrapped function.

Summary and key terms

  • A design pattern is a named, reusable solution with a problem, a structure and consequences.
  • Many classic patterns compensate for missing first-class functions and become simple in functional style.
  • Strategy chooses an algorithm at run time; a function value is often enough.
  • Observer lets a subject notify any number of subscribers; callbacks and unsubscribe handles implement it simply.
  • Command turns requests into values that can be queued, logged and undone.
  • Factory centralizes object creation; a registry of constructors keeps it open to extension.
  • Decorator adds behaviour by wrapping; Python decorators are higher-order functions.
design pattern
A named, general solution to a recurring design problem, described with its trade-offs.
strategy pattern
Encapsulating interchangeable algorithms so that one can be selected at run time.
observer pattern
A subject maintains a list of observers and notifies them of state changes.
command pattern
Representing a request as an object or value so it can be stored, queued, logged or undone.
factory
A function or object responsible for creating other objects without the caller naming concrete classes.
decorator pattern
Wrapping an object or function to add behaviour while keeping its interface.
callback
A function passed to other code to be called later, for example when an event occurs.
registry
A mapping from keys such as names or file extensions to implementations, used to look up behaviour.
memoization
Caching the results of a pure function so repeated calls with the same arguments return immediately.
Gang of Four
The authors of the 1994 book Design Patterns, which catalogued 23 object-oriented patterns.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Write a strategy-based function `apply_discount(total, rule)` and two rules, ten per cent off and five dollars off.

    Show answer

    `def apply_discount(total, rule): return rule(total)` with `ten = lambda t: t * 0.9` and `five = lambda t: max(0, t - 5)`.

  2. Why does the Thermostat iterate over list(self._observers) instead of self._observers?

    Show answer

    So that an observer can unsubscribe during notification without changing the list being iterated.

  3. Name two things you can do with commands that you cannot easily do with direct method calls.

    Show answer

    For example: undo and redo, queueing, logging and replaying, or sending them to another thread.

  4. Add a .txt parser to the factory that returns a list of lines.

    Show answer

    `@register(".txt")` above `def parse_txt(text): return text.splitlines()`.

  5. Write a decorator `twice` that calls a function two times and returns the second result.

    Show answer

    `def twice(fn):` with `@functools.wraps(fn)` on `def w(*a, **k): fn(*a, **k); return fn(*a, **k)` and `return w`.

  6. Why is it unsafe to memoize a function that reads the current time?

    Show answer

    Its result depends on when it is called, not only on its arguments, so cached results become wrong.

  7. What problem does the decorator pattern solve that subclassing solves poorly?

    Show answer

    Adding combinations of behaviours to individual objects at run time without a subclass for every combination.

  8. Which pattern does `sorted(items, key=len)` use?

    Show answer

    Strategy: the key function is the interchangeable algorithm used for comparison.

  9. Give one sign that a pattern is being overused.

    Show answer

    For example, an interface with a single implementation and no expected variation, or a factory that only ever creates one class.

  10. How does reactive programming relate to the observer pattern?

    Show answer

    It generalizes observers into streams of events that can be transformed and combined, as lesson 11 shows.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz