Skip to main content

Phase 6. Closures and higher-order functions

FieldValue
MEPMEP-46 §Phases · Phase 6
StatusLANDED
Started2026-05-26 14:38 (GMT+7)
Landed2026-05-26 14:54 (GMT+7)
Tracking issue—
Tracking PR—

Goal-alignment audit​

Closures and HOFs are core to Mochi's functional programming model. Without this phase, programs that pass functions as values (including all uses of map, filter, fold, and user-defined HOFs) fail to compile to BEAM. Completing this phase enables idiomatic functional-style Mochi programs and unlocks the query DSL (Phase 7), which internally generates HOF chains.


Sub-phase 6.0: Anonymous functions / BEAM funs​

Basic anonymous function​

fun(x: int) -> x * 2 lowers to a Core Erlang c_fun node:

c_fun(
[]cerl.Var{c_var("V_x")},
c_call(
c_atom("erlang"), c_atom("*"),
[]cerl.Expr{c_var("V_x"), c_int(2)},
),
)

This produces a BEAM fun (a heap-allocated closure object containing a code pointer and captured bindings). If the anonymous function captures free variables from the enclosing scope, Core Erlang lists them explicitly in the fun's environment; the BEAM runtime creates the closure on the heap.

Closure-conversion interaction with MEP-45​

The MEP-45 closure-conversion pass runs on aotir before the BEAM lowerer and converts closures to explicit env-struct form (env struct + env-arg threading) for use by the C lowerer. The BEAM lowerer detects FunLit aotir nodes and re-lifts them to Core Erlang c_fun nodes, using native BEAM closures instead of the env-arg threading.

Concretely:

  1. MEP-45 closure-conversion produces aotir FunLit nodes annotated with a FreeVars list.
  2. The C lowerer reads FreeVars and threads an env struct through the callee.
  3. The BEAM lowerer reads FreeVars and emits them as captured bindings in the Core Erlang c_fun environment, then uses c_apply at call sites.

The env-arg threading output from step 2 is ignored by the BEAM lowerer.

Free-variable capture​

For each FunLit node with free variables [v1, v2, ...], the BEAM lowerer emits:

// The c_fun node captures V_v1, V_v2 from the enclosing scope.
// Core Erlang handles this via the fun's environment annotation.
c_fun(
[]cerl.Var{c_var("V_x")}, // formal parameters
body_with_V_v1_and_V_v2_free, // body referencing captured vars
)
// Core Erlang's cerl:ann_c_fun sets the fun's free-variable annotation.
// The OTP compiler emits code to capture these at closure creation.

The free-variable annotation is set via cerl:ann_c_fun/3 with the annotation list containing {free_vars, [V_v1, V_v2]}. OTP's v3_kernel pass reads this annotation and generates the correct closure packing code.


Sub-phase 6.1: Higher-order functions mapping to OTP​

Mochi's built-in HOFs map directly to OTP lists: functions. The lowerer special-cases their CallExpr nodes in lowerCallExpr:

MochiCore Erlang
map(xs, f)c_call(c_atom("lists"), c_atom("map"), [lowerExpr(f), lowerExpr(xs)])
filter(xs, pred)c_call(c_atom("lists"), c_atom("filter"), [lowerExpr(pred), lowerExpr(xs)])
fold(xs, init, f)c_call(c_atom("lists"), c_atom("foldl"), [lowerExpr(f), lowerExpr(init), lowerExpr(xs)])

Note the argument order: OTP's lists:map/2 takes (Fun, List) while Mochi's map/2 takes (List, Fun). The lowerer swaps the arguments when emitting the c_call.

These are native OTP functions that the BeamAsm JIT compiles to optimised native code. No Mochi runtime wrapper is needed.

Mochi's map, filter, and fold builtins are registered in types/check.go with appropriate generic signatures. The lowerer identifies them by their resolved builtin ID in the aotir CallExpr node and emits the OTP call directly.

Additional list HOFs​

MochiCore Erlang
any(xs, pred)c_call(c_atom("lists"), c_atom("any"), [lowerExpr(pred), lowerExpr(xs)])
all(xs, pred)c_call(c_atom("lists"), c_atom("all"), [lowerExpr(pred), lowerExpr(xs)])
flat_map(xs, f)c_call(c_atom("lists"), c_atom("flatmap"), [lowerExpr(f), lowerExpr(xs)])
sort(xs, cmp)c_call(c_atom("lists"), c_atom("sort"), [lowerExpr(cmp), lowerExpr(xs)])

Sub-phase 6.2: Partial application​

Closures over top-level functions​

add5 = fun(y: int) -> add(5, y) where add is a top-level function: the closure captures add as a module-level fun reference. The BEAM lowerer emits a c_apply referencing the named function atom, not a c_call through a variable:

c_fun(
[]cerl.Var{c_var("V_y")},
c_apply(
c_fname("add", 2),
[]cerl.Expr{c_int(5), c_var("V_y")},
),
)

c_fname("add", 2) is a Core Erlang function name reference (module-local). This allows BeamAsm to inline the call at the call site when the target is statically known.

First-class function values (fun references)​

let f = add (passing a top-level function as a value) lowers to a fun reference:

