Elvelt

Backends

C++ Code Emission

C++ Code Emission

This document explains how CppBackend lowers each IR node kind to C++ source. The lowering is split across three top-level entry points: generateStatement() for statements, generateExpr() for expressions, and the per-statement preamble that sets up the runtime.

Entry point Location Inputs Output
generate(ModuleNode) CppBackend.h:24 One IR module One .cpp file containing int main()
generateProgram(ProgramNode) CppBackend.h:2264 Linked program ProgramGenerationResult (per-module .cpp + __mpl_main.cpp + __mpl_module_registry.h)
generateStatement(EMPLNodePtr, indent) CppBackend.h:5409 One IR statement C++ statements at the given indent level
generateExpr(EMPLNodePtr) CppBackend.h:7810 One IR expression A C++ expression returning mpl_value (or auto)

Dispatch flow

generate(ModuleNode)
   │
   ├─ emit runtime preamble                              (line 50-1750)
   ├─ collectFunctionScopedVars + collectNested… + …   (line 1865-2231)
   ├─ emitModuleStatement(i) for each i in [0, N)        (line 2205)
   │     └─ calls generateStatement(stmt, indent=1)
   │           └─ for expression-children, calls generateExpr(node)
   └─ wrap body in `int main(int argc, char** argv)`

Every generateExpr() and generateStatement() begins with a runtime-need check (nodeNeedsRuntime, lines 4394-4406) that returns __mpl_undefined__ (or an empty statement + __host_log__) for IR constructs the backend cannot lower. This makes the backend fail loudly at runtime instead of silently miscompiling.


Runtime needs guard

File: CppBackend.h:4394-4406

static bool nodeNeedsRuntime(const ir::EMPLNodePtr& node, std::string* reasonOut = nullptr) {
    // Returns true for: AwaitNode, YieldNode, DecoratorNode, MacroNode,
    // PatternMatchNode, certain UnsupportedNode variants
}

When nodeNeedsRuntime(stmt, &reason) is true:

Caller Effect
generateStatement (line 5418) Emits __host_log__("MPL runtime-required statement encountered: <reason>");
generateExpr (line 7816) Returns __mpl_undefined__

__host_log__ is a host-side debug helper that prints to stderr so unresolved IR nodes are visible during dev runs.


Statement dispatch (generateStatement)

File: CppBackend.h:5409-7809

This 2 400-line routine handles every statement kind in the IR. The shape of each branch is:

if (stmt->kind == ir::NodeKind::X) {
    auto xNode = std::static_pointer_cast<ir::XNode>(stmt);
    // …emit C++…
    return ss.str();
}

Statement → C++ mapping

IR NodeKind C++ output Line Notes
Import (empty — handled at module level) 5424 Imports are resolved in ProgramNode linking
Export recurse to value 5427 Named exports become __export_value_binding__ calls (see modular-mode.md)
StandardLibraryCall std::cout << …, std::sqrt(…), etc. 5435-5535 See Standard Library Lowering below
PrintCall rewritten as StandardLibraryCall ("Console.WriteLine" / "Write") 5538-5545 Legacy lowering path
VariableDecl auto x = init; (or mpl_value x = …; if boxed) 5547-5795 See ./hoisting.md for hoisting-aware re-emission
Assignment target = value; or mpl_set(target, key, value) ~5795 Member / index → mpl_set
Block recursive generateStatement for each child ~5820
If if (mpl_truthy(cond)) { … } else { … } ~5830
For for (init; mpl_truthy(cond); incr) { … } ~5900
ForEach for (auto& __it : __mpl_for_of__(iterable)) { … } ~5950
While / DoWhile standard C++ while / do-while with mpl_truthy ~6000
Switch switch (mpl_unwind_switch(expr)) { case __mpl_case_0: … } ~6100 Uses switchCounter_ to mint unique labels
TryCatch try { … } catch (const mpl_value& e) { … } catch (const mpl_js_error& e) { … } ~6300 Catches both mpl_value throws and JS-style error objects
Return return value; (boxed if needed) ~6500
Throw throw mpl_box_value(value); ~6510
Break / Continue standard C++ ~6520
FunctionDecl / FuncDecl auto name = [cap](params…) -> mpl_value { … }; (hoisted) ~6600 Hoisting controls whether this is static or auto
ClassDecl class CXX_name { public: … }; then a constructor lambda ~6800 Lowered to a C++ class with mpl_value member layout
EnumDecl enum class name : int { … }; + lookup map ~7200
LambdaDecl (inline) [cap](params…) -> mpl_value { … } inline
Break / Continue C++ break; / continue; ~6520
TryCatch try { … } catch (...) { … } ~6300

