Swiftorial Logo
Home
Swift Lessons
Tutorials
Learn More
Career
Resources

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:

  1. **Code Reusability:** Apply the same functionality (e.g., logging, timing, authentication) to multiple functions without duplicating code.
  2. **Separation of Concerns:** Keep core business logic separate from cross-cutting concerns (like logging or caching).
  3. **Readability:** The `@` syntax is concise and clearly indicates that a function's behavior is being enhanced.
  4. **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.