Aller au contenu principal

04.4 - Decorators

Theory 25 min Intermediate

What is a Decorator?

A decorator is a function that wraps another function to add behavior without modifying the original code.

@decorator
def my_function():
...

# Equivalent to:
def my_function():
...
my_function = decorator(my_function)

Decorators implement the "Open/Closed Principle": open for extension, closed for modification.


Building a Decorator Step by Step

# Step 1 — A simple wrapper
def my_decorator(func):
def wrapper():
print("Before the function")
func()
print("After the function")
return wrapper

# Without @syntax
def say_hello():
print("Hello!")

say_hello = my_decorator(say_hello)
say_hello()
# Before the function
# Hello!
# After the function

Using @functools.wraps

Always use @functools.wraps to preserve the original function's metadata:

import functools

def my_decorator(func):
@functools.wraps(func) # preserves __name__, __doc__, etc.
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Done: {func.__name__}")
return result
return wrapper

@my_decorator
def add(a, b):
"""Add two numbers."""
return a + b

add(3, 4)
# Calling add
# Done: add
# 7

add.__name__ # "add" (not "wrapper" — thanks to @wraps)
add.__doc__ # "Add two numbers."

Practical Decorators

1. Timing Decorator

import functools
import time

def timeit(func):
@functools.wraps(func)
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

@timeit
def slow_operation(n):
return sum(range(n))

slow_operation(10_000_000)
# slow_operation took 0.2341s

2. Retry Decorator

import functools
import time

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

@retry(times=3, delay=0.5, exceptions=(ConnectionError,))
def fetch_data(url):
# ...
pass

3. Caching with functools.lru_cache

from functools import lru_cache

@lru_cache(maxsize=128)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)

fibonacci(50) # instant! (cached)

# Check cache stats
fibonacci.cache_info()
# CacheInfo(hits=48, misses=51, maxsize=128, currsize=51)

# Python 3.9+ shorthand
from functools import cache # unlimited cache

@cache
def expensive(n):
...

4. Validation Decorator

def require_positive(*arg_names):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
import inspect
sig = inspect.signature(func)
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for name in arg_names:
if name in bound.arguments and bound.arguments[name] <= 0:
raise ValueError(f"'{name}' must be positive, got {bound.arguments[name]}")
return func(*args, **kwargs)
return wrapper
return decorator

@require_positive("width", "height")
def area(width, height):
return width * height

area(5, 10) # 50
area(-1, 10) # ValueError: 'width' must be positive

Decorator with Arguments (Decorator Factory)

def repeat(n):
"""Run the function n times."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator

@repeat(3)
def greet(name):
print(f"Hello, {name}!")

greet("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!

Stacking Decorators

@timeit
@retry(times=3)
@lru_cache(maxsize=64)
def fetch_and_process(url):
...

# Execution order: timeit wraps retry wraps lru_cache wraps fetch_and_process
# Applied bottom-up, executed top-down

Class-Based Decorators

class Singleton:
"""Ensure only one instance of a class is created."""
_instances = {}

def __call__(cls, *args, **kwargs):
if cls not in Singleton._instances:
Singleton._instances[cls] = super().__call__(*args, **kwargs)
return Singleton._instances[cls]

@Singleton
class DatabaseConnection:
def __init__(self):
self.connected = False

Key Vocabulary

TermDefinition
DecoratorA function that wraps another function to extend behavior
@syntaxSyntactic sugar: @dec before def f means f = dec(f)
@functools.wrapsPreserves the wrapped function's name, docstring, etc.
WrapperThe inner function inside a decorator
@lru_cacheMemoization decorator from functools
Decorator factoryA function that returns a decorator (for parameterized decorators)
Stacking decoratorsApplying multiple decorators to one function
Cross-cutting concernLogic applied to many functions (logging, timing, auth)

Summary

  • A decorator is a function that takes a function and returns a modified version
  • Always use @functools.wraps(func) inside decorators to preserve metadata
  • Common built-in decorators: @lru_cache, @cache, @staticmethod, @classmethod, @property
  • Decorators with arguments use a factory pattern: function → decorator → wrapper
  • Stack multiple decorators — they apply bottom-up, execute top-down
  • Use decorators for cross-cutting concerns: logging, timing, retry, caching, validation