SBBE has no built-in closure type. Closures are lowered by the frontend into a function pointer paired with an environment pointer, using calli for the indirect call. This guide walks through the lowering from simple function pointers to full closures with captured variables.

What we’re lowering

func makeAdder(n: Int32) -> (Int32) -> Int32 {
    return { x in x + n }  // captures n from enclosing scope
}

let add5 = makeAdder(5)
add5(10)  // returns 15
add5(20)  // returns 25

The closure { x in x + n } captures n from makeAdder’s scope. After makeAdder returns, the closure must still be able to access n. The frontend must allocate n somewhere that outlives the call and pass it to the closure at each invocation.

The standard lowering is:

  1. Allocate an environment struct in memory containing the captured values
  2. The closure function takes the environment pointer as a hidden first parameter
  3. The closure value is a (function_index, env_pointer) pair

Before diving into closures, we start with the simpler case: function pointers with no captures.

Simple function pointers

Pseudo-code

func apply(fn: (Int32) -> Int32, value: Int32) -> Int32 {
    return fn(value)
}

func double(x: Int32) -> Int32 { return x * 2 }
func triple(x: Int32) -> Int32 { return x * 3 }

apply(double, 21)  // returns 42

Step-by-step lowering

  1. Function indices: Each function in the SBBE unit has a numeric index (assigned by the assembler). $double might be index 0, $triple index 1. The frontend tracks this mapping.
  2. Passing a function: Push the function’s index as a plain i32. From SBBE’s perspective, it’s just an integer.
  3. Calling through a pointer: calli pops a function index from the stack and calls that function. Arguments must already be on the stack below the index.
  4. Why calli instead of call? call requires a statically known function name. calli resolves the target at runtime, enabling higher-order functions, vtable dispatch, and callbacks.

SBBE lowering

func $double(i32) -> i32 {
entry:
    ldl 0
    ldi 2
    mul.s i32
    ret
}

func $triple(i32) -> i32 {
entry:
    ldl 0
    ldi 3
    mul.s i32
    ret
}

func $apply(i32, i32) -> i32 {
    // params: fn_index=0, value=1
entry:
    ldl 1          // push the argument
    ldl 0          // push the function index
    calli 0        // indirect call
    ret
}

func $main() -> i32 {
entry:
    ldi 0          // index of $double
    ldi 21         // value
    call $apply    // apply($double, 21) = 42
    ret
}

Closures with captured variables

Pseudo-code

func makeAdder(n: Int32) -> (Int32) -> Int32 {
    return { x in x + n }  // captures n
}
let add5 = makeAdder(5)
add5(10)  // returns 15

Step-by-step lowering

  1. Environment struct: The closure captures n, so the environment is a single i32 at offset 0 (4 bytes total). If the closure captured multiple variables, they would be laid out sequentially in the struct.
  2. Closure function signature: The original lambda (Int32) -> Int32 becomes (ptr, Int32) -> Int32. The hidden first parameter is the environment pointer.
  3. Accessing captures: Inside the closure body, n is loaded from the environment: ldl 0 (env pointer) then ldm i32 (load n from offset 0).
  4. Factory function: makeAdder allocates the environment with salloc, stores n into it, and returns the env pointer. The caller stores this alongside the function index.
  5. Calling the closure: Push the env pointer (first arg), the actual argument (second arg), then the function index, then calli.
  6. Why a hidden first parameter? It is the simplest calling convention for closures. Every closure function has the same signature shape: (ptr env, ...original params). This means calli works uniformly for closures and plain function pointers (plain function pointers pass a null or dummy env).

SBBE lowering

The closure body:

// The closure body: takes (env_ptr, x) and returns x + env.n
func $adder_closure(ptr, i32) -> i32 {
entry:
    ldl 1          // x
    ldl 0          // env ptr
    ldm i32        // load n from env[0]
    add.s i32      // x + n
    ret
}

The factory function allocates the environment and returns a closure pair:

// make_adder(n) -> returns env_ptr (caller knows the function index)
func $make_adder(i32) -> ptr {
    var $env ptr

entry:
    // Allocate environment: 4 bytes for one i32
    ldi 4
    salloc
    str $env

    // Store captured variable n into env
    ld $env
    ldl 0          // n parameter
    stm i32        // env[0] = n

    // Return env pointer
    ld $env
    ret
}

Calling the closure:

func $main() -> i32 {
    var $env ptr

entry:
    // make_adder(5)
    ldi 5
    call $make_adder
    str $env

    // Call the closure: adder_closure(env, 10)
    ld $env        // env pointer (first arg)
    ldi 10         // x (second arg)
    ldi 0          // function index of $adder_closure
    calli 0        // indirect call -> returns 15
    ret
}

Callback registration

Pseudo-code

struct Callback {
    var handler: (Context) -> Int32
    var context: Context
}

func register(slot: *Callback, handler: (Context) -> Int32, ctx: Context) {
    slot.handler = handler
    slot.context = ctx
}

func invoke(slot: *Callback) -> Int32 {
    return slot.handler(slot.context)
}

Step-by-step lowering

  1. Callback layout: A callback slot is 16 bytes: i32 func_index at offset 0, 4 bytes padding, ptr context at offset 8.
  2. Registration: Write the function index and context pointer into the slot.
  3. Invocation: Load the context (push it as the argument), load the function index, then calli. The context pointer serves the same role as the closure environment.
  4. Why the same pattern as closures? Callbacks, closures, and vtable dispatch are all the same mechanism: an indirect call with some associated data. The only difference is who allocates the data and how long it lives.

SBBE lowering

// Callback struct layout:
//   offset 0: i32 func_index
//   offset 4: (padding)
//   offset 8: ptr context

func $register_callback(ptr, i32, ptr) {
    // params: callback_slot=0, func_index=1, context=2
entry:
    // Store func_index
    ldl 0
    ldl 1
    stm i32

    // Store context at offset 8
    ldl 0
    ldi 8
    add.s i64
    ldl 2
    stm ptr

    ret
}

func $invoke_callback(ptr) -> i32 {
entry:
    // Load context (first arg to the callback)
    ldl 0
    ldi 8
    add.s i64
    ldm ptr

    // Load func_index
    ldl 0
    ldm i32

    // Call: callback(context)
    calli 0
    ret
}

Key takeaways