Compilers · Semantic Analysis

Closure Capture, Lexical Scope, and Storage Duration

Why capturing a variable is a decision about storage rather than about names, how an escaping frame is moved to the heap, and what breaks when a loop body shares one binding.

A nested function that mentions a name from an enclosing function creates a binding the resolver can see and the runtime must materialize. The name resolution is the easy part: the scope walk from Resolving Identifier Uses Against a Stack of Lexical Scopes finds the outer binding, and the use node records the number of frames to walk outward. What makes closures hard is that the outer frame’s lifetime is decided by control flow, not by syntax, and the inner function’s lifetime is usually longer.

The lifetime question behind every capture

A frame is created when a function is entered and destroyed when it returns. A function value created inside that frame can be returned, stored in a data structure, or passed to something that keeps it. If it outlives the frame and refers to a local of that frame, the local must outlive the frame too.

Three strategies exist, and every language picks one or a combination.

Capture by value copies the variable’s contents into the closure at creation time. The closure then holds a private copy, and later writes to the original are invisible to it. A C++ lambda with [=] captures by value (an odr-use of this captures the pointer, not the object) and a lambda with [&] captures by reference, and the default can be overridden per variable. Java requires captured locals to be effectively final, which rules out the observable difference rather than resolving it: a variable that cannot be reassigned cannot diverge between the copy and the original.

Capture by reference stores a pointer to the variable’s storage, so the closure and the enclosing function share one cell; Go closures, JavaScript closures, and Python closures all behave this way, which is why a closure can observe a later write to the variable it captured. The storage then must be moved out of the stack frame into a heap-allocated environment so it survives the return, as any implementation must do when the closure and the enclosing function observe each other’s writes.

Capture by environment pointer stores one pointer to an environment record, and every free variable is reached by an offset from it. This is the cheapest at closure-creation time — one pointer copied instead of one per variable — and it is what most implementations of nested functions do. Pascal-family compilers typically use a display instead: a small array of frame pointers indexed by nesting depth, which turns the access into one indexed load to find the record and one offset load for the variable, at the cost of saving and restoring display entries on every call that changes a level.

What capture analysis computes

The compiler needs, for each function expression, the list of variables it uses that are declared outside it, together with how each is reached. Computing that list is a bottom-up pass over the function body: start with the set of names used in the body, subtract the names declared in the body’s own scopes, and recurse into nested functions taking their free sets as uses of the current function.

The result is a free-variable set per function, and for the enclosing function the usage matters even though no binding is created there: a function that calls a nested function which captures an outer variable makes that variable live for as long as the inner closure lives, so its own frame must not be treated as free of that variable when escape analysis runs. Skipping that propagation decides the frame cannot escape and hands out a closure whose inner function reaches a record that has already been reclaimed.

Alongside the set, the analysis records the depth of each capture — how many frames outward the binding lives. A use at depth 0 is local. A use at depth 1 refers to the immediately enclosing function. Depth 2 skips one. The runtime representation of a closure must then support indexed access down a chain of environment pointers, which is why environments usually carry a pointer to the enclosing environment. A compiler may instead flatten the chain at creation time, copying the needed outer environment pointers into the closure, which trades record size for a fixed offset per capture.

The flat-closure alternative stores every captured variable’s address directly in the closure record, eliminating the chain walk at the cost of a larger record and a store per capture at creation time. For a closure capturing two variables the flat form is smaller and faster; for one capturing twenty it is larger, and the space is paid per closure rather than once per frame.

Escaping analysis and the boxing decision

Whether a variable must move to the heap is a question about escape, and it is the same question a compiler asks when deciding whether an allocation can be stack-allocated. A local captured only by closures that do not escape can stay on the stack, and those closures are valid only while the frame is live. A local captured by a closure that escapes must be boxed.

Escape analysis answers it per closure, not per variable. A closure passed to a function whose parameter is not marked as retaining it does not escape; one returned, stored in a field, or sent on a channel does. Without interprocedural information the analysis must be conservative and box every captured variable, which is correct and expensive: each captured local becomes a heap cell, and every read and write goes through an indirection.

The middle ground used by many implementations is to box selectively. The frame is allocated on the stack and only the variables that are captured and whose closure escapes are moved into a separate heap record; a pointer to that record is stored in the frame, so accesses through it are unchanged. Go’s compiler performs this analysis per variable and reports the result as the “moved to heap” decision in its diagnostics; escape decisions surface in the compiled code only as the absence of a stack slot, which is why reasoning about allocation in a language with closures requires knowing which variables were captured.

The loop-variable problem

The most consequential interaction between capture and scope is the treatment of a variable declared in a loop header.

