Elvelt

Backends

Closures & Captures

Closures & Captures

Closures — lambdas that outlive their declaring scope and reference names from that scope — are the most complex thing the C++ backend handles. The implementation combines three pieces of machinery:

  1. Hoisted storage (static mpl_value …;) — names that may need to outlive their natural C++ scope are promoted to module-level or function-level static storage.
  2. Cell-wrapping (shared_ptr<mpl_value>) — for the cases where a single static slot is insufficient (multiple closures, or closures that outlive a re-assignment), names are wrapped in heap-allocated cells that all closures share by value.
  3. C++ lambda capture lists[=], [&, x], [=, &x, __this] — generated by buildLambdaCaptureList based on the rules in closureCaptureModeFor.

This document explains the rules and walks through several end-to-end examples.

Function Location Purpose
ClosureCaptureMode enum CppBackend.h:3816-3820 Per-name capture mode (None / ByRef / ByCell)
closureCaptureModeFor CppBackend.h:3822-3884 Decide the capture mode for a single name
collectReferencedCapturedCellVars CppBackend.h:3886-3937 List of cell-wrapped names referenced by a node
hasVisibleClosureCellBinding CppBackend.h:3939-3950 Quick check: is name in any visible cell-wrap layer?
buildLambdaCaptureList CppBackend.h:4894-4972 Assemble the final […, &x, __this] string
closureCellVarsStack_ CppBackend.h:2429 Stack of cell-wrap sets, one per nested callable
lastCapturedCellVars_ CppBackend.h:2433 Names of cell-vars included in the last capture list

Why closures are hard in C++

In JavaScript:

function makeCounter() {
    let n = 0;
    return function() { return ++n; };
}
const c = makeCounter();
c(); c(); c();   // 1, 2, 3

Each invocation of c mutates the same n even though n is a local of makeCounter. The C++ translation needs:

  1. n to live somewhere c can reach it after makeCounter has returned.
  2. Multiple closures created by makeCounter to share the same n (each call to makeCounter produces a fresh n).

The standard C++ way to do this is std::shared_ptr<T> — a heap-allocated cell that the closure captures by value. The backend calls this a cell-wrapped variable.


Capture mode resolution

ClosureCaptureMode enum

File: CppBackend.h:3816-3820

enum class ClosureCaptureMode {
    None,    // name has static storage; no capture needed
    ByRef,   // capture by reference: [&x]
    ByCell,  // name is a shared_ptr<mpl_value> cell; capture by value ([=] captures the shared_ptr)
};

closureCaptureModeFor(name)

File: CppBackend.h:3822-3884

Resolves the capture mode for a given name by walking the three storage hierarchies from innermost to outermost:

1. localBindingNamesStack_        — function-scope let/const/var (with localBindingScopeIds_)
2. localHoistedValueVarsStack_    — function-scope hoisted let/const/var
   localHoistedFunctionVarsStack_ — function-scope hoisted function-decls
   localHoistedStaticStorageStack_— whether the function uses static storage at all
   localInitializedVarsStack_     — was the name initialized at this scope?
   closureCellVarsStack_          — is the name cell-wrapped at this scope?
3. closureCaptureScopeBindingIds_ — ties each stack layer to a binding-scope id
4. moduleHoistedFunctionNames_    — module-level hoisted functions (static storage)
5. hoistedVarNames_               — module-level hoisted values (static storage)
If name is… Capture mode What gets emitted
A locally-bound let in the innermost function (initialised) None No [&x] — the lambda's [=] already copies it by value
A locally-bound let (not initialised yet) ByRef &x — must share the storage until init completes
Cell-wrapped at any visible scope ByCell [=] captures the shared_ptr<mpl_value>; lambda body emits auto& x = *__cell_x;
Static-storage hoisted (module or function-level with localHoistedStaticStorageStack_[i] == true) None The static is directly visible; no explicit capture
Module-level hoisted None Same — static mpl_value is visible from any nested lambda

The "static storage is visible from nested lambdas" rule is the one that prevents -Wnon-automatic-capture warnings (line 3857-3861 comment).

lastCapturedCellVars_

File: CppBackend.h:2433

After buildLambdaCaptureList runs, lastCapturedCellVars_ holds the names that were captured as cells. The caller (the emit-loop) uses this to inject aliases at the top of the lambda body:

auto& x = *__cell_x;     // injected because x ∈ lastCapturedCellVars_
auto& y = *__cell_y;
return /* body that uses x and y */;

This alias is the bridge that lets the lambda body refer to x and y directly, while the actual storage lives in heap-allocated cells.


Cell wrapping in detail

What gets cell-wrapped?