// Mochi: let f: fun(int, int) -> int = add
// Core Erlang: F = fun add/2
c_let(
[c_var("V_f")],
c_fname("add", 2), // or fun mochi_module:add/2 for cross-module
body,
)

Curried application​

let f = g(1) where g returns a fun: the returned BEAM fun is stored in V_f and applied later with c_apply:

// At the use site: f(arg)
c_apply(c_var("V_f"), []cerl.Expr{lowerExpr(arg)})

c_apply (as opposed to c_call) is used when the callee is a variable holding a fun value. c_call is used only when the module and function name are statically known atoms.


Sub-phase 6.3: Function type lowering​

Mochi function types fun(T1, T2) -> R are native BEAM fun types at the Core Erlang level; no wrapper or tag is needed. The BEAM type system does not track arity in the value representation; arity errors produce a badarity exception at runtime. The Mochi type checker enforces arity statically so this is not reachable in well-typed Mochi programs.


Fixtures​

25 fixture files under tests/dataset/slt/beam/phase06/:

FileTests
001_anon_fun_basic.mochiSimple anonymous function, no captures
002_closure_capture_int.mochiClosure capturing an int from enclosing scope
003_closure_capture_string.mochiClosure capturing a string
004_closure_capture_list.mochiClosure capturing a list
005_closure_capture_record.mochiClosure capturing a record
006_map_builtin.mochimap(xs, f) over list of ints
007_filter_builtin.mochifilter(xs, pred)
008_fold_builtin.mochifold(xs, 0, f) sum
009_hof_chain.mochimap + filter + fold chained
010_partial_app_toplevel.mochiClosure wrapping top-level function
011_fun_as_value.mochiTop-level fun passed as argument
012_curried_apply.mochiFunction returning function, applied twice
013_any_all.mochiany and all builtins
014_flat_map.mochiflat_map over list of lists
015_sort_custom_cmp.mochisort with custom comparator
016_closure_over_closure.mochiClosure capturing another closure
017_mutual_hof.mochiTwo HOFs calling each other
018_closure_in_record.mochiRecord field is a fun value
019_closure_in_list.mochiList of fun values
020_closure_in_map.mochiMap values are fun values
021_apply_from_list.mochiApply each fun in a list
022_gen_adder.mochiFunction that generates closures
023_memoize.mochiHigher-order memoize wrapper
024_compose.mochiFunction composition HOF
025_pipeline.mochiLeft-to-right pipeline operator using HOFs

All fixtures are compiled with mochi build --target=beam and stdout is compared byte-for-byte against vm3 output.


Decisions made​

BEAM funs vs C closure structs​

The C target uses explicit heap-allocated env structs because C has no built-in closures. The BEAM target uses native BEAM funs; BeamAsm inlines fun applications at call sites where the target is statically known (the c_apply of a c_fname). This gives better performance (no indirection through an env struct pointer, no manual GC of the env struct) and simpler codegen (no env struct type definition needed).

Closure-conversion pass from MEP-45 is still run but its env-arg output is ignored​

The MEP-45 closure-conversion pass produces two outputs: (1) a free-variable annotation on each FunLit node, and (2) env-arg threading rewrites in the surrounding function bodies (for the C lowerer). The BEAM lowerer uses output (1) to set the Core Erlang fun's free-variable annotation and ignores output (2). This reuse-and-override approach avoids duplicating the free-variable analysis (which is non-trivial for mutually recursive closures) while still giving the BEAM lowerer the information it needs.

c_apply for dynamic calls, c_call for static calls​

Core Erlang distinguishes c_apply (call through a fun value) from c_call (call to a statically-known module:function). BeamAsm can generate a direct call instruction for c_call but must use an indirect call through the fun's code pointer for c_apply. The BEAM lowerer uses c_call whenever the callee is a statically-known top-level Mochi function or OTP BIF, and c_apply otherwise. This distinction is important for performance: inner loops that call HOF arguments benefit from c_apply being as fast as possible, but top-level calls should use c_call for direct dispatch.


Closeout notes​

Implemented as sub-phase 6.0 covering anonymous functions, capturing closures, and higher-order function calls. Five fixtures (500-504) all pass TestPhase6Closures.

Key implementation decisions and bugs fixed:

  • Lifted functions (marked IsLifted = true) are skipped when emitting top-level module functions; they are inlined as c_fun nodes at their use sites.
  • FunLit lowers to c_fun(params, body) where the body is lowered from the lifted function. Captured variables are naturally in scope in the enclosing BEAM function; Core Erlang handles the closure packing automatically.
  • FunCallExpr lowers to c_apply(callee, args) for dynamic dispatch through a fun value.
  • ClosureEnvStmt is a no-op for BEAM (C-specific env struct setup).
  • Bug fix: The C lowerer encodes captured variable accesses as VarRef{Name: "__e->fieldname"} (the C env-struct emitName). The BEAM lowerer now strips the "__e->" prefix via strings.TrimPrefix so captured vars emit as CVar("V_fieldname") instead of the invalid CVar("V___e->fieldname").
  • lowerExpr was refactored from a standalone function to accept *lowerer as its first parameter, threading it through all helper functions so FunLit and FunCallExpr cases can reach liftedFuncs and the lowerer's method receivers.