Loweringspec

Closures

A function value that refers to a variable from an enclosing scope keeps that variable alive after the enclosing frame is gone. The language question is what the closure captures; the compiler question is where the captured variable now lives.

The question

If a function returns a lambda that uses a local variable, where does that variable live after the function returns?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A function value that is no longer just a code pointer. At the source level it is a lambda plus the set of *free variables* it mentions — names that are neither parameters nor locals of the lambda, computed by the resolver as a by-product of name resolution. At the implementation level it is a pair: code, plus somewhere to find those free variables. This lesson is about why the pair is forced; [[closure-conversion]] is about how the pair is built.

What this phase may assume or do

Any representation is legal so long as every read of a captured variable through the closure observes the same value the language's scoping rules say it should, for the whole time the closure remains reachable. That forbids leaving a captured variable in a stack frame that can be destroyed while the closure is still alive, and — in a language that captures by reference — forbids copying it, since a copy would stop observing later writes. A compiler may keep a captured variable on the stack only if it can prove the closure cannot outlive the frame, which is [[escape-analysis]].

Key points

  • A closure is a function value plus access to the free variables it mentions from enclosing scopes.
  • The free-variable set is a by-product of name resolution: identifiers inside the lambda that bind to a declaration outside it.
  • Globals are not captured; they have an address, not a frame slot. Unmentioned locals are not captured either.
  • A captured variable cannot stay in a stack frame that the closure can outlive, so it goes into the closure or into a heap environment.
  • Capturing the value and capturing the binding are different features; the difference is invisible until someone writes through one of them.
  • Escape analysis is what makes a non-escaping closure free, and whether it runs decides most of the real-world cost.
  • Closures are the first place a scoping fact forces a storage decision — the same pattern recurs for coroutines and async.

The problem, in six lines

A local variable normally lives in its function's stack frame, and the frame is destroyed when the function returns. That is the deal: it is why locals are cheap. A closure breaks the deal by producing a value that still refers to the local after the return.

Nothing about the source suggests a difficulty. outer declares x, returns a function that reads x, and the caller calls that function. But by the time the returned function runs, outer's frame has been popped and the stack space has almost certainly been reused by whatever ran next. If x were still in that frame, the closure would read whatever now occupies those bytes.

So the compiler must do something. It has exactly three options and every language picks one of them: refuse to allow it, copy the value into the closure, or move the variable somewhere with a longer life. C picks the first — a function pointer captures nothing, which is why C callbacks all take a void* you fill in yourself. C++ makes you say which of the second and third you want. Most other languages pick the third and hide it.

The whole lesson
1function outer() {
2 let x = 10;
3 return () => x; // the returned function still mentions x
4}
5
6const f = outer(); // outer's frame is now gone
7f(); // must still produce 10

The variable x outlives the function that declared it. Nothing in the syntax announces this, and the entire implementation cost of closures follows from it.

Free variables are what the resolver already computed

The set of variables a lambda captures is not a new analysis. Name resolution has to bind every identifier to a declaration anyway; a lambda's free variables are precisely the identifiers inside it that bound to a declaration *outside* it. Compilers compute the set as the resolver unwinds, and store it on the lambda node.

Two details make the set smaller than people expect. Globals are not captured, because they are not in any frame — a reference to a global is a reference to a fixed address, and no closure has to carry it. And a variable mentioned only in a nested lambda is still free in the outer lambda, transitively, which is what makes nested closures interesting: an inner lambda's captures propagate outward through every enclosing lambda that does not itself declare the name.

The set matters because it is the size of the thing the closure must carry. A lambda that captures one integer can be a pair of words. A lambda that captures fifteen locals is a fifteen-field record, allocated somewhere, on every evaluation of the lambda expression — and if that expression is inside a loop, once per iteration.

Free variables of the inner lambda, computed during resolution
Typed AST — same shape, with symbols and types attached
fn outer()— Declares x and n. Returns a lambda.
├── let x = 10: int→ outer::x
├── let n = 0: int→ outer::n— Never mentioned by the lambda, so never captured.
└── Lambda () => x + g: fn() -> int
├── Identifier x→ outer::x— Bound outside the lambda: FREE. Must be captured.
└── Identifier g→ global::g— Bound at global scope: not in any frame, so not captured.

