35 · Advanced decorators: factories, args, stacking
Go beyond plain @decorator — write configurable decorator factories, stack multiple decorators correctly, and master the standard library decorators that appear in every ML codebase.
A **decorator factory** is a function that *returns* a decorator, adding one extra level of nesting so you can pass arguments: `@retry(times=3)` calls `retry(times=3)` which returns a decorator which wraps the function. Stacked decorators (`@a` on top of `@b`) apply bottom-up: `f = a(b(f))`. `functools.lru_cache` is the single most impactful decorator in the standard library — it turns any pure function into a memoized one.
Without this:
Without decorator factories you can't express 'apply this cross-cutting behaviour with configuration' — every retry logic, cache size limit, or rate limit would need to be hardcoded or passed as an argument to every function call instead of declared once at definition time.
You already know that a plain decorator is a function that takes a function and returns a function. A decorator factory is one level deeper: it's a function that takes configuration arguments and returns a decorator.
# Plain decorator — no parens at usage site
@timer
def f(): ...
# equivalent to: f = timer(f)
# Decorator factory — parens required at usage site
@retry(times=3)
def g(): ...
# equivalent to: g = retry(times=3)(g)
# ^^^^^^^^^^^ this call returns a decorator
# ^^^ this call wraps g
The rule: if you see parentheses at the @ line, it's a factory, not a plain decorator. The factory is called first (with the config args), and the result is the decorator that wraps the function.
Stacked decorators apply bottom-up — the decorator closest to the def is applied first. @a above @b means a(b(f)): b wraps f first, then a wraps the result.
Python (in browser)
The three-level nesting — `retry` → `decorator` → `wrapper` — is the canonical shape of a decorator factory. `@functools.wraps(fn)` on the inner `wrapper` is essential: without it `flaky_fetch.__name__` would be `'wrapper'` and `help(flaky_fetch)` would show the wrong docstring.
Python runs entirely in your browser via Pyodide (~6 MB on first Run, cached after).
Python (in browser)
`lru_cache(maxsize=None)` (or equivalently `functools.cache` in Python 3.9+) stores every `(args,)` → return-value mapping indefinitely. `fib(35)` without caching makes ~29 million recursive calls; with caching it makes exactly 36 (one per unique input). The `cache_info()` method lets you inspect hit rate at runtime — useful for tuning `maxsize`.
Python runs entirely in your browser via Pyodide (~6 MB on first Run, cached after).
When to use each: `@property` for computed reads (and validated writes); `@staticmethod` for utility functions that logically belong to the class but need no `self` or `cls`; `@classmethod` for alternative constructors; `@cached_property` for expensive attributes that are computed once and reused.
Read stacked decorators bottom-up for application order, top-down for execution order. `@bold` on top of `@italic` means `bold` is the outermost wrapper — its code runs first when `greet()` is called, then it calls through to `italic`'s wrapper, which calls through to the original `greet`. The mental model: each decorator adds a layer of an onion, and the call peels the onion from the outside in.
What is the difference between writing @retry and @retry(3) above a function definition?
- A decorator factory is a function that returns a decorator, enabling `@retry(times=3)` syntax. The factory is called first (with config), and its return value is the decorator applied to the function.
- Stacked decorators apply bottom-up: `@a` on top of `@b` means `f = a(b(f))` — `b` wraps first, `a` wraps the result.
- `@functools.lru_cache(maxsize=None)` (or `@functools.cache` in 3.9+) memoises a pure function — each unique args tuple is computed exactly once.
- Always apply `@functools.wraps(fn)` to the innermost wrapper so `__name__`, `__doc__`, and `__module__` survive decoration.
- Standard library decorator toolkit: `@property` / `@x.setter`, `@staticmethod`, `@classmethod`, `@functools.cached_property`, `@functools.lru_cache`, `@dataclasses.dataclass`.
`@torch.no_grad()` is a decorator factory — the parentheses are required, and it returns a context manager / decorator that disables gradient tracking for inference. `@functools.lru_cache` is ubiquitous for caching tokenised inputs, feature transforms, and vocabulary lookups. `@retry(times=3)` is the standard pattern for flaky HTTP calls in data-collection and model-serving pipelines. `@functools.cached_property` is common on `nn.Module` subclasses for lazy-loaded embeddings and computed config properties.
If you remove it: Without decorator factories, configurable cross-cutting concerns (retry logic, caching with custom sizes, gradient control) would need to be woven manually into every function body — making them harder to reuse, test, and toggle. Decorator factories are the reason you can add `@retry(times=3)` to a data-fetching function and never think about retry logic again.