Python path · Functions and control flow · Topic 4 of 12

Closures and decorators

Not started

Functions in Python are objects created when a def statement runs, and a function defined inside another one can keep using the outer function's variables after the outer call has returned. That pairing of a function and the variables it captured is a closure. A decorator is a function that takes a function and returns a replacement, usually a closure that wraps the original.

Why it matters: Decorators are everywhere in Python (web routes, caching, retries, tests), and every one of them is a closure underneath.

  1. 1Scope is decided at compile time

    A name assigned anywhere in a function is local to the whole function, unless you declare it global or nonlocal.

  2. 2Closures keep variables, not values

    An inner function holds a reference to the outer variable's cell, so it sees the latest value when it runs.

  3. 3@ is just a call

    `@deco` above `def f` means `f = deco(f)`, run once when the def executes.

  4. 4Wrap politely

    Use functools.wraps so the wrapper keeps the original's name, docstring and signature for tools and debuggers.

1

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 value

To 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)   # 3

count 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 decorator

The 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.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.What does this print?
    fns = [lambda: i for i in range(3)]print([f() for f in fns])
  2. 2.When does the body of a decorator (not the wrapper) run?
  3. 3.Without functools.wraps, what is `slow_square.__name__` after `@timed`?

0 of 3 right so far. Get all three to finish this topic.