Python Closures & Decorators
In Python, functions are First-Class Objects. This means functions can be assigned to variables, passed as arguments into other functions, stored in data structures, and returned from functions.
What is a Lexical Closure?
A Closure is a function object that remembers values in enclosing lexical scopes even if those scopes are no longer present in memory.
For a closure to occur, three criteria must be met:
- There must be a nested function (a function inside a function).
- The inner nested function must reference a variable from the enclosing parent function.
- The enclosing parent function must return the inner function object.
# Function Factory creating customizable power calculation closures:
def make_power_calculator(exponent):
"""Enclosing parent function."""
def power(base):
"""Inner function remembering 'exponent' from parent scope."""
return base ** exponent
return power # Returns the inner function object!
# Create specialized mathematical power functions:
square = make_power_calculator(2)
cube = make_power_calculator(3)
print("Square of 5:", square(5)) # 25 (remembers exponent = 2)
print("Cube of 5: ", cube(5)) # 125 (remembers exponent = 3)
print("Square Closure cell contents:", square.__closure__[0].cell_contents)
When make_power_calculator finishes executing, its local stack frame is destroyed. However, Python detects that the inner function references exponent, so it stores exponent in a special persistent tuple of cell objects (__closure__) attached to the returned function.
A Decorator is a higher-order function that takes another function as an argument, extends or alters its behavior without modifying its source code, and returns the enhanced function.
Why @functools.wraps is Strictly Mandatory:
When you wrap a function, the inner wrapper function replaces the original function object. Without @functools.wraps(func), the original function's identity is erased:
func.__name__becomes"wrapper"instead of"calculate".func.__doc__is erased.- Docstring inspection, unit test runners, and frameworks (like FastAPI and Sphinx) break completely!
import functools
import time
def benchmark_timer(func):
"""Production execution timer decorator preserving original function metadata."""
@functools.wraps(func) # Crucial: Preserves __name__, __doc__, and type annotations!
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs) # Call original function with any arguments
duration_ms = (time.perf_counter() - start) * 1000
print(f"โฑ๏ธ [{func.__name__}] Finished in {duration_ms:.3f} ms")
return result
return wrapper
# Apply decorator using @ syntax:
@benchmark_timer
def process_data(limit):
"""Processes computational numbers up to limit."""
return sum(x ** 2 for x in range(limit))
# Execute decorated function:
res = process_data(500_000)
print("Result Total:", res)
print("Original Function Name Preserved:", process_data.__name__)
print("Original Docstring Preserved: ", process_data.__doc__)
By accepting *args, **kwargs and unpacking them as func(*args, **kwargs), your decorator becomes universally applicable to any function regardless of its argument signature.
To pass custom configuration arguments directly into a decorator (e.g. @retry(max_attempts=3, delay=1.0)), you must implement a 3-level nested function factory:
- Level 1 (Outer Decorator Factory): Receives configuration parameters (e.g.
max_attempts). - Level 2 (Middle Decorator): Receives the target function to be decorated.
- Level 3 (Inner Wrapper): Receives arguments (
*args, **kwargs) and executes the core wrapper logic.
import functools
def repeat(num_times=2):
"""Decorator factory accepting custom configuration parameters."""
def decorator_repeat(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_result = None
for i in range(num_times):
print(f"๐ Execution #{i+1} of {func.__name__}():")
last_result = func(*args, **kwargs)
return last_result
return wrapper
return decorator_repeat
# Applying decorator with custom configuration:
@repeat(num_times=3)
def send_alert(message):
print(f" ๐ ALERT: {message}")
send_alert("Database connection threshold reached!")
This 3-level factory pattern is the exact architectural mechanism used by web frameworks like FastAPI (@app.get("/users/{id}")) and testing libraries like pytest (@pytest.mark.parametrize(...)).
If a decorator is defined as a factory with arguments def my_dec(arg=1):, you MUST apply it with parentheses @my_dec() even when using default arguments. Writing @my_dec without parentheses passes the function into the factory instead of the inner decorator!
Build an authorization decorator require_role(allowed_role) that checks if user["role"] matches allowed_role before allowing function execution.
import functools
def require_role(allowed_role):
def decorator(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if user.get("role") != allowed_role:
print(f"โ Access Denied: User '{user.get('name')}' is not a {allowed_role}!")
return None
return func(user, *args, **kwargs)
return wrapper
return decorator
@require_role("Admin")
def purge_cache(user):
print(f"โ
Cache purged successfully by {user['name']}!")
user1 = {"name": "Balaji", "role": "Admin"}
user2 = {"name": "Alex", "role": "Guest"}
purge_cache(user1) # Success
purge_cache(user2) # Blocked
Q What is the order of execution when stacking multiple decorators?
Decorators execute from bottom to top (inside out). If you write @dec_a above @dec_b, the resulting execution is dec_a(dec_b(func)).
Q What is functools.lru_cache?
lru_cache is a built-in standard library decorator that memoizes function return values, caching results of expensive recursive or I/O calls based on argument hashes.
Q Can classes be used as decorators in Python?
Yes! Any Python class that implements the __call__() magic method can act as a decorator, which is especially useful when the decorator needs to maintain state across multiple calls.