Day 26: Design Patterns in Python
π Day 26: Design Patterns in Python
1. Learning Objectives
By the end of Day 26, you will be able to:
- Understand what design patterns are and why they matter
- Implement common patterns: Singleton, Factory, Observer, Strategy
- Apply patterns in a Pythonic way — using modules, decorators, and first‑class functions
- Recognize when a pattern solves a real problem vs. when it’s over‑engineering
- Combine patterns into a clean, maintainable architecture
2. Concept Explanation
2.1 What Are Design Patterns?
Design patterns are reusable solutions to common software problems. They’re not code you copy‑paste, but templates that you adapt to your specific need.
Analogy:
A design pattern is like a recipe for a cake. It tells you the steps and ingredients, but you still need to bake it.
Patterns usually fall into three categories:
| Category | Purpose | Examples |
|---|---|---|
| Creational | How objects are created | Singleton, Factory |
| Structural | How objects are composed | Adapter, Decorator (already covered!) |
| Behavioral | How objects communicate | Observer, Strategy |
⚠️ A word of caution: Not every problem needs a formal design pattern. Many patterns emerge naturally from good code. Use them where they simplify, not where they add unnecessary complexity.
Python’s dynamic nature means some classic patterns from languages like Java are trivial or unnecessary in Python. We’ll focus on the ones that deliver the most value.
2.2 Singleton — One and Only One Instance
The Singleton pattern ensures a class has exactly one instance and provides a global point of access to it.
When to use:
- A shared configuration object
- A logger
- A database connection pool
NaΓ―ve (Java‑style) implementation:
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
# Test
a = Singleton()
b = Singleton()
print(a is b) # True
Pythonic way #1: Module as Singleton
In Python, a module is already a singleton — it’s imported once and shared everywhere. Simply define the instance at module level:
# config.py
class Config:
def __init__(self):
self.debug = False
config = Config() # The one and only instance
# other_module.py
from config import config
print(config.debug)
Pythonic way #2: Decorator
def singleton(cls):
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class Logger:
def log(self, msg):
print(f"LOG: {msg}")
logger1 = Logger()
logger2 = Logger()
print(logger1 is logger2) # True
2.3 Factory — Creating Objects Without Specifying the Exact Class
The Factory pattern centralises object creation, making it easy to change what objects are built without modifying client code.
Simple Factory:
class Dog:
def speak(self): return "Woof!"
class Cat:
def speak(self): return "Meow!"
class AnimalFactory:
@staticmethod
def create_animal(animal_type):
if animal_type == "dog":
return Dog()
elif animal_type == "cat":
return Cat()
raise ValueError("Unknown animal")
# Client code
animal = AnimalFactory.create_animal("dog")
print(animal.speak()) # Woof!
Pythonic way: Using a function or dictionary
Python’s first‑class functions make factories trivial:
def create_animal(animal_type):
animals = {
"dog": Dog,
"cat": Cat,
}
klass = animals.get(animal_type)
if not klass:
raise ValueError("Unknown animal")
return klass()
This is cleaner and more extensible than a separate factory class.
2.4 Observer — One-to-Many Dependency
The Observer pattern defines a subscription mechanism so that when one object (the subject) changes state, all its dependents (the observers) are notified automatically.
Classic implementation:
class Subject:
def __init__(self):
self._observers = []
def attach(self, observer):
self._observers.append(observer)
def detach(self, observer):
self._observers.remove(observer)
def notify(self, message):
for observer in self._observers:
observer.update(message)
class Observer:
def update(self, message):
pass
class Logger(Observer):
def update(self, message):
print(f"Logger: {message}")
class Emailer(Observer):
def update(self, message):
print(f"Emailer: {message}")
# Usage
subject = Subject()
subject.attach(Logger())
subject.attach(Emailer())
subject.notify("Event happened")
Pythonic way: Use decorators or callbacks
Often, you just need a list of callbacks, which Python handles naturally:
class EventManager:
def __init__(self):
self._listeners = []
def subscribe(self, callback):
self._listeners.append(callback)
def notify(self, event, data):
for callback in self._listeners:
callback(event, data)
# Usage
def log_event(event, data):
print(f"[LOG] {event}: {data}")
def email_event(event, data):
print(f"[EMAIL] {event}: {data}")
em = EventManager()
em.subscribe(log_event)
em.subscribe(email_event)
em.notify("user_login", {"username": "alice"})
This leverages Python’s function‑as‑object nature and is much lighter than full‑class hierarchies.
2.5 Strategy — Selecting an Algorithm at Runtime
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable.
Classic implementation:
class Sorter:
def __init__(self, strategy):
self._strategy = strategy
def sort(self, data):
return self._strategy.sort(data)
class BubbleSort:
def sort(self, data):
# ... bubble sort
pass
class QuickSort:
def sort(self, data):
# ... quick sort
pass
Pythonic way: Pass a function directly
Because Python functions are objects, you can just pass the sorting function:
def bubble_sort(data):
# implementation
pass
def quick_sort(data):
# implementation
pass
def sort_data(data, algorithm):
return algorithm(data)
sorted_data = sort_data(my_list, quick_sort)
Again, the pattern collapses into a simple higher‑order function.
2.6 Mixins — Python’s Secret Weapon for Code Reuse
Not a Gang‑of‑Four pattern, but a Python‑specific technique. A mixin is a class that provides methods to other classes via multiple inheritance, without being a base class on its own.
class LogMixin:
def log(self, message):
print(f"[{self.__class__.__name__}] {message}")
class PaymentProcessor(LogMixin):
def process(self, amount):
self.log(f"Processing {amount}")
# ...
pp = PaymentProcessor()
pp.process(100) # [PaymentProcessor] Processing 100
Mixins keep your code DRY without deep inheritance chains.
2.7 When to Use Patterns — Decision Guide
| Pattern | Use When |
|---|---|
| Singleton | Exactly one instance needed; shared resource |
| Factory | Object creation logic is complex or needs to be centralised |
| Observer | One object’s state change must update many others |
| Strategy | Multiple algorithms for a task; choose at runtime |
| Decorator (Day 17) | Adding behaviour to individual functions without modifying them |
If you find yourself forcing a pattern where a simple function or dictionary would do, reconsider. Python rewards clarity.
3. Code Examples — Full Working Snippets
Example 1: Singleton Config Manager (Module style)
# config_manager.py
class ConfigManager:
"""Manage application settings."""
def __init__(self):
self.settings = {}
def set(self, key, value):
self.settings[key] = value
def get(self, key, default=None):
return self.settings.get(key, default)
# The singleton instance
config = ConfigManager()
# main.py
from config_manager import config
config.set("theme", "dark")
print(config.get("theme")) # dark
Example 2: Simple Observer with Callbacks
class DataStore:
def __init__(self):
self._data = None
self._subscribers = []
def subscribe(self, callback):
self._subscribers.append(callback)
def set_data(self, value):
self._data = value
for cb in self._subscribers:
cb(value)
# Subscriber functions
def on_data_change(value):
print(f"Data changed to {value}")
store = DataStore()
store.subscribe(on_data_change)
store.set_data(42) # Output: Data changed to 42
Example 3: Strategy with lambda
def apply_discount(price, discount_func):
return discount_func(price)
# Different discount strategies as lambdas
ten_percent = lambda p: p * 0.9
flat_five = lambda p: max(0, p - 5)
print(apply_discount(100, ten_percent)) # 90.0
print(apply_discount(100, flat_five)) # 95
4. Hands-On Exercises
Exercise 1: Thread‑Safe Singleton Logger
Implement a Logger class using the @singleton decorator (from the lesson). Add a log() method that appends messages to a list. Verify that two variables referencing Logger() are the same object.
Exercise 2: Simple Shape Factory
Write a factory function create_shape(shape_type, **kwargs) that returns an instance of Circle or Rectangle based on the string. Use a dictionary to map name to class. Test by creating a circle with radius=5 and rectangle with width=4, height=6.
Exercise 3: Notification System (Observer)
Build a NotificationCenter class with subscribe(event_type, callback) and post(event_type, message). Create two callbacks: one that prints to console, and another that writes to a log file. Post a few events.
Exercise 4: Strategy for Text Filtering
Define functions remove_spaces(text), to_upper(text), and replace_bad_words(text). Write a function process_text(text, pipeline) that applies a list of filter functions in order. Process a sample text.
Exercise 5: Mixin for Serialisation
Create a JsonSerializableMixin that adds a to_json() method to any class that inherits it. The method should return a JSON string of the object’s __dict__. Test with a class Product(name, price).
5. Applied Challenge Task π️
Event‑Driven Trading Simulator
Design a mini trading system using Observer and Strategy patterns.
Requirements:
StockMarket (Subject)
- Holds a dictionary of stock symbols and their prices.
update_price(symbol, new_price)updates the price and notifies all observers.- Observers can be any callable that receives
(symbol, new_price).
Observers:
PriceLogger: prints every price change to the console.AlertSystem: if price drops below a threshold, prints an alert. The threshold should be configurable when subscribing.
TradingStrategy:
Use the Strategy pattern to decide when to buy/sell.
Implement at least two strategies:
MomentumStrategy: buy if price increases by more than 5% in a single update.MeanReversionStrategy: buy if price drops more than 10% from its last high.
The strategy should be a callable that takes the price history (list) and returns
"BUY","SELL", or"HOLD".
Simulation:
- Initialise a StockMarket with a few symbols.
- Attach a
PriceLogger, anAlertSystemwith threshold=90, and a trader that usesMomentumStrategy. - Simulate several price updates and observe the output.
Stretch goals:
- Make the observers and strategies configurable via a JSON file.
- Use a Factory to create strategies based on a name string.
- Add a graphical display (preview of Day 28) using simple ASCII charts.
6. Brief Review Summary
| Pattern | Pythonic Implementation | Key Benefit |
|---|---|---|
| Singleton | Module‑level instance, decorator | Controlled single access point |
| Factory | Dictionary mapping, function | Centralised, flexible object creation |
| Observer | List of callbacks, decorator | Loose coupling between components |
| Strategy | Passing functions, lambdas | Swappable algorithms at runtime |
| Mixin | Multiple inheritance with simple classes | Reusable behaviour without deep hierarchies |
7. Preview of Next Topic — Day 27
Tomorrow we’ll make our applications web‑connected:
- Building REST APIs with Flask or FastAPI
- Routing, request/response handling
- JSON serialisation
- Testing APIs with
pytestandrequests - Choosing between Flask (simple) and FastAPI (modern, async)
π― Your Action Items for Day 26:
- ✅ Complete all 5 exercises
- ✅ Build the Event‑Driven Trading Simulator challenge
- ✅ Refactor one of your previous projects to use a Singleton or Observer (if appropriate)
- ✅ Identify one pattern you’ve already used unknowingly (hint: Decorator from Day 17!)
Comments
Post a Comment
Leave us your comments here...