Python Decorators: A Comprehensive Guide
Core Concepts of Decorators
What is a Decorator?
A decorator is a function that takes another function as an argument, adds some functionality (via a wrapper), and returns a new function that behaves like the original but with enhancements.
Syntax: Use the @decorator_name annotation right above a function definition to apply it. This is syntactic sugar for function = decorator_name(function).
Key Components:
Outer Function: Accepts the target function (e.g., func).
Wrapper Function: Nested inside the outer function; handles the actual logic (e.g., pre/post-execution actions) and calls the original function with args and *kwargs for flexibility.
Return: The wrapper is returned, replacing the original function.
Why Use Decorators?
Reusability: Apply the same enhancement (e.g., login check) to multiple functions.
Clean Code: Avoid repetitive boilerplate in function bodies.
Common in Frameworks: Like Django's @login_required for route protection.
Basic Structure
Python
def my_decorator(func):
def wrapper(*args, **kwargs): # Handles any arguments
# Pre-execution logic (e.g., print start time)
result = func(*args, **kwargs) # Execute original function
# Post-execution logic (e.g., print end time)
return result # Return original result
return wrapper
@my_decorator
def my_function(x):
return x * 2
# Usage: my_function(5) # Enhanced automatically
Handling Arguments
Use args for positional arguments and *kwargs for keyword arguments to make decorators universal.
Access function metadata: func.__name__ for the name, func.__doc__ for documentation.
Preserving Function Metadata
To keep the original function's name and docstring intact (useful for introspection), use functools.wraps:
Python
from functools import wraps
def my_decorator(func):
@wraps(func) # Preserves metadata
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Problem 1: Timing Function Execution
Goal: Create a decorator that measures and prints the time a function takes to execute. This is useful for performance profiling.
Explanation
Import time module.
In the wrapper: Record start time before execution, end time after, and compute the difference.
Print the function name and elapsed time using func.__name__.
Code Example
Python
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} ran in {end - start:.4f} secs") # Formatted for readability
return result
return wrapper
@timer
def example_function(n):
time.sleep(n) # Simulate work
# Usage
example_function(2) # Output: example_function ran in 2.0012 secs
Key Takeaways
Pros: Non-intrusive; works on any function.
Edge Cases: Handles async functions poorly (use asyncio for those).
Extensions: Log to file instead of printing, or return time as a value.
Problem 2: Debugging Function Calls
Goal: Build a decorator that prints the function name and argument values every time the function is called. Ideal for troubleshooting.
Explanation
In the wrapper: Convert *args to a comma-separated string using list comprehension and str().
For **kwargs: Iterate over items and format as key:value.
Print a formatted message before executing the function.
Code Example
Python
def debug(func):
def wrapper(*args, **kwargs):
args_value = ', '.join(str(arg) for arg in args)
kwargs_value = ', '.join(f"{k}: {v}" for k, v in kwargs.items())
print(f"Calling '{func.__name__}' with args [{args_value}] and kwargs [{kwargs_value}]")
return func(*args, **kwargs)
return wrapper
@debug
def hello():
print("Hello")
@debug
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
# Usage
hello() # Output: Calling 'hello' with args [] and kwargs []
# Hello
greet("Alice", greeting="Hi") # Output: Calling 'greet' with args [Alice] and kwargs [greeting: Hi]
# Hi, Alice!
Key Takeaways
Pros: Great for logging inputs without modifying function code.
Edge Cases: Handle empty args/kwargs gracefully (e.g., empty strings).
Extensions: Add timestamps or log levels (e.g., integrate with logging module).
Problem 3: Caching Return Values
Goal: Implement a decorator that stores function results in a cache (dictionary). On repeated calls with identical arguments, return the cached value instead of re-executing. Useful for expensive computations (e.g., API calls).
Explanation
Use a dictionary (cache_values) to store results, keyed by tuple of *args (tuples are hashable).
In the wrapper: Check if args are in cache; if yes, return cached result. Otherwise, execute, cache, and return.
Note: Ignores **kwargs for simplicity; extend with args + tuple(sorted(kwargs.items())) for full support.
Code Example
Python
import time
def cache(func):
cache_values = {} # Function-local cache
def wrapper(*args):
if args in cache_values:
print(f"Cache hit for {args}") # Optional debug
return cache_values[args]
result = func(*args)
cache_values[args] = result
print(f"Cache miss; computed {result} for {args}") # Optional debug
return result
return wrapper
@cache
def long_running_function(a, b):
time.sleep(3) # Simulate expensive operation
return a + b
# Usage
print(long_running_function(2, 3)) # Waits 3s: Cache miss; computed 5 for (2, 3) \n 5
print(long_running_function(2, 3)) # Instant: Cache hit for (2, 3) \n 5
print(long_running_function(4, 1)) # Waits 3s: Cache miss; computed 5 for (4, 1) \n 5
Key Takeaways
Pros: Dramatically improves performance for idempotent functions.
Edge Cases: Cache grows indefinitely (use LRU cache via functools.lru_cache for limits). Not thread-safe without locks.
Extensions: Add expiration (TTL) or persist to file/database.
Comparison of Examples
| Decorator | Purpose | Key Wrapper Logic | Use Case Example |
| Timer | Measure execution time | start/end = time.time(); print diff | Profiling slow functions |
| Debug | Log calls & args | Join args/kwargs as strings; print | Debugging inputs/outputs |
| Cache | Memoize results | Dict check/insert on args; return cached | Avoiding recomputation |