First-Class Functions¶
Complete guide to first-class functions in Sushi: function types (fn(i32) -> i32) and
function values — referencing a named function, storing it, passing it, and calling through
it. This is the v1 feature: zero-cost bare function pointers. Sushi now also has
closures (capturing lambda literals); this guide covers the non-capturing floor
both share.
Table of Contents¶
- Overview
- Function types
- Function values
- Calling through a function value
- Functions in data structures
- Custom error types
- How it compiles
- Type compatibility
- Error codes
- Limitations and deferred features
Overview¶
A function value lets you treat a function like any other value: bind it to a variable, put
it in a struct field or a List, pass it to another function, and call it indirectly. This
replaces hand-rolled match-based dispatch with ordinary data — the building block for callback
APIs, dispatch tables, and visitor-style code.
fn add_one(i32 x) i32:
return Result.Ok(x + 1)
fn apply(fn(i32) -> i32 f, i32 v) i32:
return Result.Ok(f(v)??)
fn main() i32:
let fn(i32) -> i32 g = add_one # reference a function by name
let i32 out = apply(g, 41).realise(0) # pass it, call through it -> 42
println(out)
return Result.Ok(0)
A plain function reference like add_one above carries no captured state — it is a bare
function pointer, the raw address of an already-compiled function. Sushi also has
closures: a lambda literal (|x| ...) that does capture its enclosing scope. The
two share the same function type and call syntax (see below).
Function types¶
A function type names the shape of a callable. It mirrors the function-declaration syntax for return and error types:
| Syntax | Meaning |
|---|---|
fn(i32) -> i32 |
takes an i32, returns i32, error type implicitly StdError |
fn(i32, string) -> bool |
two parameters, returns bool |
fn() -> ~ |
no parameters, blank (~) return |
fn(i32) -> i32 \| MathError |
explicit custom error type (the \| E mirrors fn f() i32 \| MathError) |
The arrow -> is required, and the return type is mandatory. Function types nest and compose
like any other type — they work as parameter types, struct fields, and generic type arguments
(List<fn(i32) -> i32>).
Function values¶
A function value is produced by writing a plain top-level function's name in value position (no call parentheses):
fn double(i32 x) i32:
return Result.Ok(x * 2)
fn main() i32:
let fn(i32) -> i32 f = double # `double` here is a value, not a call
return Result.Ok(0)
Only plain top-level functions can be referenced this way. Extension methods, perk methods, and FFI externals have different calling conventions and are not bare-referenceable; generic functions are deferred (see error codes).
Calling through a function value¶
Call a function value exactly like a named function — f(args). Because every Sushi function
returns Result<T, E>, an indirect call yields the same Result a direct call would, so ??,
if (result), and pattern matching all work unchanged:
fn run_twice(fn(i32) -> i32 f, i32 v) i32:
let i32 once = f(v)??
let i32 twice = f(once)??
return Result.Ok(twice)
Functions in data structures¶
Struct fields¶
A function value can be a struct field. To call it, bind the field to a local first — obj.op()
parses as a method call on obj, not as a call of the function-valued field op:
struct Handler:
fn(i32) -> i32 op
fn run(Handler h, i32 v) i32:
let fn(i32) -> i32 f = h.op # bind the field to a local
return Result.Ok(f(v)??) # then call through it
Lists (dispatch tables)¶
List<fn(...)> is the idiomatic way to hold a collection of functions — a dispatch table you can
iterate:
fn dispatch(List<fn(i32) -> i32> ops, i32 v) i32:
let i32 acc = v
foreach(f in ops.iter()):
acc := f(acc)??
return Result.Ok(acc)
.get(i) returns Maybe<fn(...)> just like any element type; unwrap it (?? / .realise) and
bind to a local before calling. (Raw arrays of function pointers are not expressible — the []
in fn() -> T[] binds to the return type T[] — so use List<fn(...)> for collections.)
Custom error types¶
The error type is part of the function type, so it threads through an indirect call correctly:
enum DivError:
DivByZero
fn safe_div(i32 a, i32 b) i32 | DivError:
if (b == 0):
return Result.Err(DivError.DivByZero)
return Result.Ok(a / b)
fn run(fn(i32, i32) -> i32 | DivError op, i32 x, i32 y) i32 | DivError:
return Result.Ok(op(x, y)??) # propagates DivError out of the indirect call
A function whose type omits | E has the implicit StdError error type, exactly like an
ordinary fn f() T declaration.
How it compiles¶
A function value lowers to a three-word fat pointer {fn_ptr, env_ptr, drop_ptr} (this
widened from a bare one-word pointer when closures were added; see the
closures guide for the full picture):
- A Sushi
fn add(i32) i32lowers to an LLVM function with signatureResult<i32, StdError>(i32). Referencing it as a value builds{f__thunk, null, null}— a small adapter thunk address, withenv_ptr/drop_ptrnull. - Calling through a function value is a single indirect
call, withenv_ptrpassed as a hidden leading argument (ignored by the thunk for a plain reference). - For a non-capturing value — everything on this page — there is no environment allocation and
no cleanup: the null
env_ptr/drop_ptrmake storage and destruction a no-op, so this stays effectively zero-cost. A capturing lambda instead heap-allocates an environment; that's the closures feature, not covered here.
Type compatibility¶
Function types are invariant: two are compatible only when the arity, every parameter type, the return type, and the error type match exactly. There is no implicit conversion between function types (no variance, no coercion). A mismatch is a clean diagnostic — see below.
Error codes¶
| Code | Meaning |
|---|---|
| CE2092 | function value type mismatch at a call-through — wrong arity, parameter type, return type, or error type |
| CE2093 | illegal function reference — a generic function (deferred in v1) |
| CE2002 | a function value assigned to a variable/parameter of an incompatible function type (the general assignment-mismatch error) |
Extension methods, perk methods, and FFI externals are not bare-referenceable at all: a bare name that resolves to none of constant/variable/top-level-function is an undeclared identifier (CE1001), and externals are reached only through their namespace.
Limitations and deferred features¶
v1 is intentionally the smallest useful slice, designed so each deferred piece is additive:
- Closures / lambda literals now exist — see the Closures guide. Tier 1 covers
copy-capture and escaping closures; borrow capture, move-capture of owned types, and stdlib
combinators (
List.map/.filter/.fold) remain outstanding there. - No generic-function references (
identity<i32>) — CE2093. Deferred until the instantiation can be forced and its mangled address taken. - Extension/perk-method and FFI-extern values are not referenceable (different ABIs).
- Call-through only via a
Name. Calling through an arbitrary expression —(expr)(),arr[0](),get_fn()(), or a struct fieldobj.handler()directly — is deferred. Bind to a local first:let f = arr.get(0)??thenf(x).
The deeper design rationale, the options considered, and the migration path live in the Closures & First-Class Functions design note.