- Understand what decorators are and how they work
- Create and apply function decorators
- Work with decorator arguments and nested decorators
- Apply decorators in real-world scenarios
Function Decorators
Introduction
Imagine you're a chef in a busy restaurant. Each dish that leaves your kitchen needs certain preparations - maybe it needs to be garnished, or served at a specific temperature, or plated in a particular way. Instead of remembering to do these things for each dish individually, you create a "presentation station" where all dishes pass through automatically.
In programming, decorators are like that presentation station. They allow you to add functionality to functions without modifying the functions themselves. This is one of Python's most powerful features for code reuse and organization.
What is a Decorator?
A decorator is a function that takes another function as input and returns a modified version of that function. It's like wrapping a gift - the original item is still there, but now it has extra packaging and features.
The Basic Concept
def my_decorator(func):
def wrapper():
print("Something before the function runs")
func()
print("Something after the function runs")
return wrapper
def say_hello():
print("Hello!")
# Manual decoration
decorated_hello = my_decorator(say_hello)
decorated_hello()
# Output:
# Something before the function runs
# Hello.
# Something after the function runs
The @ Syntax
The @ symbol is syntactic sugar that makes decorators easier to apply:
def my_decorator(func):
def wrapper():
print("Before")
func()
print("After")
return wrapper
@my_decorator
def say_hello():
print("Hello!")
# Now say_hello is automatically decorated
say_hello()
# Output: Before -> Hello! -> After
Function Decorators in Action
Timing Decorator
import time
def timer(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:.2f} seconds")
return result
return wrapper
@timer
def slow_function():
time.sleep(1)
return "Done!"
result = slow_function()
# Output: slow_function took 1.00 seconds
Logging Decorator
def logger(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned: {result}")
return result
return wrapper
@logger
def add_numbers(a, b):
return a + b
result = add_numbers(5, 3)
# Output:
# Calling add_numbers with args: (5, 3), kwargs: {}
# add_numbers returned: 8
Authentication Decorator
def requires_auth(func):
def wrapper(user, *args, **kwargs):
if not hasattr(user, 'is_authenticated') or not user.is_authenticated:
raise PermissionError("User must be authenticated")
return func(user, *args, **kwargs)
return wrapper
class User:
def __init__(self, name, authenticated=False):
self.name = name
self.is_authenticated = authenticated
@requires_auth
def view_dashboard(user):
return f"Welcome to dashboard, {user.name}!"
user1 = User("Alice", authenticated=True)
user2 = User("Bob", authenticated=False)
print(view_dashboard(user1)) # Works
# print(view_dashboard(user2)) # Raises PermissionError
Preserving Function Metadata
When you decorate a function, the original function's metadata (name, docstring, etc.) gets replaced. Use functools.wraps to preserve this information:
import functools
def my_decorator(func):
@functools.wraps(func) # Preserves original function metadata
def wrapper(*args, **kwargs):
"""This is the wrapper's docstring"""
print("Decorator is running")
return func(*args, **kwargs)
return wrapper
@my_decorator
def important_function():
"""This function does something very important."""
return "Important result"
print(important_function.__name__) # "important_function" (not "wrapper")
print(important_function.__doc__) # "This function does something very important."
Decorators with Arguments
Sometimes you want your decorator to accept parameters:
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
results = []
for _ in range(times):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator
@repeat(times=3)
def greet(name):
return f"Hello, {name}!"
results = greet("Alice")
print(results) # ['Hello, Alice!', 'Hello, Alice!', 'Hello, Alice!']
How It Works
@repeat(times=3)callsrepeat(3), which returns a decorator- The decorator is then applied to
greet - When
greetis called, it runs 3 times
Multiple Decorators
You can stack multiple decorators on a single function:
@timer
@logger
def complex_calculation(x, y):
return x ** y
result = complex_calculation(2, 10)
# Output:
# Calling complex_calculation with args: (2, 10), kwargs: {}
# complex_calculation took 0.00 seconds
# complex_calculation returned: 1024
Decorators are applied from bottom to top, so the execution order is:
timerwraps the functionloggerwraps the timer-wrapped function
Real-World Applications
Caching Decorator
def cache(func):
cache_dict = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Create a cache key from arguments
key = (args, tuple(sorted(kwargs.items())))
if key in cache_dict:
print(f"Cache hit for {func.__name__}")
return cache_dict[key]
print(f"Computing {func.__name__}")
result = func(*args, **kwargs)
cache_dict[key] = result
return result
return wrapper
@cache
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(10)) # Computes
print(fibonacci(10)) # Cache hit
Rate Limiting Decorator
import time
def rate_limit(calls_per_minute):
interval = 60 / calls_per_minute
last_called = [0] # Use list to modify in nested function
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - last_called[0]
if elapsed < interval:
wait_time = interval - elapsed
print(f"Rate limited. Waiting {wait_time:.1f} seconds...")
time.sleep(wait_time)
last_called[0] = time.time()
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(calls_per_minute=2)
def api_call():
print("Making API call...")
return "API response"
for i in range(3):
api_call()
time.sleep(0.1)
Common Patterns and Best Practices
1. Always Use functools.wraps
# Good
@functools.wraps(func)
def wrapper(...):
...
# Bad - loses original function metadata
def wrapper(...):
...
2. Handle All Arguments
# Good - accepts any arguments
def decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# Bad - only works with specific signatures
def decorator(func):
def wrapper(a, b):
return func(a, b)
return wrapper
3. Document Your Decorators
def my_decorator(param):
"""
A decorator that does something useful.
Args:
param: Description of the parameter
Returns:
Decorated function
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Implementation
return func(*args, **kwargs)
return wrapper
return decorator
Key Points to Remember
- Decorators are functions that modify other functions' behavior
- The
@symbol is syntactic sugar for applying decorators - Use
*argsand**kwargsto handle any function signature functools.wrapspreserves the original function's metadata- Decorators can accept arguments using decorator factories
- Multiple decorators are applied from bottom to top
Function decorators are powerful, but you can also decorate entire classes. In the next lesson, we'll explore class decorators and how they can modify class behavior before instances are even created.