Only names that outlive their declaring scope. Concretely:

  • A let declared in function f() and referenced by a lambda returned from f.
  • A let declared inside a for loop body and referenced by a callback captured inside the loop.

When does cell-wrapping happen?

Cell wrapping is decided during the ClosureBindings analysis performed by EMPLCanonicalizer::extractClosureBindings (see ../../empl/canonicalizer.md). The canonicalizer produces a ClosureBindings structure that names the cell-wrapped variables and the closure that captures them. CppBackend consumes this via closureCellVarsStack_.

Cell-wrap runtime pattern

A cell-wrapped variable is represented in C++ as:

static std::shared_ptr<mpl_value> __cell_x = std::make_shared<mpl_value>(__mpl_undefined__);
// …
auto __lambda = [=]() -> mpl_value {
    auto& x = *__cell_x;        // alias injected by the backend
    x = mpl_box_value(1);
    return x;
};

If the closure is created multiple times (e.g. inside a loop), each instance shares the same __cell_x because the lambda captures the shared_ptr by value ([=]).


Capture list construction (buildLambdaCaptureList)

File: CppBackend.h:4894-4972

std::string buildLambdaCaptureList(const ir::EMPLNodePtr& node);

Returns a string like [=], [=, &x, __this], or [=, &x]. Algorithm:

  1. Compute bodyRefs = identifiers referenced in the lambda body (excluding locals).
  2. Compute localBindings = identifiers locally declared inside the body.
  3. Walk hoistedVarNames_, localHoistedValueVarsStack_, localHoistedFunctionVarsStack_ (outermost-in), adding each name that is in bodyRefs and not in localBindings.
  4. For each candidate, consult closureCaptureModeFor(name):
    • ByRef → add , &name to the capture string.
    • ByCell → do not add &name (the [=] captures the shared_ptr); append name to lastCapturedCellVars_.
    • None → skip (already visible via static storage).
  5. If the lambda references this (bodyRefs.count("this") or "__v_this") and there is a usable this binding, append , __this.
  6. Special case: if refCaptures.empty() and captureSuppressCount_ > 0 or the node is a class-expression constructor (isLoweredClassExprCtorAliasNode), return [] (no captures) instead of [=].

The return string is placed directly inside […] so the final lambda literal is:

[capture_string](parameters) -> mpl_value { /* body */ }

Worked example 1: counter (cell-wrap)

function makeCounter() {
    let n = 0;
    return function() { return ++n; };
}
const c = makeCounter();
console.log(c(), c(), c());

After canonicalization, n is marked as a cell-wrapped variable. The IR is roughly:

ModuleNode
├── FunctionDeclNode "makeCounter"
│     body:
│       VariableDeclNode "n" init=Literal(0)   ← cell-wrap metadata says: wrap in cell
│       ReturnNode value=LambdaNode
│         parameters=[]
│         body: UnaryOpNode op="++" operand=Identifier("n")  ← uses cell
├── VariableDeclNode "c" init=FunctionCallNode("makeCounter")
├── ExpressionStatement FunctionCallNode console.log [c(), c(), c()]

Generated C++:

static mpl_value makeCounter = __mpl_undefined__;   // hoisted
static mpl_value c = __mpl_undefined__;              // hoisted

int main(int argc, char** argv) {
    __mpl_js_set_process_argv(argc, argv);

    // [1] makeCounter = [=](mpl_value __mpl_v_n) -> mpl_value {
    //         static auto __cell_n = std::make_shared<mpl_value>(__mpl_undefined__);
    //         *__cell_n = mpl_box_value(__mpl_v_n);
    //         return [=]() -> mpl_value {
    //             auto& n = *__cell_n;          // alias from lastCapturedCellVars_
    //             return mpl_box_value(mpl_prefix_inc(n));
    //         };
    //     };
    makeCounter = [=](mpl_value __mpl_v_n) -> mpl_value {
        static auto __cell_n = std::make_shared<mpl_value>(__mpl_undefined__);
        *__cell_n = mpl_box_value(__mpl_v_n);
        return [=]() -> mpl_value {
            auto& n = *__cell_n;
            return mpl_box_value(mpl_prefix_inc(n));
        };
    };

    // [2] c = mpl_call(makeCounter, mpl_box_value(0));
    c = mpl_call(makeCounter, mpl_box_value(0));

    // [3] std::cout << mpl_to_string(mpl_call(c)) << " "
    //               << mpl_to_string(mpl_call(c)) << " "
    //               << mpl_to_string(mpl_call(c)) << std::endl;
    std::cout << mpl_to_string(mpl_call(c)) << " "
              << mpl_to_string(mpl_call(c)) << " "
              << mpl_to_string(mpl_call(c)) << std::endl;

    mpl_run_event_loop();
    _exit(0);
    return 0;
}

