Closures and Callbacks
How to implement closures, function pointers, and callback systems in SBBE IR.
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:
- Allocate an environment struct in memory containing the captured values
- The closure function takes the environment pointer as a hidden first parameter
- 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
- Function indices: Each function in the SBBE unit has a numeric index (assigned by the assembler).
$doublemight be index 0,$tripleindex 1. The frontend tracks this mapping. - Passing a function: Push the function’s index as a plain
i32. From SBBE’s perspective, it’s just an integer. - Calling through a pointer:
callipops a function index from the stack and calls that function. Arguments must already be on the stack below the index. - Why
calliinstead ofcall?callrequires a statically known function name.calliresolves 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
- Environment struct: The closure captures
n, so the environment is a singlei32at offset 0 (4 bytes total). If the closure captured multiple variables, they would be laid out sequentially in the struct. - Closure function signature: The original lambda
(Int32) -> Int32becomes(ptr, Int32) -> Int32. The hidden first parameter is the environment pointer. - Accessing captures: Inside the closure body,
nis loaded from the environment:ldl 0(env pointer) thenldm i32(loadnfrom offset 0). - Factory function:
makeAdderallocates the environment withsalloc, storesninto it, and returns the env pointer. The caller stores this alongside the function index. - Calling the closure: Push the env pointer (first arg), the actual argument (second arg), then the function index, then
calli. - 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 meanscalliworks 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
- Callback layout: A callback slot is 16 bytes:
i32 func_indexat offset 0, 4 bytes padding,ptr contextat offset 8. - Registration: Write the function index and context pointer into the slot.
- 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. - 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
- A closure is a (function_index, environment_pointer) pair
- The environment is allocated in linear memory (via
sallocor the heap) - The closure function receives the environment as a hidden first parameter
callihandles the indirect call; the frontend manages the calling convention- This pattern works for closures, callbacks, vtable dispatch, and coroutine resume functions