Table of Contents
- Understanding the Decorator Pattern
- When to Use the Decorator Pattern
- Decorator Pattern in Python: Key Concepts
- Step-by-Step Implementation Guide
- Advanced Use Cases
- Common Pitfalls and Best Practices
- Conclusion
- References
1. Understanding the Decorator Pattern
The Decorator Pattern is one of the 23 classic design patterns described in the “Design Patterns: Elements of Reusable Object-Oriented Software” (Gang of Four, or GoF) book. Its primary intent is to:
“Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.”
Core Idea: Wrapping Objects
At its heart, the Decorator Pattern involves wrapping an object (or function) with another object (or function) to add new behavior. The wrapped object remains unaware of the decorator, and decorators can be combined to add multiple layers of functionality.
Analogy: Adding Toppings to a Pizza
Imagine a pizza shop. A basic pizza (e.g., Margherita) has a base cost and description. Customers can add toppings like cheese, pepperoni, or mushrooms. Instead of creating a separate subclass for every possible combination (e.g., PepperoniPizza, CheeseAndMushroomPizza), we can use decorators. Each topping is a decorator that wraps the pizza, adding to its cost and description.
2. When to Use the Decorator Pattern
Use the Decorator Pattern when:
- You need to add responsibilities to individual objects dynamically (without affecting other objects of the same class).
- Subclassing is impractical (e.g., too many combinations of features would lead to a “class explosion”).
- You want to keep core functionality clean and separate cross-cutting concerns (e.g., logging, caching, validation, or authentication).
Common Real-World Examples:
- Logging: Wrapping a function to log input/output or execution time.
- Caching: Storing results of expensive function calls to avoid recomputation.
- Authentication: Checking if a user is authenticated before allowing access to a function.
- UI Frameworks: Adding borders, shadows, or scrollbars to UI components dynamically.
3. Decorator Pattern in Python: Key Concepts
Python supports decorators natively, but it’s helpful to first understand the classic GoF structure, then see how Python’s implementation aligns with it.
Classic GoF Structure
The pattern consists of four main components:
- Component: An interface (or abstract class) defining the methods that both the concrete components and decorators must implement.
- Concrete Component: The base object to which responsibilities are added (e.g.,
BasicCoffee). - Decorator (Base): An abstract class that wraps a
Componentand implements theComponentinterface. It delegates most work to the wrapped component but allows decorators to override or extend behavior. - Concrete Decorators: Classes that extend the
Decoratorbase, adding specific responsibilities (e.g.,MilkDecorator,SugarDecorator).
Python’s Twist: Function Decorators
Python simplifies decorators by treating functions as first-class citizens. A function decorator is a function that wraps another function to modify its behavior. Python’s @ syntax makes applying decorators concise.
4. Step-by-Step Implementation Guide
Let’s implement the Decorator Pattern in Python with two practical examples: an object-oriented coffee shop and function decorators for logging.
Example 1: Object-Oriented Decorators (Coffee Shop)
Let’s model a coffee shop where customers can order a basic coffee and add extras like milk, sugar, or whipped cream.
Step 1: Define the Component Interface
First, create an abstract base class (ABC) for the Coffee component, with methods cost() (returns the price) and description() (returns the coffee’s description).
from abc import ABC, abstractmethod
class Coffee(ABC):
@abstractmethod
def cost(self) -> float:
pass
@abstractmethod
def description(self) -> str:
pass
Step 2: Create a Concrete Component
Implement a BasicCoffee class that inherits from Coffee and provides base values.
class BasicCoffee(Coffee):
def cost(self) -> float:
return 2.0 # $2.00 for basic coffee
def description(self) -> str:
return "Basic Coffee"
Step 3: Define the Decorator Base Class
Create an abstract CoffeeDecorator class that wraps a Coffee object and delegates cost() and description() to the wrapped coffee.
class CoffeeDecorator(Coffee):
def __init__(self, coffee: Coffee):
self._coffee = coffee # Wrap the coffee
@abstractmethod
def cost(self) -> float:
return self._coffee.cost() # Delegate to wrapped coffee
@abstractmethod
def description(self) -> str:
return self._coffee.description() # Delegate to wrapped coffee
Step 4: Implement Concrete Decorators
Create decorators for milk, sugar, and whipped cream. Each will add to the cost and description.
class MilkDecorator(CoffeeDecorator):
def cost(self) -> float:
return self._coffee.cost() + 0.5 # Add $0.50 for milk
def description(self) -> str:
return f"{self._coffee.description()}, Milk"
class SugarDecorator(CoffeeDecorator):
def cost(self) -> float:
return self._coffee.cost() + 0.25 # Add $0.25 for sugar
def description(self) -> str:
return f"{self._coffee.description()}, Sugar"
class WhippedCreamDecorator(CoffeeDecorator):
def cost(self) -> float:
return self._coffee.cost() + 0.75 # Add $0.75 for whipped cream
def description(self) -> str:
return f"{self._coffee.description()}, Whipped Cream"
Step 5: Use the Decorators
Now, create a coffee with milk and sugar by wrapping BasicCoffee in the decorators.
# Order a basic coffee with milk and sugar
coffee = BasicCoffee()
coffee = MilkDecorator(coffee) # Wrap with milk
coffee = SugarDecorator(coffee) # Wrap with sugar
print(f"Description: {coffee.description()}") # Output: Basic Coffee, Milk, Sugar
print(f"Cost: ${coffee.cost():.2f}") # Output: Cost: $2.75
Result: The final coffee costs $2.75 (base $2.00 + $0.50 milk + $0.25 sugar) and has the description “Basic Coffee, Milk, Sugar”.
Example 2: Function Decorators (Logging)
Python’s function decorators are ideal for adding cross-cutting concerns like logging. Let’s create a decorator that logs when a function is called and its return value.
Step 1: Define the Decorator
A decorator is a function that takes a function as input and returns a new function (the “wrapper”) that adds behavior.
def log_function(func):
def wrapper(*args, **kwargs):
# Log before execution
print(f"Calling function: {func.__name__}")
print(f"Arguments: args={args}, kwargs={kwargs}")
# Execute the original function
result = func(*args, **kwargs)
# Log after execution
print(f"Function {func.__name__} returned: {result}")
return result
return wrapper
Step 2: Apply the Decorator with @ Syntax
Use the @log_function syntax to apply the decorator to a function.
@log_function
def add(a: int, b: int) -> int:
return a + b
@log_function
def greet(name: str) -> str:
return f"Hello, {name}!"
Step 3: Test the Decorated Functions
Call the functions and observe the logging output.
add(2, 3)
# Output:
# Calling function: add
# Arguments: args=(2, 3), kwargs={}
# Function add returned: 5
greet(name="Alice")
# Output:
# Calling function: greet
# Arguments: args=(), kwargs={'name': 'Alice'}
# Function greet returned: Hello, Alice!
Pitfall: Lost Metadata
By default, the wrapper function hides metadata like __name__ and __doc__ of the original function. Fix this with functools.wraps:
from functools import wraps
def log_function(func):
@wraps(func) # Preserve original function metadata
def wrapper(*args, **kwargs):
print(f"Calling function: {func.__name__}")
result = func(*args, **kwargs)
print(f"Function {func.__name__} returned: {result}")
return result
return wrapper
@log_function
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
print(add.__name__) # Output: add (not 'wrapper'!)
print(add.__doc__) # Output: Add two numbers.
5. Advanced Use Cases
Chaining Decorators
You can apply multiple decorators to a single function. The order matters: decorators are applied from bottom to top.
def uppercase_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
def exclamation_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f"{result}!"
return wrapper
@exclamation_decorator
@uppercase_decorator # Applied first (bottom to top)
def greet(name: str) -> str:
return f"hello {name}"
print(greet("alice")) # Output: HELLO ALICE!
Explanation: uppercase_decorator runs first (converts to uppercase), then exclamation_decorator adds ”!“.
Decorators with Arguments
To create a decorator that accepts arguments, use a decorator factory: a function that takes arguments, then returns the actual decorator.
Example: A retry decorator that retries a function n times on failure.
from functools import wraps
import time
def retry(n: int, delay: float = 1.0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, n+1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == n:
raise # Raise after last attempt
print(f"Attempt {attempt} failed. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(n=3, delay=2) # Retry 3 times with 2s delay
def risky_operation():
import random
if random.random() < 0.7: # 70% chance of failure
raise ValueError("Oops, failed!")
return "Success!"
risky_operation()
Class Decorators
Class decorators modify class behavior (e.g., adding methods, overriding attributes). They take a class as input and return a modified class.
Example: A decorator that adds a greet() method to a class.
def add_greet_method(cls):
def greet(self):
return f"Hello from {self.__class__.__name__}!"
cls.greet = greet # Add the method to the class
return cls
@add_greet_method
class MyClass:
pass
obj = MyClass()
print(obj.greet()) # Output: Hello from MyClass!
6. Common Pitfalls and Best Practices
Pitfalls to Avoid:
- Lost Metadata: Always use
functools.wrapsto preserve function metadata (name, docstring, etc.). - Overcomplicating Decorators: Decorators should follow the Single Responsibility Principle—do one thing well.
- Debugging Challenges: Chaining many decorators can make stack traces harder to read. Use tools like
inspectto trace execution. - Idempotency: Ensure decorators can be applied multiple times without unintended side effects.
Best Practices:
- Keep Decorators Simple: Focus on a single task (e.g., logging, caching).
- Document Decorators: Explain what the decorator does, especially if it modifies behavior significantly.
- Test Decorators: Test edge cases (e.g., empty inputs, exceptions) for decorators that handle errors.
- Use Libraries for Common Tasks: For logging, caching, or retries, use battle-tested libraries like
functools.lru_cacheortenacityinstead of writing your own.
7. Conclusion
The Decorator Pattern is a powerful tool for adding functionality dynamically and cleanly. In Python, it’s implemented elegantly through both object-oriented decorators (for wrapping objects) and function decorators (for wrapping functions). By using decorators, you can keep core logic focused, separate cross-cutting concerns, and avoid rigid class hierarchies.
Whether you’re adding logging to functions, dynamically extending object behavior, or creating reusable components, the Decorator Pattern helps write flexible, maintainable code. Start small—experiment with simple function decorators, then move to more advanced use cases like decorators with arguments or class decorators.
8. References
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.
- Python Official Documentation: Decorators
- Python Official Documentation: functools.wraps
- Real Python: Primer on Python Decorators
- Tenacity (Retry Library): GitHub