Python FAQ: Top Questions
50. What is a decorator in Python? How do you create and use them?
In Python, a **decorator** is a design pattern that allows you to modify or extend the functionality of a function or method without explicitly changing its source code. Decorators wrap a function, allowing you to execute code before and/or after the wrapped function runs, or even replace the wrapped function entirely.
The syntax for applying a decorator is the `@` symbol placed immediately before the function definition.
@my_decorator
def my_function():
pass
This is syntactic sugar for:
def my_function():
pass
my_function = my_decorator(my_function)
How Decorators Work:
A decorator is essentially a **callable that takes a function as an argument and returns a new function (or another callable)**. The returned function typically "wraps" the original function, adding new behavior.
The common structure of a decorator function looks like this:
def my_decorator(func):
def wrapper(*args, **kwargs): # The wrapper function preserves signature
# Code to execute BEFORE func
result = func(*args, **kwargs) # Call the original function
# Code to execute AFTER func
return result
return wrapper
- `my_decorator` is the outer function that takes `func` (the function to be decorated) as its argument.
- `wrapper` is the inner function (often called a "closure") that will replace the original `func`. It's defined inside `my_decorator` so it can "remember" `func` from its enclosing scope.
- `wrapper` usually accepts `*args` and `**kwargs` to ensure it can handle any arguments passed to the original function.
- `my_decorator` returns this `wrapper` function.
Benefits of Decorators:
- **Code Reusability:** Apply the same functionality (e.g., logging, timing, authentication) to multiple functions without duplicating code.
- **Separation of Concerns:** Keep core business logic separate from cross-cutting concerns (like logging or caching).
- **Readability:** The `@` syntax is concise and clearly indicates that a function's behavior is being enhanced.
- **Flexibility:** Easily add, remove, or change functionality by simply adding or removing a decorator line.
Common Use Cases for Decorators:
- **Logging:** Log function calls, arguments, and return values.
- **Timing/Profiling:** Measure the execution time of functions.
- **Caching/Memoization:** Store the results of expensive function calls to avoid recomputing them.
- **Authentication/Authorization:** Restrict access to functions based on user permissions.
- **Input Validation:** Validate function arguments before execution.
- **Retry Logic:** Automatically retry a function call if it fails (e.g., due to network issues).
- **Frameworks (Web, ORM):** Used extensively in web frameworks (e.g., Flask, Django) for routing, middleware, etc.
The `functools.wraps` decorator is often used when creating decorators to preserve the original function's metadata (like `__name__`, `__doc__`, `__module__`), which can be lost when using a simple wrapper function.
import time
from functools import wraps # Important for preserving function metadata
# --- Example 1: Simple Decorator - A Logger ---
print("--- Simple Decorator: Logger ---")
def log_function_call(func):
"""
A decorator that logs when a function is called and its arguments.
"""
@wraps(func) # Use functools.wraps to preserve func's metadata
def wrapper(*args, **kwargs):
print(f"LOG: Calling function '{func.__name__}' with args: {args}, kwargs: {kwargs}")
result = func(*args, **kwargs)
print(f"LOG: Function '{func.__name__}' returned: {result}")
return result
return wrapper
@log_function_call
def add(a, b):
"""Adds two numbers."""
return a + b
@log_function_call
def greet(name, greeting="Hello"):
"""Greets a person."""
return f"{greeting}, {name}!"
print(f"Calling add(5, 3): {add(5, 3)}")
print(f"Calling greet('Alice', greeting='Hi'): {greet('Alice', greeting='Hi')}")
print(f"\nFunction name after decoration: {add.__name__}") # Without @wraps, this would be 'wrapper'
print(f"Function docstring after decoration: {add.__doc__}") # Without @wraps, this would be None
# --- Example 2: Decorator with Arguments ---
print("\n--- Decorator with Arguments: Retry Logic ---")
def retry(max_attempts=3, delay_seconds=1):
"""
A decorator that retries a function call a specified number of times
if it raises an exception.
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
attempts = 0
while attempts < max_attempts:
try:
return func(*args, **kwargs)
except Exception as e:
attempts += 1
print(f"Attempt {attempts} failed for '{func.__name__}': {e}")
if attempts < max_attempts:
print(f"Retrying in {delay_seconds} seconds...")
time.sleep(delay_seconds)
else:
print(f"Max attempts reached for '{func.__name__}'. Giving up.")
raise # Re-raise the last exception
return wrapper
return decorator
fail_count = 0
@retry(max_attempts=2, delay_seconds=0.5)
def unstable_api_call():
"""Simulates an API call that sometimes fails."""
global fail_count
fail_count += 1
if fail_count < 2: # Fail for the first attempt
raise ConnectionError("Simulated network issue!")
print("API call succeeded!")
return "Data from API"
@retry(max_attempts=5, delay_seconds=0.1)
def very_unstable_task():
global fail_count
fail_count += 1
if fail_count < 5: # Fail for the first 4 attempts
raise ValueError("Simulated data error!")
print("Very unstable task succeeded!")
return "Task Completed"
print("\nCalling unstable_api_call:")
try:
result = unstable_api_call()
print(f"Result: {result}")
except ConnectionError as e:
print(f"Caught error outside: {e}")
fail_count = 0 # Reset for next test
print("\nCalling very_unstable_task:")
try:
result = very_unstable_task()
print(f"Result: {result}")
except ValueError as e:
print(f"Caught error outside: {e}")
# --- Example 3: Class-based Decorator ---
print("\n--- Class-based Decorator: Timer ---")
class Timer:
def __init__(self, func):
self.func = func
wraps(func)(self) # Apply wraps to the instance for metadata
def __call__(self, *args, **kwargs):
start_time = time.perf_counter()
result = self.func(*args, **kwargs)
end_time = time.perf_counter()
run_time = end_time - start_time
print(f"TIMER: '{self.func.__name__}' took {run_time:.4f} seconds to execute.")
return result
@Timer
def calculate_sum(n):
"""Calculates sum up to n."""
time.sleep(0.05) # Simulate work
return sum(range(n))
@Timer
def complex_operation(a, b, c):
"""Performs a complex operation."""
time.sleep(0.1)
return (a * b) + c
print(f"Sum calculation: {calculate_sum(10000)}")
print(f"Complex operation: {complex_operation(10, 20, 5)}")
print(f"\nFunction name after class decoration: {calculate_sum.__name__}")
print(f"Function docstring after class decoration: {calculate_sum.__doc__}")
Explanation of the Example Code:
-
**Simple Decorator: Logger (`log_function_call`):**
- `log_function_call` is a function that takes another function (`func`) as input.
- Inside, it defines `wrapper`, which is the function that will actually be called when the decorated function is invoked. `wrapper` adds logging statements before and after calling the original `func`.
- `@wraps(func)` is crucial. It copies `func`'s metadata (like `__name__` and `__doc__`) to `wrapper`, so introspecting `add` after decoration still shows its original name and docstring, which is important for debugging and documentation.
- When `@log_function_call` is placed above `add` or `greet`, it's equivalent to `add = log_function_call(add)`.
-
**Decorator with Arguments: Retry Logic (`retry`):**
- This decorator needs arguments (`max_attempts`, `delay_seconds`). To achieve this, it's structured as a function that *returns another function*, which then acts as the actual decorator. This is a common pattern for parameterized decorators.
- `retry` is the outermost function that takes `max_attempts` and `delay_seconds`.
- `decorator` is the inner function that takes `func` (the function to be decorated).
- `wrapper` is the innermost function that actually contains the retry logic and calls the original `func`.
- The `try...except` block within `wrapper` handles exceptions, retrying the call up to `max_attempts` times with a specified `delay`.
- `@retry(max_attempts=2, delay_seconds=0.5)` calls the `retry` function, which then returns the `decorator` function, which is then applied to `unstable_api_call`.
-
**Class-based Decorator: Timer (`Timer` class):**
- Decorators can also be implemented as classes. A class-based decorator needs to implement the `__init__` method (to store the function being decorated) and the `__call__` method (to make instances of the class callable, so they can act as the wrapper function).
- When `@Timer` is placed above `calculate_sum`, it's equivalent to `calculate_sum = Timer(calculate_sum)`. So `calculate_sum` becomes an instance of the `Timer` class.
- When `calculate_sum(10000)` is called, it triggers the `__call__` method of the `Timer` instance, which wraps the original `calculate_sum` function with timing logic.
- `wraps(func)(self)` is used in `__init__` to ensure the instance itself gets the original function's metadata.
These examples provide practical implementations of both simple and parameterized decorators, as well as a class-based decorator, demonstrating their power and flexibility in extending function behavior.