Decorators & Metaprogramming

Difficulty: Advanced

Question

Explain Python decorators. How do they work under the hood? Write a custom decorator.

Answer

A decorator is a function that takes another function and extends its behavior without modifying it. Under the hood, @decorator is syntactic sugar for: function = decorator(function).

Decorators use closures - the inner wrapper function remembers and has access to the original function.

Common use cases: logging, timing, authentication, caching, rate limiting, retry logic.

Code examples

Basic Decorator with Arguments

import functools
import time

def timer(func):
    @functools.wraps(func)  # preserves func name & docstring
    def wrapper(*args, kwargs):
        start = time.perf_counter()
        result = func(*args, kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_function(n):
    """Simulates a slow operation."""
    time.sleep(n)
    return f"Done after {n}s"

# @timer is equivalent to:
# slow_function = timer(slow_function)
result = slow_function(1)
print(result)
print(slow_function.__name__)  # 'slow_function' (thanks to @wraps)

The decorator wraps the original function. @functools.wraps preserves the original function's metadata (name, docstring).

Decorator Factory (with parameters)

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    print(f"Attempt {attempt} failed: {e}. Retrying...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
    import random
    if random.random() < 0.7:
        raise ConnectionError("Server unavailable")
    return {"data": "success"}

# Three levels of nesting:
# retry(3, 0.5) returns decorator
# decorator(fetch_data) returns wrapper
# wrapper() is called when fetch_data() is called

A decorator factory is a function that returns a decorator. This pattern allows passing configuration parameters to decorators.

Stacking 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

@bold
@italic
def greet(name):
    return f"Hello, {name}"

print(greet("Alice"))
# Equivalent to: bold(italic(greet))("Alice")
# Inner decorator applies first, outer wraps around it

Decorators stack bottom-up: @italic applies first, then @bold wraps the result. Read them inside-out.

Key points

Concepts covered

Decorators, Closures, functools.wraps, Class Decorators, Decorator Factory