How it works
The explanation, with examples you can run.
How names are resolved
Python looks names up in four places, innermost first: the local scope, any enclosing function scopes, the module's globals, and the built-ins. Which scope a name belongs to is decided when the function is compiled: if a function assigns to a name anywhere in its body, that name is local everywhere in the function. That is why this fails:
x = 10def f(): print(x) x = 2 f() # UnboundLocalError: cannot access local variable 'x' where it is not associated with a valueTo assign to an outer variable, declare it: global x for a module variable, nonlocal x for one in an enclosing function.
Closures
def make_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment tick = make_counter()tick(); tick()print(tick()) # 3print(tick.__closure__[0].cell_contents) # 3count outlives make_counter because increment holds a reference to it through a cell object, which you can see in __closure__. The closure stores the variable, not a snapshot of its value, which produces the late-binding surprise:
fns = [lambda: i for i in range(3)]print([f() for f in fns]) # [2, 2, 2]fns = [lambda i=i: i for i in range(3)]print([f() for f in fns]) # [0, 1, 2]Default values are evaluated when the lambda is created, so i=i captures each value.
Decorators
@timed written above a def is shorthand for slow_square = timed(slow_square), applied immediately after the function is created:
import functoolsimport time def timed(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): start = time.perf_counter() try: return fn(*args, **kwargs) finally: print(f"{fn.__name__} took {time.perf_counter() - start:.3f}s") return wrapper @timeddef slow_square(n): """Square n, slowly.""" time.sleep(0.1) return n * n print(slow_square(4)) # prints "slow_square took 0.10…s", then 16print(slow_square.__name__, slow_square.__doc__) # slow_square Square n, slowly.wrapper is a closure over fn. *args, **kwargs forwards any arguments, and try/finally reports the time even when fn raises. Without functools.wraps the decorated function would report its name as wrapper and lose its docstring, which confuses logs, debuggers and frameworks that inspect signatures.
Decorators with arguments
@retry(times=3) has one more layer: retry(times=3) is called first and returns the real decorator. So a configurable decorator is a function that returns a function that returns a function:
def retry(times): def decorator(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): for attempt in range(1, times + 1): try: return fn(*args, **kwargs) except ConnectionError: print("attempt", attempt, "failed") raise ConnectionError(f"gave up after {times} attempts") return wrapper return decoratorThe standard library ships several decorators built this way, including functools.cache, functools.lru_cache, contextlib.contextmanager, property, staticmethod and dataclasses.dataclass. Class decorators work the same way, receiving the class instead of a function.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.