Closures: Functions That Remember
A closure is a function that retains access to variables from its enclosing scope, even after that scope has finished. This enables stateful functions without global variables.
def make_counter(start=0):
count = start # Captured variable
def counter():
nonlocal count # Required to MODIFY (not just read) the captured var
count += 1
return count
return counter # Return the inner function
c1 = make_counter()
c2 = make_counter(10) # Independent counter starting at 10
print(c1()) # 1
print(c1()) # 2
print(c1()) # 3
print(c2()) # 11 โ separate state
print(c1()) # 4 โ c1 not affectedClosure for Memoization
def make_memoized(func):
cache = {} # Each closure gets its own cache
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
def slow_fib(n):
if n <= 1: return n
return slow_fib(n-1) + slow_fib(n-2)
fast_fib = make_memoized(slow_fib)
fast_fib(30) # First call: computes
fast_fib(30) # Instant: from cacheDecorators
A decorator is syntax sugar for wrapping a function with another function. @wrapper is exactly equivalent to func = wrapper(func).
import time
import functools
def timer(func):
@functools.wraps(func) # Preserve original function's metadata
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"{func.__name__} took {end-start:.4f}s")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
slow_sum(10_000_000)
# slow_sum took 0.2341s
# @timer is EXACTLY equivalent to:
# slow_sum = timer(slow_sum)Always use @functools.wraps
Without @functools.wraps(func), your decorated function loses its name, docstring, and other metadata. Always include it in decorators that wrap user functions.
Decorator with Arguments (Decorator Factory)
def repeat(times):
"""Decorator factory โ takes args, returns a decorator"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
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!Practical Decorators
# Logging decorator
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}({args}, {kwargs})")
result = func(*args, **kwargs)
print(f" โ returned {result}")
return result
return wrapper
# Retry decorator
def retry(max_attempts=3, delay=1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
print(f"Attempt {attempt+1} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def call_api(url):
import urllib.request
return urllib.request.urlopen(url).read()
# Cache decorator (built-in version)
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) # Fast even for large nStacking Decorators
@timer
@log_calls
@retry(max_attempts=2)
def fetch_data(url):
pass
# Applied bottom-up: retry wraps fetch_data, log_calls wraps that, timer wraps all
# Equivalent to: fetch_data = timer(log_calls(retry(2)(fetch_data)))Key Takeaways
- Closures capture enclosing scope: use
nonlocalto modify captured variables - @decorator is syntax sugar for
func = decorator(func) - Always @functools.wraps: preserves name, docstring, signature
- Decorator factories: functions that return decorators, enabling
@decorator(args) - @lru_cache: built-in memoization โ use for expensive pure functions
Practice Exercises
- Write a
@validate_positivedecorator that raisesValueErrorif any positional argument is negative. - Write a
@rate_limit(calls_per_second)decorator that sleeps if the function is called too frequently. - Write a
@singletondecorator that ensures a function is only called once, returning cached result after that. - Implement a closure-based counter that exposes both
increment()andreset()operations.