Teacher's Guide
Chapter 6: Decorators
Teaching Objectives
By the end of this chapter, students should:
- Understand the concept of decorators in Python
- Master creating and using function decorators
- Learn how to implement decorators with arguments
- Comprehend class-based decorators
- Apply decorators to solve real-world problems
- Understand the internal mechanics of decorator execution
Preparation
Before teaching this chapter, ensure:
- Students have a solid understanding of functions as first-class objects
- Students are comfortable with function closures and nested functions
- You have prepared examples that demonstrate the power of decorators
- You have real-world examples of decorator usage ready
Lesson Overview
1. Introduction to Decorators (20 minutes)
Start by explaining the core concept:
- Decorators are a powerful and expressive feature in Python
- They allow you to modify the behavior of functions or classes without changing their code
- They follow the principle of open/closed: open for extension, closed for modification
- They are implemented using the concept of higher-order functions (functions that operate on other functions)
Basic example:
# A simple function
def greet(name):
return f"Hello, {name}!"
print(greet("Alice")) # Output: Hello, Alice!
# A decorator is a function that takes a function and returns a new function
def uppercase_decorator(func):
# Inner function that wraps the original function
def wrapper(*args, **kwargs):
# Call the original function
result = func(*args, **kwargs)
# Modify the result
return result.upper()
# Return the inner function
return wrapper
# Apply the decorator manually
decorated_greet = uppercase_decorator(greet)
print(decorated_greet("Bob")) # Output: HELLO, BOB!
# Python's decorator syntax
@uppercase_decorator
def greet_decorated(name):
return f"Hello, {name}!"
print(greet_decorated("Charlie")) # Output: HELLO, CHARLIE!
Teaching points:
- Decorators are just syntactic sugar for function composition
- The
@decoratorsyntax is equivalent tofunction = decorator(function) - Decorators take a function as input and return a new function
- The wrapped function can execute code before and after the original function
- The wrapper can modify the input arguments and return value
Multiple decorators:
def uppercase_decorator(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
def exclamation_decorator(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result + "!!!"
return wrapper
# Apply multiple decorators (order matters)
@exclamation_decorator
@uppercase_decorator
def greet(name):
return f"Hello, {name}"
print(greet("Dave")) # Output: HELLO, DAVE!!!
Teaching points:
- Multiple decorators can be stacked
- They are applied from bottom to top (the closest to the function is applied first)
- Equivalent to
function = decorator1(decorator2(function))
2. Preserving Function Metadata (15 minutes)
Explain the importance of function metadata:
import functools
def simple_decorator(func):
def wrapper(*args, **kwargs):
"""This is the wrapper function"""
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@simple_decorator
def hello(name):
"""Says hello to someone"""
return f"Hello, {name}!"
# Check function metadata
print(f"Function name: {hello.__name__}") # Output: wrapper (wrong!)
print(f"Function docstring: {hello.__doc__}") # Output: This is the wrapper function (wrong!)
# Fix it with functools.wraps
def better_decorator(func):
@functools.wraps(func) # Preserves metadata
def wrapper(*args, **kwargs):
"""This is the wrapper function"""
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@better_decorator
def hello_better(name):
"""Says hello to someone"""
return f"Hello, {name}!"
# Check function metadata again
print(f"Function name: {hello_better.__name__}") # Output: hello_better (correct!)
print(f"Function docstring: {hello_better.__doc__}") # Output: Says hello to someone (correct!)
Teaching points:
- Unmodified decorators lose the original function's metadata (name, docstring, etc.)
- This can cause problems with documentation tools and debugging
functools.wrapsis a decorator that preserves the original function's metadata- Always use
@functools.wrapswhen creating decorators
3. Decorators with Arguments (25 minutes)
Introduce more complex decorators:
import functools
# Decorator with fixed arguments
def repeat(n):
"""Repeat the function call n times"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
results = []
for _ in range(n):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator
@repeat(3)
def say_hello(name):
return f"Hello, {name}!"
print(say_hello("Alice")) # Calls say_hello 3 times
# Output: ['Hello, Alice!', 'Hello, Alice!', 'Hello, Alice!']
Teaching points:
- A decorator can take arguments
- It requires an additional level of nesting (a function that returns a decorator)
- The pattern is:
decorator_with_args -> decorator -> wrapper -> original_function
Decorator with flexible arguments:
def debug(func=None, *, prefix=''):
"""Print function call details.
Args:
func: The function to decorate
prefix: Optional prefix for debug messages
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
args_repr = [repr(a) for a in args]
kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
signature = ", ".join(args_repr + kwargs_repr)
print(f"{prefix}Calling {func.__name__}({signature})")
result = func(*args, **kwargs)
print(f"{prefix}{func.__name__} returned {result!r}")
return result
return wrapper
# If called as @debug (no args)
if func is not None:
return decorator(func)
# If called as @debug(prefix='>>>')
return decorator
# Use as @debug (no arguments)
@debug
def greet(name):
return f"Hello, {name}!"
# Use with arguments
@debug(prefix='>> ')
def add(a, b):
return a + b
greet("World")
# Output:
# Calling greet('World')
# greet returned 'Hello, World!'
add(3, 5)
# Output:
# >> Calling add(3, 5)
# >> add returned 8
Teaching points:
- Decorators can be flexible, working with or without arguments
- The
*in the parameter list forces subsequent parameters to be keyword-only - The pattern allows both
@debugand@debug(prefix='>> ')syntax - Use None checking to determine how the decorator was called
4. Practical Decorator Examples (25 minutes)
Show some common use cases:
Timing decorator:
import functools
import time
def timer(func):
"""Measure execution time of a function"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"{func.__name__} took {end_time - start_time:.6f} seconds to run")
return result
return wrapper
@timer
def slow_function():
"""A deliberately slow function"""
time.sleep(1)
return "Done!"
slow_function()
# Output: slow_function took 1.001234 seconds to run
Caching/Memoization decorator:
import functools
def memoize(func):
"""Cache the results of a function call"""
cache = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Create a key from the arguments
# We need a hashable key, so convert lists/dicts if needed
key_args = tuple(args)
key_kwargs = tuple(sorted(kwargs.items()))
cache_key = (key_args, key_kwargs)
# Check if result is already in cache
if cache_key not in cache:
print(f"Cache miss for {func.__name__}{args}, computing result...")
cache[cache_key] = func(*args, **kwargs)
else:
print(f"Cache hit for {func.__name__}{args}, returning cached result")
return cache[cache_key]
return wrapper
# Apply to a recursive function
@memoize
def fibonacci(n):
"""Calculate the nth Fibonacci number recursively"""
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
# Without memoization, this would be very slow
print(fibonacci(10)) # Output: 55
print(fibonacci(10)) # Uses cached result
Teaching points:
- Memoization dramatically improves performance for functions with repeated calls
- It's especially useful for recursive functions like Fibonacci
- Real-world applications include API rate limiting, caching, authentication, etc.
Validation decorator:
import functools
def validate_types(**expected_types):
"""Validate argument types based on annotations"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Check each argument against expected types
for arg_name, expected_type in expected_types.items():
if arg_name in kwargs:
if not isinstance(kwargs[arg_name], expected_type):
raise TypeError(f"Argument {arg_name} must be {expected_type.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
@validate_types(age=int, name=str)
def register_user(name, age):
print(f"Registered user {name}, age {age}")
# Try with correct types
register_user(name="Alice", age=30) # Works fine
# Try with incorrect types
try:
register_user(name="Bob", age="twenty") # Should raise TypeError
except TypeError as e:
print(f"Error: {e}")
Teaching points:
- Decorators can validate inputs without modifying the original function
- This follows the separation of concerns principle
- Validation logic is reusable across multiple functions
5. Class-Based Decorators (20 minutes)
Introduce decorators implemented as classes:
import functools
class CountCalls:
"""A decorator that counts function calls"""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
"""Called when the decorated function is called"""
self.count += 1
print(f"{self.func.__name__} has been called {self.count} times")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
print("Hello!")
say_hello() # Called 1st time
say_hello() # Called 2nd time
say_hello() # Called 3rd time
Teaching points:
- Class-based decorators use
__init__to store the function __call__makes the class instance callable like a function- They can maintain state between calls (like the count)
- Use
functools.update_wrapperto preserve metadata (equivalent to@functools.wraps)
Class decorator with parameters:
import functools
class Retry:
"""Retry a function if it raises an exception"""
def __init__(self, max_attempts=3, exceptions=(Exception,)):
self.max_attempts = max_attempts
self.exceptions = exceptions
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
attempts = 0
while attempts < self.max_attempts:
try:
return func(*args, **kwargs)
except self.exceptions as e:
attempts += 1
if attempts == self.max_attempts:
raise
print(f"Attempt {attempts} failed with {type(e).__name__}: {e}")
print(f"Retrying... ({attempts}/{self.max_attempts-1})")
return None # Should never reach here
return wrapper
# Simulate an unreliable function
import random
@Retry(max_attempts=5, exceptions=(ValueError, RuntimeError))
def unreliable_function():
if random.random() < 0.7: # 70% chance of failure
if random.random() < 0.5: # 50/50 between two error types
raise ValueError("Random value error")
else:
raise RuntimeError("Random runtime error")
return "Success!"
# Try it
try:
result = unreliable_function()
print(f"Result: {result}")
except Exception as e:
print(f"Final failure: {type(e).__name__}: {e}")
Teaching points:
- Class-based decorators with parameters separate initialization from the decorating process
- The pattern is:
@ClassDecorator(params)-> call__init__(self, params)-> call__call__(self, func) - They're useful for complex decorators with multiple parameters
- They provide clear organization for more complex logic
6. Decorating Classes (15 minutes)
Explain how to decorate entire classes:
import functools
def log_methods(cls):
"""Decorator that logs all method calls for a class"""
# Get all attributes
for name, method in cls.__dict__.items():
# Skip special and non-callable attributes
if name.startswith('__') or not callable(method):
continue
@functools.wraps(method)
def wrapper(self, *args, **kwargs):
method_name = method.__name__
print(f"Calling {cls.__name__}.{method_name}")
return method(self, *args, **kwargs)
# Replace the original method with the wrapper
setattr(cls, name, wrapper)
return cls
@log_methods
class Calculator:
def add(self, a, b):
return a + b
def subtract(self, a, b):
return a - b
def multiply(self, a, b):
return a * b
# Create an instance
calc = Calculator()
# Call methods
print(calc.add(5, 3))
print(calc.subtract(10, 4))
print(calc.multiply(2, 6))
Teaching points:
- Class decorators receive a class object and return a modified version
- They can add, modify, or remove attributes and methods
- Using them makes cross-cutting functionality like logging easier to implement
Singleton class decorator:
def singleton(cls):
"""Make a class follow the Singleton pattern"""
instances = {}
@functools.wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class DatabaseConnection:
def __init__(self, host, port):
self.host = host
self.port = port
print(f"Initializing connection to {host}:{port}")
def execute_query(self, query):
print(f"Executing: {query}")
# First instance - will initialize
db1 = DatabaseConnection("localhost", 5432)
db1.execute_query("SELECT * FROM users")
# Second instance - won't initialize again
db2 = DatabaseConnection("example.com", 8080) # Note different parameters
db2.execute_query("SELECT * FROM products")
# Verify they're the same instance
print(f"Same instance: {db1 is db2}") # True
print(f"db1 host: {db1.host}, db2 host: {db2.host}") # Both show first connection
Teaching points:
- Decorators can fundamentally change how classes behave
- Singleton is a common pattern that ensures only one instance exists
- Notice that the second initialization parameters are ignored
- This demonstrates the power of decorators for implementing design patterns
7. Decorator Factories and Parametrization (15 minutes)
Explain more advanced decorator patterns:
import functools
import logging
# Configure basic logging
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
def log(level=logging.INFO, name=None):
"""Factory function to create a logging decorator with custom level"""
def decorator(func):
# Get logger name from function if not specified
logger_name = name if name else func.__module__
logger = logging.getLogger(logger_name)
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Log function call
args_str = ', '.join(repr(a) for a in args)
kwargs_str = ', '.join(f"{k}={v!r}" for k, v in kwargs.items())
all_args = ', '.join(filter(None, [args_str, kwargs_str]))
logger.log(level, f"Calling {func.__name__}({all_args})")
# Call function
result = func(*args, **kwargs)
# Log result
logger.log(level, f"{func.__name__} returned {result!r}")
return result
return wrapper
return decorator
# Use with default parameters
@log()
def add(a, b):
return a + b
# Use with custom level
@log(level=logging.WARNING)
def divide(a, b):
return a / b
# Use with custom name
@log(name="math_ops", level=logging.DEBUG)
def multiply(a, b):
return a * b
add(5, 3)
divide(10, 2)
try:
divide(5, 0)
except ZeroDivisionError:
pass # Ignore the error for demonstration
multiply(4, 7)
Teaching points:
- Decorator factories can create customized decorators
- Default parameters make them flexible
- This pattern separates decorator creation from application
- Real-world applications include configurable logging, permissions, etc.
Multiple arguments with optional parameters:
def route(path, methods=None):
"""Simplified route decorator like in web frameworks"""
if methods is None:
methods = ['GET']
def decorator(func):
# Store route info on the function
func.route_info = {
'path': path,
'methods': methods
}
@functools.wraps(func)
def wrapper(*args, **kwargs):
# In a real framework, this would handle HTTP requests
print(f"Handling {methods} request to {path}")
return func(*args, **kwargs)
return wrapper
return decorator
# Sample "route handlers"
@route('/users')
def get_users():
return "List of users"
@route('/users/<id>', methods=['GET', 'POST'])
def user_detail(id):
return f"User {id} details"
@route('/admin', methods=['GET', 'PUT', 'DELETE'])
def admin():
return "Admin panel"
# Simulate a web framework finding routes
def find_routes():
"""Find all functions with route_info"""
import sys
routes = []
# Get all global variables
for name, func in globals().items():
# Check if it has route_info
if callable(func) and hasattr(func, 'route_info'):
routes.append((name, func.route_info))
return routes
# Print all routes
for name, info in find_routes():
print(f"{name}: {info['path']} [{', '.join(info['methods'])}]")
Teaching points:
- This pattern is used in many web frameworks (Flask, FastAPI, Django)
- Decorators can attach metadata to functions without changing their behavior
- The framework can then use this metadata to configure routing
- This demonstrates a non-intrusive way to add functionality
8. Unwrapping and Introspection (10 minutes)
Explain how to work with decorated functions:
import functools
import inspect
def simple_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Before {func.__name__}")
result = func(*args, **kwargs)
print(f"After {func.__name__}")
return result
return wrapper
@simple_decorator
def greet(name):
"""Greet a person"""
return f"Hello, {name}!"
# Demonstrate introspection
print(f"Function name: {greet.__name__}")
print(f"Docstring: {greet.__doc__}")
print(f"Module: {greet.__module__}")
print(f"Original function: {greet.__wrapped__}")
# Call the original function directly
original_greet = greet.__wrapped__
print(original_greet("Alice")) # No decoration
# Inspect the call signature
sig = inspect.signature(greet)
print(f"Signature: {sig}")
print(f"Parameters: {list(sig.parameters)}")
Teaching points:
functools.wrapspreserves the original function in__wrapped__- This allows access to the original function if needed
- The
inspectmodule provides tools for introspection - Signature inspection works correctly when using
functools.wraps
9. Guided Practice (20 minutes)
Have students work through these exercises:
-
Creating a retry decorator:
# Exercise: Create a retry decorator that retries a function if it fails # with exponential backoff (increasing delay between attempts) import functools import time def retry_with_backoff(max_attempts=3, initial_delay=1, backoff_factor=2): """Retry the decorated function with exponential backoff""" # TODO: Implement the retry decorator # - Retry up to max_attempts times # - Wait initial_delay seconds before first retry # - Increase delay by multiplying by backoff_factor each retry # - Return the function result if successful # - If all attempts fail, raise the last exception pass # Test function that sometimes fails @retry_with_backoff(max_attempts=4, initial_delay=0.1) def unreliable_network_call(fail_probability=0.6): import random if random.random() < fail_probability: print("Network error occurred!") raise ConnectionError("Network unavailable") return "Success!" # TODO: Test the decorator with the unreliable function -
Creating a rate limiting decorator:
# Exercise: Create a rate limiting decorator that allows a function # to be called at most 'limit' times per 'period' seconds import functools import time def rate_limit(limit=5, period=60): """Limit the function to 'limit' calls per 'period' seconds""" # TODO: Implement the rate limiting decorator # - Track the timestamps of recent calls # - If too many recent calls, either wait or raise an error # - Allow the function to be called if rate limit is not exceeded pass # TODO: Test the decorator with a simple function
10. Review and Discussion (10 minutes)
- Review the key concepts covered
- Ask students to explain in their own words:
- What is a decorator and how does it work?
- How do you create a decorator that takes arguments?
- What is the difference between function and class-based decorators?
- What are some practical uses of decorators?
Common Challenges and Solutions
- Decorator chain execution order: Students may be confused about the order in which multiple decorators are applied. Use a diagram to show the nesting.
- Function signature changes: Point out how decorators can modify the apparent signature of a function, and how to avoid this with
functools.wraps. - Mental model of closures: The concept of closures can be challenging. Use visual aids to explain how the nested functions capture variables.
- Error handling: Remind students about proper error propagation in decorators.
Extension Activities
For students who finish early:
- Challenge them to implement a decorator that caches function results with a time-to-live (TTL)
- Have them create a permission system using decorators
- Ask them to refactor existing code to use decorators for cross-cutting concerns
Assessment
Look for these indicators of understanding:
- Students can create simple function decorators
- They understand how to preserve function metadata
- They can implement decorators with parameters
- They can apply decorators to solve practical problems
- They understand the trade-offs between different decorator implementations