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 cataloged 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.
| Pattern | Category | Problem it solves | Functional counterpart |
|---|---|---|---|
| Strategy | Behavioral | Choose an algorithm at run time | Pass a function |
| Observer | Behavioral | Notify many dependants when something changes | A list of callback functions; reactive streams |
| Command | Behavioral | Treat a request as a value: queue, log, undo it | A closure or a data value describing the action |
| Factory | Creational | Create objects without naming concrete classes | A function that returns values or closures |
| Decorator | Structural | Add behavior to an object without changing its class | A higher-order function wrapping another |
| Adapter | Structural | Make an existing interface fit the one a client expects | A small wrapping function |
| Iterator | Behavioral | Traverse a collection without exposing its structure | Generators 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.
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))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.
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)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
- Polling: 200 requests every 5 seconds is 200 × 12 = 2,400 requests per minute, each with a reply.
- Observer: about 10 notifications per minute, one per change.
- Polling also delays updates by up to 5 seconds; notifications arrive as soon as a change happens.
- 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.
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))'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.
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")){'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 behavior 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.
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__)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
- There is one input format, one output format and one consumer of progress: nothing varies.
- Each pattern adds indirection that a reader must trace, with no flexibility gained.
- A function convert(path) that reads, transforms and writes, plus a print for progress, does the job in a few lines.
- 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 behavior 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 behavior 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 behavior.
- 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 cataloged 23 object-oriented patterns.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Write a strategy-based function `apply_discount(total, rule)` and two rules, ten percent 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)`.
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.
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.
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()`.
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`.
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.
What problem does the decorator pattern solve that subclassing solves poorly?
Show answer
Adding combinations of behaviors to individual objects at run time without a subclass for every combination.
Which pattern does `sorted(items, key=len)` use?
Show answer
Strategy: the key function is the interchangeable algorithm used for comparison.
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.
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.
Create a free account to save your progress, take quizzes and earn a certificate.