Python · lesson 32 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Also part of: Low-Level Design
Python Design Patterns
Run singleton, factory, observer, strategy, decorator and command patterns in idiomatic Python, and see where functions and modules replace classes.
By SkillByExample · Updated
Design patterns are named solutions to recurring design problems. Python’s first-class functions and modules make several of them much smaller than their textbook Java versions. Output below is from Python 3.13.
Singleton
A class that always hands back the same instance — overriding __new__ is the classic way, and it has a trap.
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
self.value = value
s1 = Singleton("first")
s2 = Singleton("second")
print(s1 is s2, s1.value)
$ python singleton.py
True second
__new__ returned the same object both times, but Python still calls __init__ on every construction, so the second call overwrote value. Guarding __init__ fixes that, but the more Pythonic singleton needs no class tricks at all:
# settings.py
class Settings:
def __init__(self):
self.debug = False
settings = Settings() # created once, on first import
import settings
from settings import settings as s
s.debug = True
import settings as again # a second import returns the cached module
print(again.settings.debug, again is settings)
$ python use_settings.py
True True
Python caches modules in sys.modules, so every import settings gets the same module object and therefore the same settings instance.
Factory
A factory chooses which class to build from data — in Python, a dict of classes is usually enough.
class Email:
def __init__(self, address: str):
self.address = address
def send(self, msg: str) -> None:
print(f"email to {self.address}: {msg}")
class SMS:
def __init__(self, phone: str):
self.phone = phone
def send(self, msg: str) -> None:
print(f"sms to {self.phone}: {msg}")
CHANNELS = {"email": Email, "sms": SMS}
def create_notifier(kind: str, **kwargs):
try:
return CHANNELS[kind](**kwargs)
except KeyError:
raise ValueError(f"unknown channel: {kind!r}") from None
for kind, args in [("email", {"address": "a@example.com"}), ("sms", {"phone": "555-0100"})]:
create_notifier(kind, **args).send("order shipped")
try:
create_notifier("fax")
except ValueError as e:
print(e)
$ python factory.py
email to a@example.com: order shipped
sms to 555-0100: order shipped
unknown channel: 'fax'
The caller names a channel as a string — say, from a config file — and never imports Email or SMS. Adding a channel means one new class and one dict entry. from None hides the internal KeyError so the traceback shows only the meaningful error.
Observer
Subscribers register callbacks; the subject calls them all when an event fires.
from collections import defaultdict
from typing import Any, Callable
class EventEmitter:
def __init__(self):
self._listeners: defaultdict[str, list[Callable]] = defaultdict(list)
def on(self, event: str, listener: Callable) -> None:
self._listeners[event].append(listener)
def off(self, event: str, listener: Callable) -> None:
self._listeners[event].remove(listener)
def emit(self, event: str, *args: Any) -> None:
for listener in list(self._listeners[event]):
listener(*args)
def confirm(order): print(f"confirmation for order {order['id']}")
def charge(order): print(f"charging ${order['total']:.2f}")
bus = EventEmitter()
bus.on("order_placed", confirm)
bus.on("order_placed", charge)
bus.emit("order_placed", {"id": 42, "total": 99.99})
bus.off("order_placed", charge)
bus.emit("order_placed", {"id": 43, "total": 5})
bus.emit("nobody_listens")
$ python observer.py
confirmation for order 42
charging $99.99
confirmation for order 43
Listeners run in registration order. After off, order 43 only got a confirmation, and an event with no listeners is a silent no-op. emit iterates over a copy of the list so a listener can unsubscribe itself mid-emit without skipping its neighbour.
Strategy
A strategy is an interchangeable algorithm. In Python, pass a function.
def by_price(item): return item["price"]
def by_name(item): return item["name"]
def by_value(item): return item["rating"] / item["price"]
products = [
{"name": "kettle", "price": 30, "rating": 4.5},
{"name": "blender", "price": 80, "rating": 4.8},
{"name": "airfryer", "price": 60, "rating": 4.9},
]
def show(strategy, reverse=False):
ordered = sorted(products, key=strategy, reverse=reverse)
print(f"{strategy.__name__:9}", [p["name"] for p in ordered])
show(by_price)
show(by_name)
show(by_value, reverse=True)
$ python strategy.py
by_price ['kettle', 'airfryer', 'blender']
by_name ['airfryer', 'blender', 'kettle']
by_value ['kettle', 'airfryer', 'blender']
sorted’s key= is the strategy pattern: the sorting algorithm stays fixed and the comparison rule is swapped in. Note that the cheap kettle wins on rating-per-dollar even though it has the lowest rating. A class per strategy would only be worth it if strategies carried their own state.
Decorator (the object pattern)
Wrappers that share an interface can be stacked at runtime, each adding one behaviour.
import base64
import zlib
class MemorySource:
def __init__(self): self.stored = ""
def write(self, data: str) -> None: self.stored = data
def read(self) -> str: return self.stored
class Reversing: # toy "encryption"
def __init__(self, inner): self.inner = inner
def write(self, data: str) -> None: self.inner.write(data[::-1])
def read(self) -> str: return self.inner.read()[::-1]
class Compressing:
def __init__(self, inner): self.inner = inner
def write(self, data: str) -> None:
self.inner.write(base64.b64encode(zlib.compress(data.encode())).decode())
def read(self) -> str:
return zlib.decompress(base64.b64decode(self.inner.read())).decode()
raw = MemorySource()
source = Compressing(Reversing(raw))
source.write("hello hello hello hello")
print(raw.stored)
print(source.read())
$ python wrappers.py
==QsIMAaBcCQIfVyJ3MSLzJe
hello hello hello hello
The caller sees only write and read. Underneath, the text was compressed, base64-encoded, then reversed — which is why the stored string starts with ==, base64’s padding. Reading peels the layers off in reverse.
Command
Each operation becomes an object with execute and undo; a history list gives you undo for free.
class Editor:
def __init__(self):
self.text = ""
self.history: list = []
def run(self, cmd) -> None:
cmd.execute(self)
self.history.append(cmd)
def undo(self) -> None:
if self.history:
self.history.pop().undo(self)
class Insert:
def __init__(self, s: str): self.s = s
def execute(self, ed: Editor) -> None: ed.text += self.s
def undo(self, ed: Editor) -> None: ed.text = ed.text[: -len(self.s)]
class Upper:
def execute(self, ed: Editor) -> None:
self.before = ed.text
ed.text = ed.text.upper()
def undo(self, ed: Editor) -> None: ed.text = self.before
ed = Editor()
for cmd in [Insert("hello"), Insert(", world"), Upper()]:
ed.run(cmd)
print(repr(ed.text))
ed.undo(); print("undo:", repr(ed.text))
ed.undo(); print("undo:", repr(ed.text))
$ python command.py
'hello'
'hello, world'
'HELLO, WORLD'
undo: 'hello, world'
undo: 'hello'
Insert can undo by trimming, but Upper can’t be inverted (it loses the original case), so it saves the previous text when it executes. Each command stores exactly what it needs to reverse itself.
Practice
1. Fix singleton.py so the second construction does not overwrite value, and print s1.value again.
Solution
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
if not hasattr(self, "value"):
self.value = value
s1 = Singleton("first")
s2 = Singleton("second")
print(s1 is s2, s1.value)
$ python singleton_fixed.py
True first
__init__ still runs twice; the guard just makes the second run do nothing.
2. Add a "push" channel to the factory that takes no arguments, without changing create_notifier.
Solution
class SMS:
def __init__(self, phone: str):
self.phone = phone
def send(self, msg: str) -> None:
print(f"sms to {self.phone}: {msg}")
CHANNELS = {"sms": SMS}
def create_notifier(kind: str, **kwargs):
try:
return CHANNELS[kind](**kwargs)
except KeyError:
raise ValueError(f"unknown channel: {kind!r}") from None
# the extension: a new class and one registry entry
class Push:
def send(self, msg: str) -> None:
print(f"push: {msg}")
CHANNELS["push"] = Push
create_notifier("push").send("order shipped")
create_notifier("sms", phone="555-0100").send("order shipped")
$ python factory_push.py
push: order shipped
sms to 555-0100: order shipped
The factory is open for extension through the registry — only the dict changed.
3. Add a once(event, listener) method to EventEmitter that removes the listener after its first call.
Solution
from collections import defaultdict
class EventEmitter:
def __init__(self):
self._listeners = defaultdict(list)
def on(self, event, listener): self._listeners[event].append(listener)
def off(self, event, listener): self._listeners[event].remove(listener)
def once(self, event, listener):
def wrapper(*args):
self.off(event, wrapper)
listener(*args)
self.on(event, wrapper)
def emit(self, event, *args):
for listener in list(self._listeners[event]):
listener(*args)
bus = EventEmitter()
bus.once("ready", lambda: print("first ready only"))
bus.on("ready", lambda: print("every ready"))
bus.emit("ready")
bus.emit("ready")
$ python once.py
first ready only
every ready
every ready
The wrapper unsubscribes itself during emit — which only works because emit loops over a copy of the list.
4. Add redo to the command example: undone commands go onto a redo stack, and redo() re-executes the most recent one.
Solution
class Editor:
def __init__(self):
self.text, self.history, self.redo_stack = "", [], []
def run(self, cmd):
cmd.execute(self)
self.history.append(cmd)
self.redo_stack.clear()
def undo(self):
cmd = self.history.pop()
cmd.undo(self)
self.redo_stack.append(cmd)
def redo(self):
cmd = self.redo_stack.pop()
cmd.execute(self)
self.history.append(cmd)
class Insert:
def __init__(self, s): self.s = s
def execute(self, ed): ed.text += self.s
def undo(self, ed): ed.text = ed.text[: -len(self.s)]
ed = Editor()
ed.run(Insert("a")); ed.run(Insert("b"))
ed.undo(); print(ed.text)
ed.redo(); print(ed.text)
$ python redo.py
a
ab
Running a new command clears the redo stack, as every editor does — redoing after a fresh edit would replay history that no longer applies.