20 · Closures and decorators
Functions that remember their birth environment, and the @decorator syntax that wraps any callable without touching its source.
A **closure** is a function that captures variables from its enclosing scope and keeps them alive after the outer function returns. A **decorator** is a closure that wraps another function — `@dec` is syntactic sugar for `f = dec(f)`.
Without this:
Without closures and decorators, cross-cutting concerns (timing, logging, caching, gradient disabling) must be copy-pasted into every function they affect. A decorator applies the concern once and lets the original function stay clean.
A closure is created when a nested function references variables from its enclosing (outer) function's scope. Python keeps those variables alive in a cell object even after the outer function returns. Use nonlocal to mutate an enclosing variable from inside the nested function (the same rule as global, one scope level up). A decorator is a function that takes a function as its argument and returns a new (wrapped) function. The @decorator syntax above a def is shorthand for fn = decorator(fn) — Python applies it automatically at function definition time. Always use functools.wraps(original) inside a decorator to preserve the original function's __name__, __doc__, and other metadata.
Python (in browser)
`make_counter` returns `counter` — the inner function. Each call to `make_counter` creates a fresh `count` cell, so `epoch_counter` and `batch_counter` are fully independent despite sharing the same `counter` definition.
Python runs entirely in your browser via Pyodide (~6 MB on first Run, cached after).
Python (in browser)
`@timer` wraps `sum_of_squares` — every call now prints its execution time. `*args, **kwargs` in `wrapper` forwards all arguments unchanged, so the decorator is transparent to callers.
Python runs entirely in your browser via Pyodide (~6 MB on first Run, cached after).
Without `@functools.wraps`, every decorated function reports its name as `'wrapper'` — breaking `help()`, logging, and tracebacks. Always include it.
What is `@dec` above a `def f():` shorthand for?
Gradient descent relies on closures: the gradient function *captures* the loss function and the current model parameters, forming a closure that can be evaluated at any point in parameter space. Each step of the optimizer calls this closure with the current weights to get the direction of steepest descent.
- A closure captures variables from its enclosing scope into *cells* that outlive the outer function. Use `nonlocal` to mutate them.
- `@decorator` is shorthand for `f = decorator(f)`. A decorator is a function that receives a function and returns a (usually wrapped) function.
- Always use `@functools.wraps(original)` inside a decorator to preserve `__name__`, `__doc__`, and other metadata of the wrapped function.
PyTorch's `@torch.no_grad()` decorator disables gradient tracking for inference and evaluation loops — without it, every forward pass during validation wastes memory building a computation graph. `@functools.lru_cache` memoizes expensive data transforms or tokenisation calls. Logging hooks and profilers wrap model methods with decorators to inject timing without modifying the model's source code.
If you remove it: Without decorators, adding timing, logging, or gradient disabling to a function requires editing its source — violating the open/closed principle and making the code harder to maintain. A single `@timer` or `@torch.no_grad()` replaces dozens of manual `t0 = time.perf_counter()` / `with torch.no_grad():` blocks scattered across the codebase.