Closures: Functions That Remember
Before we can understand decorators, we need to understand closures. A closure is a function that "remembers" variables from its enclosing scope, even after that scope has finished executing. Think of it as a function bundled with its environment:
def make_multiplier(factor):
"""Returns a function that multiplies by factor."""
def multiply(x):
return x * factor # 'factor' is captured from the enclosing scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
print(double(100)) # 200
10 15 200
When make_multiplier(2) returns, its local variable factor would normally be destroyed. But the inner multiply function still needs it, so Python keeps it alive. Each call to make_multiplier creates a new closure with its own captured factor.
Function Decorators
A decorator is a function that takes another function as input and returns a new function that usually extends the behavior of the original. It's one of Python's most powerful patterns — used extensively in web frameworks, testing, caching, and logging.
The @decorator syntax is just syntactic sugar for func = decorator(func):
import time
def timer(func):
"""Measure how long a function takes to run."""
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer # Same as: slow_function = timer(slow_function)
def slow_function():
"""Simulate a slow operation."""
time.sleep(0.1)
return "done"
result = slow_function()
print(result)
slow_function took 0.1003s done
The wrapper function accepts *args, **kwargs so it works with any function signature. It calls the original function, does something extra (timing in this case), and returns the result.
functools.wraps — Preserving Metadata
Without @wraps, the decorated function loses its name, docstring, and other metadata. Always use @wraps in your decorators:
from functools import wraps
def debug(func):
@wraps(func) # Copies name, docstring, etc. from func to wrapper
def wrapper(*args, **kwargs):
args_str = ", ".join(repr(a) for a in args)
print(f"Calling {func.__name__}({args_str})")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@debug
def greet(name):
"""Greet someone by name."""
return f"Hello, {name}!"
greet("Alice")
print(f"Name: {greet.__name__}") # 'greet' (not 'wrapper')
print(f"Doc: {greet.__doc__}") # 'Greet someone by name.'
Calling greet('Alice')
greet returned 'Hello, Alice!'
Name: greet
Doc: Greet someone by name.@functools.wraps(func) in your decorator's wrapper function. Without it, debugging tools, documentation generators, and introspection break because the function appears to be named "wrapper".Decorators with Arguments
Sometimes you want to configure a decorator. For example, a retry decorator that takes the number of attempts. This requires an extra layer of nesting — a function that returns a decorator:
from functools import wraps
import random
def retry(max_attempts=3, exceptions=(Exception,)):
"""Retry a function up to max_attempts times."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == max_attempts:
raise
print(f" Attempt {attempt} failed: {e}. Retrying...")
return wrapper
return decorator
@retry(max_attempts=3, exceptions=(ConnectionError,))
def fetch_data():
"""Simulate an unreliable API call."""
if random.random() < 0.7:
raise ConnectionError("Server unavailable")
return {"status": "ok", "data": [1, 2, 3]}
try:
result = fetch_data()
print(f"Success: {result}")
except ConnectionError:
print("All attempts failed")
Built-in Decorators
Python comes with several powerful decorators. You've already seen @property in the OOP tutorial. Here are others you'll use frequently:
from dataclasses import dataclass, field
from functools import lru_cache
# @dataclass — auto-generates __init__, __repr__, __eq__
@dataclass
class Point:
x: float
y: float
def distance_to(self, other):
return ((self.x - other.x)**2 + (self.y - other.y)**2) ** 0.5
p1 = Point(0, 0)
p2 = Point(3, 4)
print(p1) # Point(x=0, y=0)
print(p1.distance_to(p2)) # 5.0
print(p1 == Point(0, 0)) # True
# @lru_cache — memoize function results
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(100)) # Instant! Without cache, this takes forever
print(fibonacci.cache_info())
Point(x=0, y=0) 5.0 True 354224848179261915075 CacheInfo(hits=98, misses=101, maxsize=128, currsize=101)
| Decorator | Module | Purpose |
|---|---|---|
@property | built-in | Define getter/setter as attribute access |
@staticmethod | built-in | Method that doesn't need self or cls |
@classmethod | built-in | Method that receives the class, not instance |
@dataclass | dataclasses | Auto-generate boilerplate methods |
@lru_cache | functools | Cache function results (memoization) |
@wraps | functools | Preserve decorated function's metadata |
@abstractmethod | abc | Force subclasses to implement a method |
Chaining Decorators
You can stack multiple decorators. They're applied bottom-up (the decorator closest to the function runs first):
@timer # Applied second (wraps the debug-wrapped function)
@debug # Applied first (wraps the original function)
def process(data):
return sorted(data)
process([3, 1, 4, 1, 5])
This is equivalent to process = timer(debug(process)).
🔍 Deep Dive: Class Decorators
Decorators aren't limited to functions. You can decorate classes too. @dataclass is the most common example. You can also write your own class decorators — they receive the class as an argument and return a modified (or new) class. A common use case is registering classes in a plugin system: @register adds the class to a registry dict so it can be discovered later.
⚠️ Common Mistake: Forgetting @wraps
Wrong:
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper # wrapper.__name__ is 'wrapper', not func's name!
Why: The decorated function loses its name, docstring, and module. This breaks debugging, logging, documentation, and serialization.
Instead:
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper