TRT
Sign in
easyFoundationscoding

Explain how closures work at the bytecode/memory level in CPython (`PyCellObject`, `LOAD_DEREF`, `nonlocal`), why lambdas in a loop all see the final loop.

Trace `co_freevars`, `co_cellvars`, `__closure__`, `UnboundLocalError`, and 3-layer parameterized decorators `def retry(max_attempts):`.

TRT practice prompt — not a verified question from a named employer.

The Runtime Theory Team2 min read

A strong answer

When an inner function references a variable i from an enclosing scope, CPython compiles i as a free variable stored in a heap-allocated PyCellObject (__closure__). Crucially, the closure captures the cell pointer to the variable i, NOT the value i had when the lambda was created (late binding)! By the time the loop for i in range(4) finishes, the shared cell i holds 3, so all 4 lambdas read 3 from that same cell when called later. Binding the current value as a default argument lambda i=i: run_step(i) evaluates i eagerly at function creation time (__defaults__). Furthermore, a decorator @dec replaces fn = dec(fn); without @functools.wraps(fn), the wrapper hides fn.__name__, fn.__doc__, fn.__annotations__, and fn.__wrapped__, breaking frameworks like FastAPI, pytest, and inspect.signature that introspect type annotations.

A closure variable resolved via LOAD_DEREF reads the current ob_ref inside the shared PyCellObject at call time (late binding), whereas a default parameter i=i in fn.__defaults__ captures the PyObject* reference at function definition time (early binding). Because all iterations of a loop in the same scope share a single PyCellObject for the loop variable i, mutating i on each iteration updates cell->ob_ref in place; only creating a new binding per iteration (via default argument i=i or a factory function make_handler(i)) allocates a distinct reference per callback.

LOAD_DEREF (closure cell lookup) adds only 1 pointer indirection over LOAD_FAST, and a @functools.wraps decorator adds ~50–100 ns per call while preserving full runtime type introspection. In production systems (FastAPI route/dependency introspection (inspect.signature following __wrapped__), functools.lru_cache, pytest.fixture, and React/Python UI event callbacks all depend on closure cell binding and @functools.wraps.), engineers monitor these exact trade-offs to keep p99 latency predictable and prevent resource exhaustion under peak load.

What the interviewer is listening for

  • Traces how lexical closures (PyCellObject), late binding, and @functools.wraps decorators operates in memory step by step rather than giving a vague textbook definition.
  • States the core correctness invariant and explains why edge cases do not violate it.
  • Quantifies time, space, and hardware costs (Local Variable (LOAD_FAST), Closure Cell (LOAD_DEREF)) with concrete numbers.
  • Identifies real-world failure modes (Defining callbacks [lambda: send(u) for u in users] inside a loop) and explains how production systems mitigate them.

Common mistakes

  • Treating lexical closures (PyCellObject), late binding, and @functools.wraps decorators as a black box without explaining the underlying memory or state transitions.
  • Ignoring worst-case tail latency or memory overhead (Omitting @functools.wraps(fn) in a custom decorator).
  • Forgetting boundary conditions and error recovery (Decorating an async def coroutine with a synchronous wrapper that returns fn(*args) without await).

This answer walks

Practice follow-ups

  1. 01Write a parameterized decorator `@retry(times=3)` that works both on sync functions (`def`) and async coroutines (`async def`) using `inspect.iscoroutinefunction`.
  2. 02How do you inspect the actual captured values inside a closure at runtime (`fn.__closure__[0].cell_contents`)?

Not started

Sign in to save your learning progress.

Sign in to save