skip to content

Closures & Lexical Capture

A function bundled with the variables it captured from the scope where it was written, and what capture really means. Interviewers favour the loop-variable trap: it exposes a shaky model.

on this pageshow

explore

questions

22

When a function that captured local values is stored and called long after its defining call returned, what keeps those values alive?

level: juniorimportance: must knowfreq 58%

answer

  1. a function value carrying state
  2. the frame ends, the data does not
  3. lifetime follows the function value
  4. stored callback, stored environment
  5. released when the closure is unreachable

basics

~20 s

The stored function itself keeps them alive. Captured state is moved out of the short-lived call frame into storage the function value owns, so it lives exactly as long as that function value stays reachable.

solid answer

~40 s

A closure is a function bundled with the environment it captured, and that environment is not part of the caller's frame. When the defining call returns its frame is discarded, but anything the returned function still needs has been placed in longer-lived storage the function value owns. So the lifetime of every captured value becomes the lifetime of the closure: put the closure in a field, a registry or a callback list that lives for the whole process, and the captured values live that long too. They are released when the closure itself becomes unreachable, not when the call that created it returns. Implementations differ in how they relocate the state; every one of them extends its lifetime this way.

code

pseudocode · 9 lines
pseudocode
function makeProgressReporter(jobLabel)
    startedAt = now()
    return function (percent)
        show(jobLabel, percent, now() - startedAt)
    end
end

reporter = makeProgressReporter("import")   // the outer call has returned
registry.add(reporter)                      // jobLabel and startedAt live on

go deeper

for a junior

Recall that a closure bundles code with the values it captured, and that the bundle keeps working after the call that created it has returned.

for a middle

Explain that captured state is relocated into storage the function value owns, so its lifetime tracks the closure's reachability rather than the call stack.

for a senior

Show where this bites in a running system: a callback parked in a long-lived list holds its whole captured environment for as long as that list does.

for a principal

Treat it as a policy question: which parts of the codebase may create functions that outlive their creator, and what the default shape for those call sites is.

## What a closure carries A **closure** is a function value bundled with the *environment* it needs: the **free variables** its body names, which are neither its own parameters nor declared inside it. If the body computes `now() - startedAt` and `startedAt` belongs to the enclosing call, the function simply cannot run without access to `startedAt`. The function value is therefore not just code — it is code **plus** a hold on that state. That bundle is exactly what makes a returned or stored function still work later, and it is also why lifetime is a question at all. A function that captures nothing raises none of this: there is no environment, so storing it forever costs only the function value. ## Why the end of the call is not the end of the data A call frame is short-lived storage: the arguments and locals of one activation, discarded when that activation returns. If captured state lived only there, every closure that outlived its defining call would be reading discarded memory. Implementations avoid that in different ways — copying the value into storage the function value owns, moving the variable into a small separately-allocated cell that both the frame and the closure refer to, or keeping an entire environment record alive — but the observable rule is the same in all of them: - captured state is **not** released because the defining call returned; - it is released when the function value that needs it is no longer reachable; - until then it is as alive as any other object you are still holding. | | a plain local | a captured local | |---|---|---| | lives in | the call frame | storage the closure owns | | released when | the call returns | the closure becomes unreachable | | bounded by | the call's duration | the closure's lifetime | | ongoing cost | none after the call | its own size, while held | ## Lifetime follows reachability, not time The useful way to state it is that **the lifetime of the captured environment equals the reachability of the closure**. Nothing about elapsed time, about the job being finished, or about the callback having already run changes it. Three shapes follow directly: 1. A closure built, called and dropped inside one function: its environment dies almost immediately, and the capture costs nothing worth thinking about. 2. A closure returned to a caller that uses it and lets it go: the environment dies when that caller drops the last reference. 3. A closure handed to a registry, a scheduler or a subscription list that lives for the whole process: the environment lives for the whole process. Only the third shape is dangerous, and it is dangerous in proportion to what was captured, not to how long the code is. ## Where this turns into a leak A retention problem here is not a special mechanism; it is this ordinary rule applied to a long-lived holder. A progress callback captured what it needed when it was created. The work it reported on finished an hour ago. Nobody dropped the callback. Everything it captured is therefore still there, and will be until the process ends. The same reasoning holds in both families of language. Where memory is reclaimed automatically once nothing can reach it, a reachable closure keeps its environment out of reach of reclamation. Where memory is freed deterministically by an owner, the closure *is* an owner and will not release what it owns while it is alive. Neither family gives you the release you were implicitly expecting from "the function returned". ## Four questions to ask as you write one 1. **Who will hold this function, and for how long?** If the answer is "a list nobody empties", everything below matters. 2. **What did the body name that it did not declare?** That set — not the set you intended — is the environment. 3. **Is any of it large, or a door into something large?** A handle to a container is a handle to everything in it. 4. **Could the value be taken out and copied before the function is built?** Narrowing what is captured is the cheapest lever you have, because it works even when the closure is never released. Answering those four at the moment you write the function is far easier than reconstructing them later from a memory graph, and it is what separates an engineer who treats closures as free from one who treats them as storage with a lifetime.

  • If the same closure is added to three different registries, is the captured environment duplicated?
    No. The environment belongs to one function value, and the three entries are three references to that value. There is one environment, and it is released only when the last of the three entries is dropped. Adding entries multiplies references, not captured state.
  • Does a function that captures nothing raise the same concern?
    No. With no free variables there is no environment to keep alive, so storing it indefinitely costs only the function value itself, and some implementations reuse a single instance of it. The retention question begins only once something is captured.