Standard-library lowering (StandardLibraryCall)

File: CppBackend.h:5435-5535

StandardLibraryCallNode is the IR's language-neutral standard-library call. The C++ backend lowers each (moduleName, functionName) to a direct C++ call:

IR call C++ output Example
Console.WriteLine(a, b, c) std::cout << mpl_to_string(a) << mpl_to_string(b) << mpl_to_string(c) << std::endl; line 5438-5446
Console.Write(a) std::cout << mpl_to_string(a); line 5442
Console.ErrorWriteLine(a) std::cerr << mpl_to_string(a) << std::endl; line 5449-5454
Console.ReadLine() std::string __mpl_read_line__; std::getline(std::cin, __mpl_read_line__); line 5457
Math.Abs(x) std::abs(x) line 5464-5466
Math.Floor(x) std::floor(x) line 5468
Math.Ceil(x) std::ceil(x) line 5472
Math.Round(x) std::round(x) line 5476
Math.Sqrt(x) std::sqrt(x) line 5480
Math.Pow(x, y) std::pow(x, y) line 5484
Math.Max(a, b) / Math.Min(a, b) std::max(a, b) / std::min(a, b) line 5488-5494
String.Trim(s) inline lambda (whitespace strip) line 5499-5501
String.ToLower(s) / ToUpper(s) std::transform + ::tolower / ::toupper line 5503-5509
String.StartsWith(s, p) / EndsWith(s, p) inline lambda (string compare) line 5511-5518
DateTime.NowUnixMs std::chrono::duration_cast<…>(…).count() line 5521-5528

Unmapped calls emit __host_log__("Unhandled standard library call: <mod>.<fn>"); (line 5531-5533) so a future frontend can find its unimplemented calls by grepping generated code.


Expression dispatch (generateExpr)

File: CppBackend.h:7810-9792

The expression dispatcher mirrors generateStatement but emits values, not side effects. It always returns a std::string containing a C++ expression of type mpl_value (or auto when the caller can infer the type).

Expression → C++ mapping

IR NodeKind C++ output Line Notes
Literal (int / double / string / bool / nullptr) numeric / std::string("…") / true/false / nullptr 7820-7847 std::to_chars for full-precision doubles (line 7827)
Identifier mpl_box_value(name) or direct identifier 7850-7988 See identifier resolution below
ArrayLiteral std::vector<…>{…} or std::vector<mpl_value>{…} lambda 8009-8093 Spread, hole, primitive-typed vs boxed
BinaryOp mpl_add, mpl_sub, mpl_mul, mpl_strict_eq, … 8095-8213 All operators use runtime helpers (see table)
UnaryOp mpl_unary_plus, mpl_truthy(!x), mpl_delete, ++/-- 8215-8288 Prefix vs postfix inc/dec handled
Ternary mpl_truthy(c) ? box(t) : box(f) lambda 8290-8294 Always wrapped in a box-value lambda
VariableDecl (as expression) name = init 8296-8312 Used inside comma expressions
FunctionCall mpl_call(name, args…) or direct call 8314-8710 See ./special-patterns.md for the long table
MemberAccess mpl_get_value(obj, "memberName") 8715-8750 Hoisted member access for classes
IndexAccess mpl_get_value(obj, key) (or mpl_is_nullish-guarded) 7990-8007 Optional chaining → lambda
Lambda [capture](params…) -> mpl_value { … } 8750-8800 Capture list from buildLambdaCaptureList
FunctionDecl (as expr) auto name = [cap](params…) -> mpl_value { … }; similar
ClassDecl (as expr) auto name = (function-pointer-from-ctor); similar Used for class-expression const X = class {}
Await mpl_await(expr) ~8900 From runtime preamble
Yield mpl_yield(expr) ~8920
Ternary / New (handled in FunctionCall as new keyword) 8598-8630 new lowered as lambda creating prototype-bearing mpl_object