Read it asThe capture set is exactly the identifiers whose resolved symbol lives in an enclosing *function* scope. n is in scope and not captured because it is not mentioned. g is mentioned and not captured because a global has an address rather than a frame slot. Getting this set right is a [[name-resolution]] question that has an allocation attached to the answer.

Three places the variable can live

specThe C++ row is the one that bites: a [&] capture leaves the variable in the enclosing frame, so a lambda that captures by reference and outlives that frame has a dangling reference and undefined behavior — the language permits you to write it and the compiler is not required to diagnose it. Every garbage-collected language in the table makes this case impossible by construction, which is why the same code is a crash in one language and correct in another.

Once you know what is captured, the question is where it goes. The frame is out unless it can be proved safe, so the realistic answers are: inside the closure object itself, in a separate heap-allocated environment shared with the enclosing scope, or — the special case that matters for performance — still on the stack, because the compiler proved the closure never escapes.

Putting the value inside the closure object is capture *by value*, and it is a copy. Reads through the closure see the value at capture time and never again. That is simple, allocation-friendly, and wrong for any language where the enclosing scope and the closure are supposed to share a mutable variable.

Putting the variable in a shared heap cell is capture *by reference*, or more precisely capture of the binding. The variable is moved out of the frame into a heap object at the point the compiler realises it is captured, both the enclosing code and the closure access it through that object, and it lives as long as either of them does. This is what a garbage-collected language typically does, and it is why a closure in such a language is an allocation.

The third case is the one that turns closures from a cost into a non-cost. If the closure is only called and never stored — passed to a sort comparator, say — nothing outlives the frame and the environment can stay exactly where it was. That proof is [[escape-analysis]], and whether a given compiler performs it is the single biggest determinant of what closures cost in practice.

What mainstream languages capture, and where it livesimplementation
LanguageWhat is capturedWhere it livesWho decides
JavaScriptspecThe binding, always by referenceA heap environment record shared with the enclosing scopeThe language; the programmer has no syntax for the choice
PythonspecThe binding, by reference (a cell object)A heap cell; nonlocal is required to rebind itThe language, with nonlocal/global controlling assignment only
C++ lambdasspecWhatever the capture list says: [x] by value, [&x] by referenceBy value: inside the closure object. By reference: nowhere — it stays in the frameThe programmer, explicitly, per variable
RustspecBy reference, by mutable reference or by move, inferred from use; move forces the lastBorrowed: the original location, with a lifetime the checker enforces. Moved: inside the closureInferred, and checked — a closure outliving a borrow does not compile
GoimplementationThe variable, by referenceStack if escape analysis permits, heap otherwiseThe compiler, per variable, reported by -gcflags=-m
Java lambdasspecThe value; captured locals must be effectively finalInside the closure instanceThe language, by forbidding the case where the distinction would show

Why this is a compiler lesson and not a language trivia lesson

The reason closures get their own module entry is that they are the first feature in most languages where a *lexical* fact — a name resolving to an enclosing scope — forces a *storage* decision. Nothing else in the frontend does that. Types decide sizes, but scoping deciding lifetimes is new, and it is the thing that makes closures more than a syntax for anonymous functions.

It also introduces the pattern the rest of this module repeats. Coroutines force locals off the frame because a suspension outlives the activation. Async functions do the same. Generators do the same. In every case the mechanism is: some construct lets a piece of the function outlive the frame the function was given, so the compiler must relocate whatever that piece touches. Closures are the smallest instance and the right place to learn it.

The practical corollary is that closure cost is not uniform and is not knowable from the source. Two identical-looking lambdas can have completely different costs depending on whether the compiler could prove non-escape, how many variables were free, and whether the language captures values or bindings. That is why the inspection flags in this lesson matter more than usual.

How it works