saying these in an interview costs you the question

  • Says captured locals die when the enclosing function returns.
  • Thinks the closure reads the discarded frame of its defining call.
  • Assumes a function value is just a code pointer with no state.
  • Believes only declared parameters are retained, not free variables.
  • Thinks storing a callback costs only the size of the function.
open as a page

A queued audit entry captures a status variable that its caller later reassigns — what decides which value the entry reports?

level: juniorimportance: must knowfreq 66%

basics

~20 s

Capture mode decides. A by-value capture copied the status when the entry was created and reports the old value; a capture of the binding itself reads the variable at call time and reports the new one.

open as a page

Under lexical scoping, where is a free name inside a message-formatting helper looked up?

level: juniorimportance: must knowfreq 72%

basics

~20 s

Lexical scoping resolves a free name in the text surrounding the helper's definition - innermost enclosing region first, then outward. The call site is never consulted, so the helper means the same thing everywhere it is used.

open as a page

A loop builds one action handler per table row; every handler later acts on the last row. Why?

level: juniorimportance: must knowfreq 74%

basics

~20 s

Every handler captured the same loop variable rather than a copy of that pass's value. The loop reassigned that one binding on each pass, so by the time any handler ran it read whatever the loop had left behind.

open as a page

A batch job's sequence issuer keeps its counter in a variable captured by the returned function: why does that counter survive between calls?

level: juniorimportance: must knowfreq 66%

basics

~20 s

The returned function captured that variable, so the variable lives as long as the function value does instead of ending with the call that created it. No code outside can name it, so the returned function is the only way in.

open as a page

A callback created inside a screen object reads one unqualified field of that object — what does the callback retain?

level: middleimportance: must knowfreq 66%

basics

~10 s

The whole enclosing object, not the field. An unqualified member read resolves through the instance that owns it, so the closure captures that instance and transitively keeps everything reachable from it alive.

open as a page

A captured local is reassigned by both the closure and its enclosing function — why can it not stay in the call frame?

level: middleimportance: must knowfreq 55%

basics

~20 s

Because two lifetimes now share one variable: the closure can outlive the call, and each side must see the other's writes. The variable is boxed — moved into a small cell both the frame and the closure hold — so exactly one copy exists.

open as a page

A shared formatting helper produces different output depending on which module calls it - which scoping rule explains that?

level: middleimportance: must knowfreq 58%

basics

~20 s

Caller-driven, or dynamic, scoping explains it: a free name in the helper is resolved against the chain of calls active at that moment, so each caller's own binding of that name supplies a different value.

open as a page

Handlers built inside a loop all see the final value — what are the two ways to give each its own?

level: middleimportance: must knowfreq 56%

basics

~20 s

Either use a loop form that creates a fresh binding for each pass, so every closure captures a different variable, or copy the value inside the loop body into a local declared there and capture that copy instead.

open as a page

A counter closure holding private state and an object with a single increment method do the same job: how do the two forms differ?

level: middleimportance: must knowfreq 55%