Identifier resolution

File: CppBackend.h:7850-7988

generateExpr(IdentifierNode) is the most complex branch — it is the only place the backend has to decide what an unqualified name refers to. The resolution order is:

1. exports / __mpl_module_exports__                            (line 7859)
2. void / nullptr / __magic__                                  (line 7862-7870)
3. this / arguments inside class context                       (line 7872-7882)
4. classCaptureAccessFor(name) (hoisted class member access)   (line 7883)
5. isVisibleJsClassName(name) (hoisted JS class)               (line 7886)
6. isCurrentLocalBindingName(name) (function-scope locals)     (line 7889)
7. moduleScopeVarNames_ (module-scope locals)                  (line 7892)
8. jsGlobals (Map<String, runtime-helper-expr>)                (line 7896-7982)
9. fallback: sanitizeVarName(name)                             (line 7987)

Step 8 is the largest — it covers ~70 JS global names (Object, Array, Map, Set, Promise, Symbol, BigInt, Headers, fetch, setTimeout, …). For languages that don't emit those identifiers, the table is simply never consulted.

Operator dispatch (BinaryOpNode)

File: CppBackend.h:8095-8213

The IR uses the canonical operator strings defined in core/ir/EMPLNode.h. The backend maps each to a runtime helper:

IR op C++ helper Line
=== mpl_strict_eq(a, b) 8122
!== (!mpl_strict_eq(a, b)) 8125
== mpl_loose_eq(a, b) 8128
!= (!mpl_loose_eq(a, b)) 8131
?? lambda mpl_is_nullish(lhs) ? rhs : lhs 8134
&& lambda mpl_truthy(lhs) ? rhs : lhs 8139
|| lambda mpl_truthy(lhs) ? lhs : rhs 8144
** mpl_pow(a, b) 8149
>>> mpl_urshift(a, b) 8152
instanceof mpl_instanceof or mpl_instanceof_type<T> 8155
in mpl_in(a, b) 8164
, ((a), (b)) 8167
+ mpl_add(a, b) 8170
-, *, /, % mpl_sub, mpl_mul, mpl_div, mpl_mod 8173-8183
<, <=, >, >= mpl_lt, mpl_le, mpl_gt, mpl_ge 8185-8195
<<, >>, &, |, ^ mpl_shl, mpl_shr, mpl_bitand, mpl_bitor, mpl_bitxor 8197-8211

=== is not operator== on the variant — it uses mpl_strict_eq which short-circuits type mismatches (5 === "5" is false). Likewise == uses mpl_loose_eq which performs numeric coercion.

The three core value helpers (mpl_call, mpl_get_value, mpl_set)

The runtime preamble (defined in MPL_Standard/System/empl.h and inlined at the top of every translation unit) provides three helpers that all IR expressions eventually funnel through:

Helper Signature (informal) Purpose Where emitted from
mpl_call(callee, args…) mpl_value(mpl_value, std::vector<mpl_value>) Invoke any callable value (function, class ctor, lambda, built-in) with boxed args line 8584, 8619, mpl_call(...)
mpl_get_value(obj, key) mpl_value(mpl_value, mpl_value) Read a property by string or numeric key line 7999, 8161
mpl_set(obj, key, value) void(mpl_value, mpl_value, mpl_value) Write a property line 8253, 8267

Every member access (obj.prop) becomes mpl_get_value(obj, "prop"). Every assignment to a property becomes mpl_set(obj, "prop", value). Every function call becomes mpl_call(callee, args…). The backend only deviates from this rule for built-ins (std::abs, std::sqrt, std::pow) and for __mpl_* helpers that are internal.

Boxing discipline

Every expression in the IR lowers to either:

  • a value already in mpl_value form (from a nested call), or
  • a primitive (int, double, std::string, bool) that the caller will box if needed