The steps, in the order the compiler takes them.

  • Name resolution binds every identifier; for each lambda, the identifiers that resolved to a declaration in an enclosing function scope form its free-variable set, stored on the lambda node.
  • Inner lambdas propagate their free variables outward: a name free in a nested lambda and not declared by the enclosing lambda is free in the enclosing lambda too.
  • The compiler decides, per captured variable, whether the closure copies it or shares it, following the language rule or an explicit capture list.
  • Variables that must be shared are moved out of the frame into a heap-allocated environment; the enclosing function is rewritten so its own accesses go through that environment too.
  • The lambda expression evaluates to a value pairing a code pointer with the environment, allocated at the point of evaluation — inside a loop, that is once per iteration.
  • Escape analysis may prove the closure does not outlive the frame, in which case the environment is placed on the stack and the allocation disappears.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A returned closure reads garbage or crashes: the captured variable was left in a frame that had already been popped, and the stack slot has since been reused by another call.
  • A closure created in a loop sees the loop variable's final value in every iteration, because what was captured was the shared binding rather than the per-iteration value — see [[closure-conversion]].
  • A mutation made through a closure is invisible to the enclosing scope, because a language or a capture list copied the value at capture time and the two are now separate variables.
  • Memory grows steadily in a long-running process: a closure stored in a callback registry keeps a whole environment alive, including large objects the lambda never mentions but that share the environment.
  • A tight loop allocates once per iteration because a lambda inside it captures a variable and escape analysis could not prove it stays put; the profile shows allocation with no new anywhere in the source.
  • A C++ lambda captured by reference and stored in a container compiles cleanly, works in tests where the frame is still live, and produces undefined behavior in production.

When it helps

  • Callbacks and event handlers, where the code to run and the data it needs must travel together to somewhere that knows about neither.
  • Higher-order functions over collections, where the operation needs context from the calling scope.
  • Encapsulating state without declaring a type — a counter, a memo table, a configured function — where a class would be ceremony.
  • Deferred and lazy work: a closure is a computation packaged with everything it needs to run later.

When it hurts

  • In hot loops in languages where the environment is heap-allocated and escape analysis cannot see through the call, where the per-iteration allocation dominates the work.
  • When the captured environment keeps far more alive than the closure uses, which is a common shape of memory leak in long-lived registries.
  • When the capture semantics are implicit and the code relies on sharing, so a refactor that moves the lambda changes what it sees.
  • When reasoning about lifetimes matters and the language hides them, so the cost and the liveness are both invisible at the call site.

What it costs

Every one of these is paid by something.

  • Capturing the binding buys a shared mutable variable that behaves exactly as lexical scoping suggests, and pays an allocation plus an indirection on every access to that variable — including from the enclosing function, which now also goes through the environment.
  • Capturing by value buys a flat, allocation-free closure object and pays the loss of sharing: writes on either side are invisible to the other, and the language must either forbid the confusing cases or accept them.
  • Leaving captured variables in the frame — the C++ [&] option — buys zero cost and pays with a lifetime obligation the compiler does not check, converting a scoping mistake into undefined behavior.
  • Escape analysis buys back most of the cost and pays in compile time and in unpredictability: the same source compiles to an allocation or to nothing depending on whether an interprocedural proof succeeded, which makes performance non-local.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Function pointer plus an explicit context argument, which is what C does. No capture, no allocation, no hidden lifetime — and the caller must manage the context's lifetime by hand, which is the same problem moved rather than solved.
  • An object with a call method and explicit fields — the pre-lambda Java and C++ approach. Identical machinery with the capture written out by the programmer, which is more code and no hidden costs.
  • Defunctionalization: replace every closure with a tagged data value and a single dispatch function that interprets the tag. Used in compilers targeting environments without indirect calls, and in whole-program compilers that want no function pointers at all.
  • Lambda lifting: turn free variables into extra parameters and make the function top-level, which avoids the environment entirely when the closure does not escape — see [[lambda-lifting]].

See it for yourself

