Design patterns are reusable solutions to common programming problems. They represent best practices evolved over time by experienced developers. In Python, some patterns are built into the language itself, while others can significantly improve code organization, maintainability, and readability.
1. Singleton Pattern
Ensures a class has only one instance and provides global access to it.
class DatabaseConnection:
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not self._initialized:
self.connection = "Database connected"
self._initialized = True
def query(self, sql):
return f"Executing: {sql}"
# Usage
db1 = DatabaseConnection()
db2 = DatabaseConnection()
print(db1 is db2) # True - same instance
# Alternative using 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 __init__(self):
self.logs = []
def log(self, message):
self.logs.append(message)
2. Factory Pattern
Creates objects without specifying their exact classes.
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def make_sound(self):
pass
class Dog(Animal):
def make_sound(self):
return "Woof!"
class Cat(Animal):
def make_sound(self):
return "Meow!"
class Bird(Animal):
def make_sound(self):
return "Tweet!"
class AnimalFactory:
@staticmethod
def create_animal(animal_type):
animals = {
'dog': Dog,
'cat': Cat,
'bird': Bird
}
animal_class = animals.get(animal_type.lower())
if animal_class:
return animal_class()
else:
raise ValueError(f"Unknown animal type: {animal_type}")
# Usage
factory = AnimalFactory()
dog = factory.create_animal('dog')
cat = factory.create_animal('cat')
print(dog.make_sound()) # "Woof!"
print(cat.make_sound()) # "Meow!"
3. Observer Pattern
Defines a one-to-many dependency between objects.
class Subject:
def __init__(self):
self._observers = []
self._state = None
def attach(self, observer):
if observer not in self._observers:
self._observers.append(observer)
def detach(self, observer):
if observer in self._observers:
self._observers.remove(observer)
def notify(self):
for observer in self._observers:
observer.update(self)
def set_state(self, state):
self._state = state
self.notify()
def get_state(self):
return self._state
class Observer(ABC):
@abstractmethod
def update(self, subject):
pass
class EmailNotifier(Observer):
def __init__(self, name):
self.name = name
def update(self, subject):
print(f"{self.name} received email: State changed to {subject.get_state()}")
class SMSNotifier(Observer):
def __init__(self, name):
self.name = name
def update(self, subject):
print(f"{self.name} received SMS: State changed to {subject.get_state()}")
# Usage
news_agency = Subject()
email_notifier = EmailNotifier("John")
sms_notifier = SMSNotifier("Jane")
news_agency.attach(email_notifier)
news_agency.attach(sms_notifier)
news_agency.set_state("Breaking News: Python 4.0 Released!")
4. Decorator Pattern
Python’s built-in decorator syntax makes this pattern elegant.
import functools
import time
from datetime import datetime
def timing_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
def retry(max_attempts=3, delay=1):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise e
print(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay} seconds...")
time.sleep(delay)
return wrapper
return decorator
class cache:
def __init__(self, expiration_time=300): # 5 minutes default
self.expiration_time = expiration_time
self.cache_data = {}
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = str(args) + str(sorted(kwargs.items()))
if key in self.cache_data:
result, timestamp = self.cache_data[key]
if time.time() - timestamp < self.expiration_time:
print(f"Cache hit for {func.__name__}")
return result
result = func(*args, **kwargs)
self.cache_data[key] = (result, time.time())
print(f"Cache miss for {func.__name__}")
return result
return wrapper
# Usage
@timing_decorator
@cache(expiration_time=60)
@retry(max_attempts=3, delay=0.5)
def expensive_operation(n):
time.sleep(1) # Simulate expensive operation
return n * n
print(expensive_operation(5)) # Cache miss, takes ~1 second
print(expensive_operation(5)) # Cache hit, much faster
5. Strategy Pattern
Defines a family of algorithms and makes them interchangeable.
from abc import ABC, abstractmethod
class PaymentStrategy(ABC):
@abstractmethod
def pay(self, amount):
pass
class CreditCardPayment(PaymentStrategy):
def __init__(self, card_number, cvv):
self.card_number = card_number
self.cvv = cvv
def pay(self, amount):
return f"Paid ${amount} using Credit Card ending in {self.card_number[-4:]}"
class PayPalPayment(PaymentStrategy):
def __init__(self, email):
self.email = email
def pay(self, amount):
return f"Paid ${amount} using PayPal account {self.email}"
class BankTransferPayment(PaymentStrategy):
def __init__(self, account_number):
self.account_number = account_number
def pay(self, amount):
return f"Paid ${amount} using Bank Transfer from account {self.account_number}"
class PaymentProcessor:
def __init__(self, strategy: PaymentStrategy):
self._strategy = strategy
def set_strategy(self, strategy: PaymentStrategy):
self._strategy = strategy
def process_payment(self, amount):
return self._strategy.pay(amount)
# Usage
processor = PaymentProcessor(CreditCardPayment("1234567890123456", "123"))
print(processor.process_payment(100))
processor.set_strategy(PayPalPayment("user@example.com"))
print(processor.process_payment(50))
processor.set_strategy(BankTransferPayment("9876543210"))
print(processor.process_payment(200))
6. Command Pattern
Encapsulates requests as objects, allowing you to parameterize clients with different requests.
from abc import ABC, abstractmethod
import json
class Command(ABC):
@abstractmethod
def execute(self):
pass
@abstractmethod
def undo(self):
pass
class FileManager:
def __init__(self):
self.files = {}
def create_file(self, filename, content):
self.files[filename] = content
print(f"Created file: {filename}")
def delete_file(self, filename):
if filename in self.files:
del self.files[filename]
print(f"Deleted file: {filename}")
def modify_file(self, filename, new_content):
if filename in self.files:
old_content = self.files[filename]
self.files[filename] = new_content
print(f"Modified file: {filename}")
return old_content
class CreateFileCommand(Command):
def __init__(self, file_manager, filename, content):
self.file_manager = file_manager
self.filename = filename
self.content = content
def execute(self):
self.file_manager.create_file(self.filename, self.content)
def undo(self):
self.file_manager.delete_file(self.filename)
class DeleteFileCommand(Command):
def __init__(self, file_manager, filename):
self.file_manager = file_manager
self.filename = filename
self.deleted_content = None
def execute(self):
self.deleted_content = self.file_manager.files.get(self.filename)
self.file_manager.delete_file(self.filename)
def undo(self):
if self.deleted_content is not None:
self.file_manager.create_file(self.filename, self.deleted_content)
class FileManagerInvoker:
def __init__(self):
self.history = []
self.current_position = -1
def execute_command(self, command):
# Remove any commands after current position
self.history = self.history[:self.current_position + 1]
command.execute()
self.history.append(command)
self.current_position += 1
def undo(self):
if self.current_position >= 0:
command = self.history[self.current_position]
command.undo()
self.current_position -= 1
def redo(self):
if self.current_position < len(self.history) - 1:
self.current_position += 1
command = self.history[self.current_position]
command.execute()
# Usage
file_manager = FileManager()
invoker = FileManagerInvoker()
# Execute commands
create_cmd = CreateFileCommand(file_manager, "test.txt", "Hello World")
invoker.execute_command(create_cmd)
delete_cmd = DeleteFileCommand(file_manager, "test.txt")
invoker.execute_command(delete_cmd)
# Undo operations
invoker.undo() # Recreates the file
invoker.undo() # Removes the file again
invoker.redo() # Recreates the file
7. Builder Pattern
Constructs complex objects step by step.
class Pizza:
def __init__(self):
self.size = None
self.crust = None
self.toppings = []
self.cheese = None
def __str__(self):
return f"{self.size} {self.crust} pizza with {self.cheese} cheese and toppings: {', '.join(self.toppings)}"
class PizzaBuilder:
def __init__(self):
self.pizza = Pizza()
def set_size(self, size):
self.pizza.size = size
return self
def set_crust(self, crust):
self.pizza.crust = crust
return self
def add_topping(self, topping):
self.pizza.toppings.append(topping)
return self
def set_cheese(self, cheese):
self.pizza.cheese = cheese
return self
def build(self):
return self.pizza
class PizzaDirector:
@staticmethod
def make_margherita():
return (PizzaBuilder()
.set_size("Medium")
.set_crust("Thin")
.set_cheese("Mozzarella")
.add_topping("Tomato")
.add_topping("Basil")
.build())
@staticmethod
def make_pepperoni():
return (PizzaBuilder()
.set_size("Large")
.set_crust("Thick")
.set_cheese("Cheddar")
.add_topping("Pepperoni")
.add_topping("Mushrooms")
.build())
# Usage
# Manual building
custom_pizza = (PizzaBuilder()
.set_size("Small")
.set_crust("Stuffed")
.set_cheese("Parmesan")
.add_topping("Chicken")
.add_topping("BBQ Sauce")
.add_topping("Red Onions")
.build())
# Using director for common configurations
margherita = PizzaDirector.make_margherita()
pepperoni = PizzaDirector.make_pepperoni()
print(custom_pizza)
print(margherita)
print(pepperoni)
8. Context Manager Pattern
Python's context managers provide a clean way to manage resources.
import sqlite3
from contextlib import contextmanager
class DatabaseManager:
def __init__(self, db_name):
self.db_name = db_name
self.connection = None
def __enter__(self):
self.connection = sqlite3.connect(self.db_name)
return self.connection
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
self.connection.rollback()
else:
self.connection.commit()
self.connection.close()
@contextmanager
def file_manager(filename, mode='r'):
print(f"Opening file {filename}")
file = open(filename, mode)
try:
yield file
finally:
print(f"Closing file {filename}")
file.close()
@contextmanager
def timer_context():
import time
start = time.time()
try:
yield
finally:
end = time.time()
print(f"Operation took {end - start:.4f} seconds")
# Usage
with DatabaseManager('example.db') as db:
cursor = db.cursor()
cursor.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER, name TEXT)')
cursor.execute('INSERT INTO users VALUES (1, "John")')
with file_manager('test.txt', 'w') as f:
f.write('Hello, World!')
with timer_context():
time.sleep(1) # Some operation
When to Use Each Pattern
- Singleton: Database connections, loggers, configuration objects
- Factory: Creating objects based on user input or configuration
- Observer: Event systems, model-view architectures
- Decorator: Adding functionality to existing functions (logging, caching, authentication)
- Strategy: Algorithm selection, payment processing, data formatting
- Command: Undo/redo functionality, queuing operations, macro recording
- Builder: Complex object construction with many optional parameters
- Context Manager: Resource management, temporary state changes
Best Practices
- Don't overuse patterns: Apply them when they solve real problems
- Favor composition over inheritance: Python's duck typing makes this natural
- Use Python's built-in features: Many patterns are simplified by Python's syntax
- Keep it readable: The pattern should make code clearer, not more complex
- Test your patterns: Ensure they work correctly and provide the expected benefits
Design patterns are tools in your programming toolkit. Understanding when and how to apply them will make you a more effective Python developer, leading to more maintainable and robust code.
Further Reading and References
- Refactoring Guru - Design Patterns in Python — Comprehensive and beginner-friendly explanations of all classic design patterns with Python examples.
- SourceMaking - Design Patterns — Detailed explanations and UML diagrams of design patterns, language-agnostic but very helpful.
- Head First Design Patterns by Eric Freeman & Elisabeth Robson — Highly recommended book introducing design patterns in an engaging, easy-to-understand style.
- Refactoring: Improving the Design of Existing Code by Martin Fowler — Classic book covering refactoring and design principles that often complement design patterns.
- Real Python - Object-Oriented Programming in Python — A practical guide to OOP concepts in Python, which are foundational to understanding design patterns.
- Python’s contextlib Module Documentation — Official documentation on context managers and utilities, useful for the Context Manager pattern.
- Python’s functools Module Documentation — Contains decorators and tools useful in implementing patterns like Decorator and Singleton.
- GeeksforGeeks - Python Design Patterns — Collection of design pattern tutorials with code examples in Python.