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:
- 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. - 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. - C++ lambda capture lists —
[=],[&, x],[=, &x, __this]— generated bybuildLambdaCaptureListbased on the rules inclosureCaptureModeFor.
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:
nto live somewhereccan reach it aftermakeCounterhas returned.- Multiple closures created by
makeCounterto share the samen(each call tomakeCounterproduces a freshn).
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
letdeclared infunction f()and referenced by a lambda returned fromf. - A
letdeclared inside aforloop 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:
- Compute
bodyRefs= identifiers referenced in the lambda body (excluding locals). - Compute
localBindings= identifiers locally declared inside the body. - Walk
hoistedVarNames_,localHoistedValueVarsStack_,localHoistedFunctionVarsStack_(outermost-in), adding each name that is inbodyRefsand not inlocalBindings. - For each candidate, consult
closureCaptureModeFor(name):ByRef→ add, &nameto the capture string.ByCell→ do not add&name(the[=]captures theshared_ptr); appendnametolastCapturedCellVars_.None→ skip (already visible via static storage).
- If the lambda references
this(bodyRefs.count("this")or"__v_this") and there is a usablethisbinding, append, __this. - Special case: if
refCaptures.empty()andcaptureSuppressCount_ > 0or 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
- Hoisting of module/function-scope names that closures reference:
./hoisting.md - Variant access patterns used inside closure bodies:
./variant-handling.md - Canonicalizer step that decides cell-wrap vs by-ref:
../../empl/canonicalizer.md - Class-method
thiscapture:./special-patterns.md§ JS classes