basics

~20 s

Barely, in substance: both bundle state with behaviour and both hide the state. They differ in surface — a closure offers exactly one entry point and state with no name, while the object gives the state a named home and room for more operations, plus something you can identify, inspect and describe.

open as a page

A long-lived callback is rewritten to capture two values copied into locals beforehand — what does that release?

level: middleimportance: should knowfreq 40%

basics

~20 s

Everything that was alive only through the enclosing instance. The closure now holds two locals, so the instance, its collections and its caches become unreachable — unless one of the copied values leads back into that same graph.

open as a page

A closure captured a copy of a variable holding a mutable record — why does it still see a field written afterwards?

level: middleimportance: should knowfreq 44%

basics

~20 s

Because the copy is a copy of what the variable held — a handle to the record, not the record. Capture semantics governs reassignment of the name; it never freezes whatever the name points at.

open as a page

When an inner declaration reuses an enclosing name, which binding does the scope chain give the inner body?

level: middleimportance: should knowfreq 46%

basics

~10 s

The innermost declaration wins inside its own region: the outward search stops at the first one it meets. The enclosing binding is hidden there, not destroyed, and code outside that region still sees it.

open as a page

Why do closures built in a loop behave correctly when called inside it and wrongly afterwards?

level: middleimportance: should knowfreq 44%

basics

~20 s

A closure resolves its captured binding when it runs, not when it is made. Called during its own pass, the shared variable still holds that pass's value; called after the loop, all of them read the one value left behind.

open as a page

A factory takes a batch job's run prefix and number width once and returns an issuer already configured: what does that buy every later call?

level: middleimportance: should knowfreq 48%

basics

~20 s

Calls carry no configuration at all: it was validated and prepared once at construction, the returned issuer holds it in captured scope where callers cannot change or mistype it, and each issuer built this way owns its settings and its own counter.

open as a page

A process-lifetime registry holds one progress callback per job, yet memory grows by megabytes per job — what is retained?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Each entry's captured environment, not the callback. Per entry the cost is the object graph reachable only through that closure — the instance it was created inside plus its buffers and caches — multiplied by every entry never dropped.

open as a page

A deferred audit entry records the status at flush time instead of at capture time — how do you diagnose and fix it?

level: seniorimportance: should knowfreq 45%

basics

~20 s

The entry captured the status variable rather than its value, so it reads the variable when it finally runs. Confirm by reassigning the variable between capture and flush; fix by copying the status into a name nothing reassigns, or passing it as an argument.

open as a page

Formatting functions are registered at startup and invoked later from unrelated call sites - what does lexical resolution guarantee about their free names?

level: seniorimportance: should knowfreq 38%

basics

~20 s

It guarantees that each free name still refers to the declaration the text around the definition chose. Deferral, storage and an unfamiliar call site cannot re-point a name, so a registered function can be reviewed where it was written.

open as a page

Handlers built in a loop all reported the last row, so a copy was added above the loop; why did nothing change?

level: seniorimportance: should knowfreq 36%

basics

~20 s

A variable declared above the loop is one binding reassigned on every pass — the loop variable under a new name. Only a binding created during the pass, by the loop form or by a declaration in the body, differs per closure.

open as a page

Two issuers built from one factory are numbering into a single shared sequence instead of independently: what is wrong with where the counter was declared?

level: seniorimportance: should knowfreq 38%

basics

~20 s

The counter was declared in the scope enclosing the factory rather than inside its body. That scope is entered once, so one binding exists and every issuer the factory returns captured the same one. Moving the declaration inside the body gives each call its own.

open as a page

Some languages refuse to capture a local that is ever reassigned — what does that restriction buy them?

level: middleimportance: nice to knowfreq 34%

basics

~20 s

Unambiguous capture. With no reassignment possible, copying the value and holding the binding behave identically, so the language can copy, needs no shared cell, and removes the trap of a closure reporting a value written after it was created.

open as a page

Dynamic scoping is a liability by default, so where does caller-determined name lookup still earn its place?

level: seniorimportance: nice to knowfreq 26%

basics

~20 s

It earns its place for values every layer would otherwise thread through untouched - a formatting locale, an output sink, a verbosity setting. Modern languages offer this as an explicit, named, opt-in binding rather than the rule for all free names.

open as a page