In JavaScript before let, var declarations are function-scoped: one binding exists for the entire function, and every closure created in the loop body captures that same binding. A loop that creates a function per iteration therefore produces functions that all see the final value. This is a consequence of the specification’s environment model, not an implementation quirk, and it is why let was specified with a fresh binding per iteration: each iteration gets a new environment record, so each closure captures a distinct cell.

Go carried the same trap. Up to Go 1.21, a for loop with i := 0; i < n; i++ declared one variable for the whole loop, and a closure capturing i saw the final value. Go 1.22 changed the semantics so that each iteration has its own copy of the loop variable, gated on the module declaring Go 1.22 or later in its go directive, which is why the change did not silently alter older modules. The lesson for a compiler under construction is that the binding structure of a loop header is a language design decision that must be settled before capture analysis is written, because the analysis’s answer for a loop body depends on it entirely.

C# has a related distinction in foreach: the iteration variable is a fresh variable per iteration, while a for loop’s variable is shared. That asymmetry is specified, and a checker that models loops uniformly will mispredict which closures see which values.

Representing the environment

The classic representation is a linked record: each function’s frame holds its locals and a pointer to the enclosing frame; a closure holds a pointer to the frame in which it was created plus a code pointer. Access to a captured variable is a fixed number of pointer dereferences — the depth is known statically — followed by a load at a fixed offset.

The problem with one frame per function is that a function with hundreds of locals forces every closure created in it to keep all of them alive. Implementations therefore split the frame: locals that are never captured stay on the stack, and captured ones go into a separate environment record holding only those. The record is smaller, and the liveness of the non-captured locals is unaffected by closures.

The layout below is the chained form in C: a closure holds a code pointer and a pointer to the environment record it was created in, and that record links to its own enclosing record. A captured variable at depth 2 is two links and one offset away, and the depth is a compile-time constant, so the access costs exactly that many dependent loads.

struct env {
    struct env *parent;      /* enclosing function's record, or NULL */
    size_t nslots;
    void *slots[];           /* boxed captured locals, by slot index */
};

struct closure {
    void (*code)(struct closure *, void *);
    struct env *env;         /* record of the creating frame */
};

static inline void **captured(struct env *e, unsigned depth, unsigned slot)
{
    while (depth--)
        e = e->parent;       /* depth is a static constant, so this unrolls */
    return &e->slots[slot];
}

The slots array holds pointers rather than values so that a variable whose size is not yet fixed — a generic instantiation, or a local whose type is still an inference variable — can be accommodated without changing the record’s layout.

Lua’s implementation takes a different route for the common case where the captured variable is still live on the stack. An upvalue starts as a pointer into the stack frame, and every upvalue pointing into one frame is linked together so that all of them can be relocated in a single pass. When the frame closes, each open upvalue is closed: the value is copied into the upvalue object and the pointer is retargeted at that copy. The cost while the frame is open is one indirection per access, and the benefit is that the closure observes the variable’s current value rather than a snapshot taken at creation. That distinction is what makes the loop-variable rules above observable at all.

Diagnostics and edge cases

Capture analysis produces errors in languages that restrict it. Java requires captured locals to be final or effectively final, which is checked by the assignment-count analysis described in Definite Assignment as a Flow-Sensitive Dataflow Problem. A captured variable assigned after the lambda is created is rejected because the capture is a copy taken at creation, and the language refuses to let the two diverge silently.

Recursive closures are a separate case: a local function that refers to itself through a captured binding must have that binding already in scope where the closure is created, which is why the idiom is a declaration followed by an assignment rather than a single definition. The checker’s ordering rule has to allow the reference inside the function body while still rejecting a read at closure-creation time, which places this in the territory of Definite Assignment as a Flow-Sensitive Dataflow Problem rather than in the capture analysis itself.

The last edge case is a closure capturing a variable whose type is not yet fully known. If the capture happens before inference completes, the environment slot’s type must be a metavariable that is later unified, and a slot layout computed at creation time is not yet final. Implementations resolve this by delaying layout until inference has finished for the enclosing function, and by keeping captured storage in a record that is addressed by slot index rather than by a fixed byte offset until then. That is one reason an environment record tends to hold pointers rather than inline values, and it connects capture to the inference machinery in Bottom-Up Expression Typing with Inference Variables.

References

  1. The Implementation of Lua 5.0 — Ierusalimschy, de Figueiredo, Celes; Journal of Universal Computer Science, 2005
  2. Compiling with Continuations — Andrew W. Appel; Cambridge University Press
  3. The Go Programming Language Specification — Function literals — The Go project
  4. ECMAScript Language Specification — Lexical Environments and CreateMutableBinding — Ecma International