Module 3 ยท Lesson 11

Closures and Decorators

๐Ÿ Pythonโฑ 16 min read๐Ÿ“– Advanced

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 affected

Closure 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 cache

Decorators

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 n

Stacking 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

Practice Exercises

  1. Write a @validate_positive decorator that raises ValueError if any positional argument is negative.
  2. Write a @rate_limit(calls_per_second) decorator that sleeps if the function is called too frequently.
  3. Write a @singleton decorator that ensures a function is only called once, returning cached result after that.
  4. Implement a closure-based counter that exposes both increment() and reset() operations.
โ† Lambda and Higher-Order Functions