Decorators: Function, Class, Parameterized, Stacking
Advanced decorator patterns and metaprogramming techniques
Interview Question
"Explain Python decorators in depth. What's the difference between function and class decorators? How do parameterized decorators work? Show advanced patterns like stacking, caching, and async decorators."
Difficulty: Hard | Frequently asked at Google, Meta, Amazon, Microsoft
Theoretical Foundation
What is a Decorator?
A decorator is a function that takes another function as input and returns a new function that usually extends the behavior of the original function.
# Basic decorator syntax
@decorator
def function():
pass
# Equivalent without syntactic sugar
def function():
pass
function = decorator(function)
ℹ️
Key Concept: Decorators are just functions that take functions and return functions. The @ syntax is syntactic sugar.
Function Decorators
import functools
import time
from typing import Callable, Any
# Basic decorator
def simple_decorator(func):
"""Simple decorator example."""
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):
return f"Hello, {name}!"
print(greet("World"))
# Output:
# Before greet
# After greet
# Hello, World!
Preserving Function Metadata
import functools
def proper_decorator(func):
"""Decorator that preserves function metadata."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
"""Wrapper function documentation."""
return func(*args, **kwargs)
return wrapper
@proper_decorator
def documented_function():
"""This is the actual function documentation."""
pass
# Without functools.wraps
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bad_decorator
def bad_documented():
"""This documentation is lost."""
pass
print(f"With wraps: {documented_function.__name__}, {documented_function.__doc__}")
print(f"Without wraps: {bad_documented.__name__}, {bad_documented.__doc__}")
Output:
With wraps: documented_function, This is the actual function documentation.
Without wraps: wrapper, None
Parameterized Decorators
Decorators with Arguments
import functools
import time
from typing import Callable, Optional
# Parameterized decorator
def repeat(times: int):
"""Repeat function execution."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def say_hello():
print("Hello!")
say_hello()
# Output:
# Hello!
# Hello!
# Hello!
# Parameterized decorator with default values
def retry(max_attempts: int = 3, delay: float = 1.0):
"""Retry function on failure."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_attempts - 1:
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(max_attempts=3, delay=0.1)
def unreliable_function():
"""Function that might fail."""
import random
if random.random() < 0.5:
raise ValueError("Random failure")
return "Success!"
# Multiple parameters
def rate_limit(calls_per_second: float):
"""Rate limit function calls."""
min_interval = 1.0 / calls_per_second
last_called = [0.0]
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - last_called[0]
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
last_called[0] = time.time()
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(calls_per_second=10)
def api_call():
print("API call executed")
💡
Interview Tip: Parameterized decorators use three levels of nested functions: outer (parameters), middle (decorator), inner (wrapper).
Class Decorators
Using Classes as Decorators
import functools
import time
from typing import Callable, Any
# Class decorator implementing __call__
class CountCalls:
"""Count function calls using a class."""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.num_calls = 0
def __call__(self, *args, **kwargs):
self.num_calls += 1
print(f"Call #{self.num_calls} to {self.func.__name__}")
return self.func(*args, **kwargs)
def reset(self):
self.num_calls = 0
@CountCalls
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
result = fibonacci(5)
print(f"Result: {result}")
print(f"Total calls: {fibonacci.num_calls}")
Output:
Call #1 to fibonacci
Call #2 to fibonacci
Call #3 to fibonacci
Call #4 to fibonacci
Call #5 to fibonacci
Call #6 to fibonacci
Call #7 to fibonacci
Call #8 to fibonacci
Call #9 to fibonacci
Result: 5
Total calls: 9
Stateful Class Decorators
import functools
import time
from typing import Callable, Any, Dict, List
class Cache:
"""LRU Cache implementation using class decorator."""
def __init__(self, maxsize: int = 128):
self.maxsize = maxsize
self.cache: Dict[Any, Any] = {}
self.hits = 0
self.misses = 0
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = str(args) + str(kwargs)
if key in self.cache:
self.hits += 1
return self.cache[key]
self.misses += 1
result = func(*args, **kwargs)
if len(self.cache) >= self.maxsize:
# Remove oldest item
oldest_key = next(iter(self.cache))
del self.cache[oldest_key]
self.cache[key] = result
return result
wrapper.cache_info = self.cache_info
wrapper.cache_clear = self.cache_clear
return wrapper
def cache_info(self):
return {
'hits': self.hits,
'misses': self.misses,
'size': len(self.cache)
}
def cache_clear(self):
self.cache.clear()
self.hits = 0
self.misses = 0
@Cache(maxsize=100)
def expensive_computation(n):
"""Expensive function with caching."""
time.sleep(0.01) # Simulate work
return n * n
# Usage
print(expensive_computation(5))
print(expensive_computation(5)) # Cache hit
print(expensive_computation.cache_info())
ℹ️
Class vs Function Decorators: Class decorators are better for stateful decorators that need to maintain internal state.
Stacking Decorators
Multiple Decorators
import functools
import time
from typing import Callable
# Multiple decorators
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
def underline(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<u>{func(*args, **kwargs)}</u>"
return wrapper
@bold
@italic
@underline
def greet(name):
return f"Hello, {name}!"
print(greet("World"))
# Output: <b><i><u>Hello, World!</u></i></b>
# Execution order: bold(italic(underline(greet)))
# greet() -> underline() -> italic() -> bold()
Decorator Ordering
import functools
def decorator_a(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("Decorator A - Before")
result = func(*args, **kwargs)
print("Decorator A - After")
return result
return wrapper
def decorator_b(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("Decorator B - Before")
result = func(*args, **kwargs)
print("Decorator B - After")
return result
return wrapper
@decorator_a
@decorator_b
def my_function():
print("Function executed")
my_function()
# Output:
# Decorator A - Before
# Decorator B - Before
# Function executed
# Decorator B - After
# Decorator A - After
# Order matters! @decorator_a @decorator_b means:
# my_function = decorator_a(decorator_b(my_function))
⚠️
Common Mistake: Decorators apply bottom-up but execute top-down. Understanding this ordering is crucial for debugging.
Advanced Decorator Patterns
Decorator Factory
import functools
from typing import Callable, Optional
def log(level: str = "INFO", prefix: Optional[str] = None):
"""Decorator factory for logging."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
prefix_str = prefix or func.__name__
print(f"[{level}] {prefix_str}: Called with {args}, {kwargs}")
result = func(*args, **kwargs)
print(f"[{level}] {prefix_str}: Returned {result}")
return result
return wrapper
return decorator
@log(level="DEBUG", prefix="UserService")
def get_user(user_id: int):
return {"id": user_id, "name": "John"}
user = get_user(123)
Output:
[DEBUG] UserService: Called with (123,), {}
[DEBUG] UserService: Returned {'id': 123, 'name': 'John'}
Decorator with Access Control
import functools
from typing import Callable, Any
class PermissionError(Exception):
pass
def require_permission(permission: str):
"""Decorator for access control."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Get user from context (simplified)
user = kwargs.get('user') or (args[0] if args else None)
if not user:
raise PermissionError("No user provided")
if permission not in user.get('permissions', []):
raise PermissionError(f"Missing permission: {permission}")
return func(*args, **kwargs)
return wrapper
return decorator
@require_permission("admin")
def delete_user(user_id: int, user: dict):
return f"Deleted user {user_id}"
# Usage
admin_user = {"name": "Admin", "permissions": ["admin", "write"]}
regular_user = {"name": "User", "permissions": ["read"]}
try:
delete_user(123, user=admin_user)
print("Success!")
except PermissionError as e:
print(f"Error: {e}")
try:
delete_user(123, user=regular_user)
except PermissionError as e:
print(f"Error: {e}")
Output:
Success!
Error: Missing permission: admin
Memoization Decorator
import functools
import time
from typing import Callable, Any
def memoize(maxsize: int = 128):
"""Custom memoization decorator."""
cache = {}
hits = 0
misses = 0
def decorator(func):
@functools.wraps(func)
def wrapper(*args):
nonlocal hits, misses
if args in cache:
hits += 1
return cache[args]
misses += 1
result = func(*args)
if len(cache) >= maxsize:
# Simple eviction: remove first item
cache.pop(next(iter(cache)))
cache[args] = result
return result
def cache_info():
return {
'hits': hits,
'misses': misses,
'size': len(cache),
'maxsize': maxsize
}
def cache_clear():
nonlocal hits, misses
cache.clear()
hits = 0
misses = 0
wrapper.cache_info = cache_info
wrapper.cache_clear = cache_clear
return wrapper
return decorator
@memoize(maxsize=100)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Usage
start = time.time()
result = fibonacci(100)
elapsed = time.time() - start
print(f"Fibonacci(100) = {result}")
print(f"Time: {elapsed:.4f}s")
print(f"Cache info: {fibonacci.cache_info()}")
Output:
Fibonacci(100) = 354224848179261915075
Time: 0.0003s
Cache info: {'hits': 98, 'misses': 101, 'size': 101, 'maxsize': 100}
💡
Performance Tip: Memoization can transform O(2^n) algorithms to O(n) for recursive functions with overlapping subproblems.
Async Decorators
Async Function Decorators
import functools
import asyncio
import time
from typing import Callable, Any
# Async decorator
def async_retry(max_attempts: int = 3, delay: float = 1.0):
"""Retry decorator for async functions."""
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_attempts):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_attempts - 1:
await asyncio.sleep(delay)
raise last_exception
return wrapper
return decorator
@async_retry(max_attempts=3, delay=0.1)
async def async_unreliable_function():
"""Async function that might fail."""
import random
if random.random() < 0.5:
raise ValueError("Random failure")
return "Success!"
# Async timing decorator
def async_timer(func):
"""Time async function execution."""
@functools.wraps(func)
async def wrapper(*args, **kwargs):
start = time.time()
result = await func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@async_timer
async def slow_operation():
await asyncio.sleep(0.1)
return "Done"
async def main():
result = await slow_operation()
print(result)
asyncio.run(main())
Async Class Decorators
import functools
import asyncio
from typing import Callable
class AsyncCache:
"""Async version of cache decorator."""
def __init__(self, maxsize: int = 128):
self.maxsize = maxsize
self.cache = {}
def __call__(self, func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
key = str(args) + str(kwargs)
if key in self.cache:
return self.cache[key]
result = await func(*args, **kwargs)
if len(self.cache) >= self.maxsize:
self.cache.pop(next(iter(self.cache)))
self.cache[key] = result
return result
return wrapper
@AsyncCache(maxsize=100)
async def async_expensive_computation(n):
await asyncio.sleep(0.01)
return n * n
async def main():
result = await async_expensive_computation(5)
print(f"Result: {result}")
asyncio.run(main())
ℹ️
Async Decorators: When decorating async functions, you must use async def and await in the wrapper.
Real-World Patterns
Authentication Decorator
import functools
import hashlib
import time
from typing import Callable, Any, Optional
class AuthError(Exception):
pass
def require_auth(token: Optional[str] = None):
"""Authentication decorator."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Get token from kwargs or use default
provided_token = kwargs.pop('auth_token', token)
if not provided_token:
raise AuthError("No authentication token provided")
# Simple token validation (in real app, use proper JWT verification)
expected_token = "secret_token_123"
if provided_token != expected_token:
raise AuthError("Invalid authentication token")
return func(*args, **kwargs)
return wrapper
return decorator
@require_auth()
def get_sensitive_data(data_id: int):
return {"id": data_id, "secret": "sensitive_data"}
# Usage
try:
result = get_sensitive_data(1, auth_token="secret_token_123")
print(f"Success: {result}")
except AuthError as e:
print(f"Auth Error: {e}")
try:
result = get_sensitive_data(1, auth_token="wrong_token")
except AuthError as e:
print(f"Auth Error: {e}")
Rate Limiting Decorator
import functools
import time
from collections import defaultdict
from typing import Callable, Any
class RateLimiter:
"""Rate limiter using sliding window."""
def __init__(self, max_calls: int, time_window: float):
self.max_calls = max_calls
self.time_window = time_window
self.calls = defaultdict(list)
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.time()
key = f"{func.__name__}:{args}"
# Remove old calls outside time window
self.calls[key] = [
call_time for call_time in self.calls[key]
if now - call_time < self.time_window
]
if len(self.calls[key]) >= self.max_calls:
raise Exception(f"Rate limit exceeded for {func.__name__}")
self.calls[key].append(now)
return func(*args, **kwargs)
return wrapper
@RateLimiter(max_calls=5, time_window=1.0)
def api_endpoint(endpoint: str):
return f"Response from {endpoint}"
# Usage
for i in range(7):
try:
result = api_endpoint("/users")
print(f"Call {i+1}: {result}")
except Exception as e:
print(f"Call {i+1}: {e}")
Validation Decorator
import functools
from typing import Any, Callable, Dict, List, Type
def validate(**validators):
"""Validate function arguments."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Validate positional arguments
for i, arg in enumerate(args):
if i in validators:
if not validators[i](arg):
raise ValueError(f"Invalid argument {i}: {arg}")
# Validate keyword arguments
for key, value in kwargs.items():
if key in validators:
if not validators[key](value):
raise ValueError(f"Invalid argument {key}: {value}")
return func(*args, **kwargs)
return wrapper
return decorator
@validate(
age=lambda x: isinstance(x, int) and 0 <= x <= 150,
email=lambda x: isinstance(x, str) and "@" in x,
name=lambda x: isinstance(x, str) and len(x) > 0
)
def create_user(name: str, age: int, email: str):
return {"name": name, "age": age, "email": email}
# Usage
try:
user = create_user(name="John", age=30, email="john@example.com")
print(f"Created user: {user}")
except ValueError as e:
print(f"Validation error: {e}")
try:
user = create_user(name="John", age=-5, email="john@example.com")
except ValueError as e:
print(f"Validation error: {e}")
💡
Design Pattern: Decorators are a form of the Decorator pattern from GoF. They add behavior to objects dynamically.
Complexity Analysis
Time Complexity
| Decorator Type | Overhead | Use Case |
|---|---|---|
| Simple wrapper | O(1) | Logging, timing |
| Memoization | O(1) avg | Caching |
| Rate limiting | O(n) | API throttling |
| Validation | O(1) | Input checking |
Space Complexity
| Decorator | Space | Notes |
|---|---|---|
| Simple | O(1) | No state |
| Memoization | O(n) | Cache storage |
| Stateful | O(n) | Instance attributes |
| Stacking | O(k) | k = number of decorators |
Interview Tips
Common Follow-up Questions
-
"What's the difference between a decorator and a higher-order function?"
- Decorators are a specific use case of higher-order functions
- Higher-order functions take/return functions
- Decorators specifically modify function behavior
-
"How do you debug decorators?"
- Use
functools.wrapsto preserve metadata - Add logging inside wrappers
- Use
__name__and__doc__attributes
- Use
-
"Can decorators be used on classes?"
- Yes, class decorators exist
- They can modify class behavior
- Examples:
@dataclass,@singleton
Code Review Tips
# BAD: Not preserving metadata
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# GOOD: Preserving metadata
import functools
def good_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# BAD: Hardcoded parameters
def bad_retry(func):
def wrapper(*args, **kwargs):
for i in range(3):
try:
return func(*args, **kwargs)
except:
pass
raise Exception("Failed")
return wrapper
# GOOD: Parameterized
def good_retry(max_attempts=3, delay=1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for i in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if i == max_attempts - 1:
raise
time.sleep(delay)
return wrapper
return decorator
⚠️
Common Mistake: Forgetting to use functools.wraps causes loss of function metadata, making debugging difficult.
Summary
| Pattern | Use Case | Complexity |
|---|---|---|
| Simple wrapper | Logging, timing | O(1) |
| Parameterized | Configurable behavior | O(1) |
| Class-based | Stateful decorators | O(1) |
| Stacking | Multiple behaviors | O(k) |
| Async | Async function modification | O(1) |
| Factory | Creating decorators | O(1) |
Best Practices
- Always use
functools.wrapsto preserve metadata - Use parameterized decorators for configurable behavior
- Consider class decorators for stateful operations
- Test decorators independently before applying
- Document decorator behavior clearly
ℹ️
Key Takeaway: Decorators are powerful metaprogramming tools. Master them to write cleaner, more maintainable code.
Practice Problems
- Caching Decorator: Implement an LRU cache decorator
- Timing Decorator: Create a decorator that logs execution time
- Retry Decorator: Build a retry decorator with exponential backoff
- Validation Decorator: Implement argument validation
- Rate Limiter: Create a rate limiting decorator
Further Reading
- PEP 318: Decorators for functions and methods
- PEP 612: Parameter specification variables
- Books: "Python Cookbook" by David Beazley
- Advanced: Descriptor protocol and metaclasses
Remember: Decorators are just functions that take functions and return functions. Understanding this simple concept unlocks powerful metaprogramming capabilities.