Boxing is done by mpl_box_value<T> (preamble line 334-350), which uses if constexpr to dispatch on the type. The backend inserts mpl_box_value(…) calls at three places:

Where Why
Before passing args to mpl_call (line 8585-8587, 8620-8622) mpl_call expects boxed args
When assigning to a mpl_value typed slot (line 5570, etc.) To preserve heterogeneous storage
When returning from a lambda (line 8307, etc.) To normalize the return type

Unboxed primitives are emitted in expression position when the operator explicitly accepts them (mpl_add, mpl_sub, etc. all take mpl_value and internally unbox). See ./variant-handling.md for the unwrap rules.


Statement emission: the hoisting-aware emit ordering

File: CppBackend.h:2205-2231

When a module-level statement references a hoisted callable that appears later in the statement list, the backend needs to emit the referenced callable first (since C++ requires a forward declaration for same-scope references). The dispatch loop is:

std::function<void(size_t)> emitModuleStatement = [&](size_t index) {
    if (index >= N || moduleStatementEmitted[index]) return;
    const auto& stmt = module->statements[index];
    std::unordered_set<std::string> refs;
    collectIdentifierRefs(stmt, refs);
    for (const auto& refName : refs) {
        auto it = moduleHoistedCallableIndices.find(refName);
        if (it != moduleHoistedCallableIndices.end() && it->second > index) {
            emitModuleStatement(it->second);     // recurse to emit the callee first
        }
    }
    ss << generateStatement(stmt, indent);
    moduleStatementEmitted[index] = true;
};
for (size_t i = 0; i < N; ++i) emitModuleStatement(i);

This produces a depth-first pre-order traversal where dependencies come first. See ./hoisting.md for the exact forward-declaration rules.


End-to-end example: a JS function with closure

Source JS:

function makeAdder(x) {
    return function(y) { return x + y; };
}
const add5 = makeAdder(5);
console.log(add5(3));

Lowered IR (simplified):

ModuleNode
├── FunctionDeclNode name="makeAdder"parameters=[Parameter("x")]
│     body=Block
│       └── ReturnNode value=LambdaNode
│             parameters=[Parameter("y")]
│             body=BinaryOpNode op="+" left=Identifier("x") right=Identifier("y")
│             capturesLexicalThis=false
├── VariableDeclNode name="add5" initializer=FunctionCallNode("makeAdder", [Literal(5)])
├── ExpressionStatement
│     └── FunctionCallNode name="console.log" arguments=[FunctionCallNode(add5, [Literal(3)])]

Generated C++ (single-file mode):

// Auto-generated by MPL Compiler
// Source language: javascript
// Target backend: C++
// EMPL Runtime: Language-agnostic core + javascript adapter
#include <iostream>
#include <string>
// …preamble…

// Hoisted forward declarations:
static mpl_value makeAdder = __mpl_undefined__;   // line 2118
static mpl_value add5 = __mpl_undefined__;          // line 2122

int main(int argc, char** argv) {
    __mpl_js_set_process_argv(argc, argv);
    // Module statements in emit order:

    // [1] makeAdder = [=](auto x) -> mpl_value {
    //         return [=, &x](auto y) -> mpl_value {
    //             return mpl_box_value(mpl_add(x, y));
    //         };
    //     };
    makeAdder = [=](mpl_value x) -> mpl_value {
        return [=, &x](mpl_value y) -> mpl_value {
            return mpl_box_value(mpl_add(x, y));
        };
    };

    // [2] add5 = mpl_call(makeAdder, mpl_box_value(5));
    add5 = mpl_call(makeAdder, mpl_box_value(5));

    // [3] std::cout << mpl_to_string(mpl_call(add5, mpl_box_value(3))) << std::endl;
    std::cout << mpl_to_string(mpl_call(add5, mpl_box_value(3))) << std::endl;

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

Every primitive goes through mpl_box_value and mpl_to_string. Every call goes through mpl_call. Every closure uses C++ lambdas with explicit capture lists computed by buildLambdaCaptureList (see ./closures.md).


Cross-references