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 @decorator syntax is equivalent to function = 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.wraps is a decorator that preserves the original function's metadata
  • Always use @functools.wraps when 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 @debug and @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_wrapper to 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.wraps preserves the original function in __wrapped__
  • This allows access to the original function if needed
  • The inspect module provides tools for introspection
  • Signature inspection works correctly when using functools.wraps

9. Guided Practice (20 minutes)

Have students work through these exercises:

  1. 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
    
  2. 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

Resources

Chapter 6: Decorators | Teacher's Guide