The flag, dump or tool that shows you this directly.

  • Go: go build -gcflags=-m prints escape decisions per variable, including lines like moved to heap: x and func literal escapes to heap. This is the most direct view of the whole lesson available in any mainstream toolchain.
  • Python: f.__code__.co_freevars lists a function's free variables and f.__closure__ shows the cells holding them; cell_contents reads one. dis.dis shows LOAD_DEREF for a captured variable versus LOAD_FAST for a local.
  • JavaScript: Chrome DevTools shows a Closure scope in the scope chain when a closure is paused; a heap snapshot shows the retained environment, which is how closure-shaped memory leaks are actually found.
  • C++: compile a lambda on Compiler Explorer and look at the generated closure type. A [x] capture becomes a struct member; a [&x] capture becomes a pointer, which makes the dangling case visible.
  • Rust: rustc -Z unpretty=hir shows the inferred capture mode per variable, and the 2021 edition's disjoint closure captures mean a closure may capture s.field rather than s.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "A closure captures the enclosing scope." It captures the variables it mentions. Capturing the whole scope is an implementation some early engines used, and it is the reason closure memory leaks were once much worse than they are now.
  • "Closures are just anonymous functions." An anonymous function that mentions nothing outside itself needs no environment and is a plain function pointer. Capture is the feature; anonymity is the syntax.
  • "A closure copies the variables it uses." In most garbage-collected languages it shares them, which is exactly why a loop-created closure sees the final value.
  • "Closures always allocate." They allocate when the environment must outlive the frame. A comparator passed to a sort in a language with escape analysis frequently allocates nothing.

Misconceptions

The claim, and what is actually true.

A closure is a data structure the programmer creates.
It is a representation the compiler is forced into by a name resolving outward. In a language without closures you build the same pair by hand and call it a callback with a context pointer.
If the closure is not returned, there is no cost.
There is no cost *if the compiler can prove it*. Passing a closure to a function whose body the compiler cannot see is usually enough to defeat the proof.
Capturing by reference is more efficient than by value.
By reference generally forces the variable out of the frame into a shared cell, adding an allocation and an indirection. By value is the cheap one; it is just not always the correct one.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

When a function creates another function that uses one of its local variables, that variable has to survive the outer function returning. Normally locals die with the frame, so the compiler moves the variable somewhere longer-lived and gives the inner function a way to reach it. The pair of "code to run" and "variables it can reach" is what the word closure names.

practical

Three things to know for your own language. First, what does it capture — the value or the variable? That decides whether a write on one side is visible on the other, and it is the source of the loop-variable bug in [[closure-conversion]]. Second, does capture allocate, and does your compiler tell you? Go will tell you with -gcflags=-m; most others will not without a profiler. Third, what does the closure keep alive? A closure stored in a registry retains its whole environment, so a lambda that mentions one field of a large object may keep the object alive — which is a leak that never appears in the code as a reference.

advanced

The design space is best understood as a choice about *where the binding lives*, and every other property follows. If bindings live in a heap environment, closures are uniform, mutation is shared, and the cost is an allocation you then try to remove with escape analysis. If bindings live in the closure object, closures are flat and cheap and the language must either forbid mutation of captured variables (Java) or accept that sharing is lost (C++ by-value). If bindings stay in the frame, the language must prove the closure does not outlive it — which is exactly what Rust's borrow checker does, and it is why Rust can offer the cheap representation without the C++ hazard. The three answers are not better and worse; they are the same trade paid in allocation, in expressiveness, and in checker complexity respectively.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

specCapture semantics are specified per language and are not a compiler choice. JavaScript and Python capture the binding; Java captures the value and requires captured locals to be effectively final; C++ requires an explicit capture list and permits both; Rust infers between borrow, mutable borrow and move and rejects programs where the choice would be unsound. Any statement about "what closures do" is a statement about one of these.
implementationWhether a closure allocates depends on escape analysis, which is an optimization and not a guarantee. Go reports its decisions with -gcflags=-m and they change between releases; the JVM relies on escape analysis in C2 that runs only after a method is hot, so the same lambda allocates during warmup and may not afterwards. Never treat a measured allocation count as a property of the source.
typicalMainstream implementations move only the captured variables to the heap, not the whole frame, and share one environment among sibling closures in the same scope. Some older JavaScript engines retained the entire enclosing activation, which made a single small closure keep large unrelated objects alive; that behaviour is largely gone but it is why old advice about nulling out variables before creating closures exists.

If you were asked this in an interview

  • A function returns a lambda that reads one of its locals. Walk me through what the compiler has to do.
  • What is the difference between capturing a value and capturing a variable, and when can a user tell?
  • How would you decide whether a closure needs a heap allocation?

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — How the heap environment a closure needs is allocated, traced and collected at run time
    This lesson stops at the compiler deciding a variable must leave the frame. What the allocator does with that request, how the collector finds the environment through the closure, and what the object header costs are the runtime's half of the same mechanism.