Each call to mpl_call(c) re-invokes the inner lambda; each invocation aliases n from the same __cell_n (captured by value of the shared_ptr); the counter increments across calls.


Worked example 2: this capture in class methods

class Box {
    constructor(v) { this.v = v; }
    getV() { return this.v; }
    arrow = () => this.v;     // ES2022 class-field arrow function
}

The arrow function captures this lexically. In C++:

class Box {
public:
    mpl_value v;
    Box(mpl_value __mpl_v) { this->v = __mpl_v; }
    mpl_value getV() {
        return this->v;
    }
    mpl_value arrow = [__this]() -> mpl_value { return __this.v; };
};

The capture list is [__this]__this is the alias the backend uses for this outside a class definition (line 7872-7878). Inside a class method, this is the C++ this; inside a lambda defined in a method, it must be captured explicitly as __this.

hasAvailableThisBinding() (line 2442-2444) returns thisBindingDepth_ > 0 — the backend increments thisBindingDepth_ whenever it enters a context that supplies this (class, method, constructor). Only when this is true does the arrow-function capture list include __this.


Worked example 3: captured locals in a for loop

const fns = [];
for (let i = 0; i < 3; i++) {
    fns.push(() => i);
}
console.log(fns.map(f => f()));   // [0, 1, 2]

The classic closure-in-loop gotcha: i must be a fresh cell per iteration, but in var-style C++ it would be shared.

The IR marks i as cell-wrapped because each lambda captures it and outlives the iteration scope. Generated C++:

static auto __loop_i_cell_0 = std::make_shared<mpl_value>(__mpl_undefined__);

auto fns = std::vector<mpl_value>{};

for (*__loop_i_cell_0 = mpl_box_value(static_cast<long double>(0));
     mpl_truthy(mpl_lt(*__loop_i_cell_0, mpl_box_value(3)));
     *__loop_i_cell_0 = mpl_box_value(mpl_prefix_inc(*__loop_i_cell_0))) {
    fns.push_back(mpl_box_value([=]() -> mpl_value {
        auto& i = *__loop_i_cell_0;
        return i;
    }));
}

std::cout << mpl_to_string(fns.map(/* call helper */)) << std::endl;

(In practice, the loop counter cell is fresh per iteration because the canonicalizer emits a new cell for each iteration — the canonical pattern for for (let i …).)


Suppression: captureSuppressCount_

File: CppBackend.h:2424

The captureSuppressCount_ field is used to suppress the default [=] capture for self-referencing hoisted class constructor lambdas. When > 0, buildLambdaCaptureList returns [] instead of [=] when there are no reference captures.

Why? When lowering class X { constructor() { /* X is in scope */ } } to a C++ lambda constructor, the body may need to reference X (the class itself, in a recursive constructor pattern). A default [=] would attempt to copy the static mpl_value slot for X — which is uninitialized at construction time. Suppressing [=] lets the lambda reference X as a static directly.

The increment/decrement happens around specific emit sites for class declarations; not user-facing.


Cell alias injection at lambda body

After buildLambdaCaptureList runs and the lambda literal is emitted, the caller inspects lastCapturedCellVars_ and prepends aliases:

auto __lambda = [=, &x]() -> mpl_value {
    auto& x = *__cell_x;        // injected if x ∈ lastCapturedCellVars_
    auto& y = *__cell_y;        // injected if y ∈ lastCapturedCellVars_
    /* user body */
};

The injection code lives in the lambda-emit branch of generateExpr (around LambdaNode handling).


Edge cases

Closure with no captures

If the lambda body references no external names, refCaptures is empty and the capture list is [=] (or [] if captureSuppressCount_ > 0 or it's a class-expression ctor alias).

Closure with this but no other captures

captureOuterThis = true[=, __this] (or [__this] if captureSuppressCount_ > 0).

Closure that captures a hoisted module-level name

closureCaptureModeFor(name) == None (module-level hoists have static storage) → no [&name] added. The lambda references the static mpl_value directly.

Closure inside another closure (nested)

Each level maintains its own closureCellVarsStack_ layer. Inner closure can capture outer closure's cell-wrapped variables — the capture list adds them as [=] cells, and the alias auto& x = *__cell_x; works because the outer closure also has the alias.

arguments in a non-arrow function

arguments is mapped to the C++ arguments slot inside a class context (line 7879-7882). Outside a class context, the backend does not currently emit arguments-bearing callables — they would need a custom lowering that constructs an mpl_value-typed arguments array.


Cross-references