Decorators: Mastering Wrappers
Exploiting Python's first-class functions to extend behavior dynamically. From closures to metaprogramming.
1. The Theoretical Foundation
👶 Explain Like I'm 10: The Gift Wrapper
Imagine you have a plain Toy (a function). You want to give it as a gift. You don't break the toy to paint it. Instead, you put it inside a Shiny Box (the Decorator). When the child opens it, they get the same Toy, but the experience was enhanced (wrapping paper, bow, card). A Decorator is just a wrapper function that runs some code before and after your original function runs.
Before we touch the `@` syntax, we must understand the core concept: Functions are Objects. In Python, functions are "First-Class Citizens". This means:
- You can assign them to variables (`x = my_func`).
- You can pass them as arguments to other functions.
- You can return them from other functions.
- You can define them inside other functions (Nested Functions).
This capability allows us to use Higher-Order Functions—functions that operate on other functions. This is the bedrock of functional programming and the mechanism that powers decorators.
2. The Syntax Sugar
A decorator is simply a function that takes a function `f` and returns a function `g`. The `@` symbol is just "Syntactic Sugar" (a shortcut) for this assignment.
# The Manual Way (Under the hood)
def my_decorator(func):
def wrapper():
print("Something before...")
func()
print("Something after...")
return wrapper
def say_hello():
print("Hello!")
say_hello = my_decorator(say_hello) # 👈 This is the real magic
# The Pythonic Way (Syntax Sugar)
@my_decorator
def say_hello():
print("Hello!")When Python sees `@my_decorator`, it immediately executes `my_decorator(say_hello)` at import time (when the file is loaded), not when the function is called. This is a critical distinction for understanding side effects.
3. Real-World Pattern: The Timer
One of the most common uses for decorators is instrumentation: measuring how long things take without polluting the business logic.
import time
import functools
def timer(func):
"""Print the runtime of the decorated function"""
@functools.wraps(func) # We will explain this shortly!
def wrapper_timer(*args, **kwargs):
print(f"â± Starting {func.__name__}...")
start_time = time.perf_counter() # High precision clock
value = func(*args, **kwargs) # Call the original
end_time = time.perf_counter()
run_time = end_time - start_time
print(f"✅ Finished {func.__name__} in {run_time:.4f} secs")
return value
return wrapper_timer
@timer
def waste_some_time(num_times):
for _ in range(num_times):
sum([i**2 for i in range(10000)])
waste_some_time(1)
# Output:
# â± Starting waste_some_time...
# ✅ Finished waste_some_time in 0.0023 secs4. Deep Dive: Metadata and `functools.wraps`
When you wrap a function, you accidentally hide its identity. If you check `waste_some_time.__name__` on a decorated function without `wraps`, it will return `wrapper_timer`, not `waste_some_time`.
This is bad because:
- Debuggers show confusing names in stack traces.
- Documentation Generators (Sphinx/Pydoc) can't find the original docstring.
- Pickling (serialization) might fail.
The Fix: `@functools.wraps(func)` is a decorator for your decorator. It copies the `__module__`, `__name__`, `__qualname__`, `__doc__`, and `__annotations__` from the original function to the new wrapper. Always use it.
5. Advanced Pattern: Decorators with Arguments
What if we want to configure the decorator? For example, `@retry(times=3)` vs `@retry(times=10)`. To do this, we need a Decorator Factory. This requires three levels of nested functions.
def retry(max_retries=3, delay=1):
# Layer 1: The Factory (Receives arguments, returns a decorator)
def decorator(func):
# Layer 2: The Decorator (Receives function, returns wrapper)
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Layer 3: The Wrapper (Executes logic)
attempts = 0
while attempts < max_retries:
try:
return func(*args, **kwargs)
except Exception as e:
attempts += 1
print(f"âš ï¸ Attempt {attempts} failed: {e}")
if attempts == max_retries:
raise # Give up
time.sleep(delay)
return wrapper
return decorator
@retry(max_retries=2, delay=0.5)
def unstable_network_call():
import random
if random.random() < 0.8:
raise ConnectionError("Network flaked out!")
return "Success Payload"
# It acts like a resilient function now!
print(unstable_network_call())Heuristic: If you see parentheses after the decorator name (`@my_dec()`), it's a Factory calling a function. If no parentheses (`@my_dec`), it's the Decorator itself.
6. Class-Based Decorators
Decorators don't strictly have to be functions. They can be Classes! If a class implements `__call__`, instances of it can act like functions. This is often cleaner when your decorator needs to maintain complex state (like a cache or a count).
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.num_calls = 0 # State is persistant!
def __call__(self, *args, **kwargs):
self.num_calls += 1
print(f"Call {self.num_calls} of {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hi():
print("Hi!")
say_hi() # Call 1...
say_hi() # Call 2...7. Comparison: Decorators vs Java Annotations
Decorators are often compared to Annotations in Java or Attributes in C#, but they are fundamentally different.
| Feature | Python Decorators | Java Annotations |
|---|---|---|
| Execution | Active. They execute code at import time and wrap the function. | Passive. They just add metadata tags. |
| Behavior | Can completely change or replace the function logic. | Requires an external processor (Reflection) to act on them. |
| Flexibility | Higher. Can implement complex logic (caching, proxies). | Lower. Mostly used for configuration/validation. |
8. Applying Multiple Decorators
You can stack multiple decorators on a single function (`Onion Architecture`). But be careful: Order Matters. They are applied from bottom-to-top (inner-to-outer).
@debug
@timer
@auth_required
def database_query():
pass
# Is equivalent to:
# database_query = debug(timer(auth_required(database_query)))If `auth_required` fails and raises an error, `timer` might never run, and `debug` might see the exception. Think of it like nesting dolls. The inner-most decorator runs first on the way IN, and last on the way OUT.