A decorator in Python is a function designed to alter the behavior of another function or method without permanently modifying its source code. The core mechanism often relies on the concept of a closure, where an inner function retains access to variables from the scope of its outer function, even after the outer function has completed execution.
Examining Closures
A closure is created when a nested function references a variable from its enclosing scope. This allows the inner function to "remember" its originating environment.
Basic Closure Example
def message_generator(prefix):
stored_message = [prefix]
def add_recipient(recipient):
stored_message.append(recipient)
print(f"Message: {' '.join(stored_message)}")
return add_recipient
send_message = message_generator("Hello")
send_message("Alice") # Output: Message: Hello Alice
send_message("Bob") # Output: Message: Hello Alice Bob
Variable Binding in Nested Scopes
Nested functions can modify objects of mutable types (like lists or dictionaries) from an outer scope. However, attempting to reassign a variable of an immutable type (like a string or integer) directly will create a new local variable unless explicitly declared otherwise with the nonlocal keyword.
Example Causing an Error
def outer():
count = 0
def inner():
# This creates a new local variable 'count', shadowing the outer one.
count += 1
print(count)
return inner
try_func = outer()
try_func() # Raises UnboundLocalError
Corrected Using nonlocal
def outer():
count = 0
def inner():
nonlocal count # Explicitly refers to the outer variable
count += 1
print(f"Count: {count}")
return inner
counter = outer()
counter() # Output: Count: 1
counter() # Output: Count: 2
A Common Pitfall with Loop Variables
When creating closures inside a loop, all inner functions may inadvertently reference the same loop variable, leading to unexpected results.
def create_callbacks():
functions_list = []
for num in range(3):
def callback():
return num * num
functions_list.append(callback)
return functions_list
cb1, cb2, cb3 = create_callbacks()
print(cb1(), cb2(), cb3()) # Output: 4 4 4 (Not 0, 1, 4)
All three returned functions capture the variable num. Since the loop completes before any function is called, they all reference the final value of num (which is 2), resulting in 2*2=4.
Solution: Bind the loop variable's current value to a parameter with a default argument at the time of definition, creating a new binding for each function.
def create_callbacks_fixed():
functions_list = []
for num in range(3):
def callback(value=num):
return value * value
functions_list.append(callback)
return functions_list
f1, f2, f3 = create_callbacks_fixed()
print(f1(), f2(), f3()) # Output: 0 1 4
Constructing Decorators
A decorator is essentially a fucntion that takes another function as input and returns a new function, typically implementde using closures.
The syntax @decorator above a function definition is equivalent to my_func = decorator(my_func).
Basic Decorator Structure
def simple_decorator(original_func):
def wrapper_func(): # The closure that will replace the original function
# Code to execute BEFORE the original function
print(f"Calling '{original_func.__name__}'")
result = original_func() # Call the original function
# Code to execute AFTER the original function
print(f"'{original_func.__name__}' finished")
return result
return wrapper_func
@simple_decorator
def say_hello():
print("Hello, world!")
say_hello()
# Output:
# Calling 'say_hello'
# Hello, world!
# 'say_hello' finished
Decorators with Arguments
To make a decorator that accepts its own arguments, an extra outer function layer is required.
def repeat_execution(times):
"""A decorator factory that returns a decorator."""
def actual_decorator(func_to_wrap):
def wrapper(*args, **kwargs):
for i in range(times):
print(f"Iteration {i+1}")
func_to_wrap(*args, **kwargs)
return wrapper
return actual_decorator
# Usage
@repeat_execution(times=3)
def display_message(msg):
print(f"Message: {msg}")
display_message("Test")
# Output:
# Iteration 1
# Message: Test
# Iteration 2
# Message: Test
# Iteration 3
# Message: Test
Preserving Function Metadata with functools.wraps
When a decorator returns a new wrapper function, it replaces the original function. This can obscure the original function's name, documentation, and other metadata. The functools.wraps decorator helps preserve this information.
from functools import wraps
def log_call(func):
@wraps(func) # Copies metadata from 'func' to 'wrapper'
def wrapper(*args, **kwargs):
print(f"LOG: Executing {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def compute_sum(a, b):
"""Returns the sum of two numbers."""
return a + b
print(compute_sum.__name__) # Output: compute_sum
print(compute_sum.__doc__) # Output: Returns the sum of two numbers.
print(compute_sum(5, 3)) # Output: LOG: Executing compute_sum
# 8
Without @wraps(func), compute_sum.__name__ would be "wrapper" and compute_sum.__doc__ would be None.
Practical Applications
Decorators provide a clean way to add cross-cutting concerns to functions:
- Logging: Automatically log function calls, arguments, and execution time.
- Access Control: Check user permissions before executing a function.
- Caching/Memoization: Store the results of expensive function calls.
- Input Validation: Sanitize or validate arguments before the core logic runs.
- Rate Limiting: Restrict how often a function can be called.