Variadics¶
Complete guide to variadic functions in Sushi: the two safe native mechanisms (homogeneous
...T arrays and heterogeneous ...Ts parameter packs), the unsafe extern ... form for C
varargs, and how packs cross .slib library boundaries.
Table of Contents¶
- Overview
- Native homogeneous variadics (
...T) - Spread / forwarding (bloom)
- Parameter packs (
...Ts+expand) - Perk constraints on packs
expandsemantics- How packs compile (monomorphization)
- Packs across
.sliblibraries - Extern variadics (
...) - Choosing a form
- Error codes
- Limitations and deferred features
Overview¶
Sushi has three deliberately separate variadic forms. They look similar at the call site but serve different purposes:
| Form | World | Element types | Lowering |
|---|---|---|---|
...T |
safe, native | one homogeneous T |
collected into an owned runtime T[] |
...Ts + expand |
safe, native | heterogeneous, generic | monomorphized + compile-time-unrolled |
... |
unsafe, FFI | untyped C varargs | LLVM var_arg (libc printf-family) |
The first two are memory-safe, bounds-checked, and null-free like the rest of the language. The
third exists only inside unsafe external "C" blocks for calling libc — see the
FFI guide.
Native homogeneous variadics (...T)¶
A trailing parameter written ...T name collects every trailing call argument into an owned
dynamic array T[]. The marker ... is a prefix on the element type, and the variadic
parameter must come last.
fn sum(...i32 nums) i32:
let i32 total = 0
foreach(n in nums.iter()):
total := total + n
return Result.Ok(total)
fn main() i32:
let i32 a = sum(1, 2, 3, 4).realise(0) # 10
let i32 b = sum().realise(0) # 0 — zero args is valid
println("{a} {b}")
return Result.Ok(0)
Key properties:
- The element type
Tis one concrete type for every trailing argument (homogeneous). - Zero trailing arguments is valid — the callee receives an empty array.
- A variadic may follow fixed parameters:
fn log(string prefix, ...i32 values) ~:. - The synthesized
T[]is moved into the callee, which owns it and RAII-destroys it at scope exit. The lowered LLVM function stays non-variadic. - Reference element types (
...&peek T) are rejected (CE0114). A dynamic-array element (...T[]) is allowed — each trailing array argument is moved into the callee's array-of-arrays, not copied.
Native variadics are allowed only in plain function definitions — not perk or extension
methods (CE0115) — and are not exportable through a .slib public API (CE0116), because
a single concrete runtime function carrying an array ABI has no template to monomorphize at a
consumer.
Spread / forwarding (bloom)¶
An existing array can be forwarded into a ...T slot with postfix ..., written directly after
the array expression: arr.... This is called a bloom — the array "opens" and its elements
fan out to fill the trailing arguments — mirroring Go's f(arr...).
fn sum(...i32 nums) i32:
let i32 total = 0
foreach(n in nums.iter()):
total := total + n
return Result.Ok(total)
fn main() i32:
let i32[] xs = from([1, 2, 3])
let i32 s = sum(xs...).realise(0) # bloom: xs is MOVED into the variadic slot
println("sum = {s}") # sum = 6
return Result.Ok(0)
Key properties:
- Move, not copy. The source array is consumed — moved into the callee's synthesized
T[]. Do not use the source array after a bloom call. - Source must be a bare variable (a
Name) of array type. Blooming an arbitrary expression (a call result, a field access, an inline array literal) is not supported in v1. - Sole, last trailing argument. A bloom must be the only trailing argument at the call site — it cannot be mixed with individual trailing arguments, and blooming into a non-variadic parameter or anywhere but the variadic slot is CE0120.
- Type-checked. The bloomed array's element type must match the variadic's declared element type; a mismatch, or blooming a non-array value, is CE2006.
sys/process's run() uses this to forward a computed argument list:
use <sys/process>
let string[] argv = from(["-c", "seq 1 100000"])
let ProcessOutput out = run("sh", argv...).realise(ProcessOutput(1, "", ""))
Parameter packs (...Ts + expand)¶
A parameter pack is the heterogeneous, generic cousin of ...T. Instead of collecting one type
into a runtime array, a pack binds a variable-length tuple of concrete types per call site and
is expanded at compile time — so the arguments may each have a different type.
A pack has two halves:
- a type pack
...Tsin the type-parameter list, and - a value pack
...Ts namein the parameter list, whose element type is that type pack.
perk Describe:
fn describe() string
extend i32 with Describe:
fn describe() string:
return "int {self}"
extend string with Describe:
fn describe() string:
return "text '{self}'"
fn show_all<...Ts: Describe>(...Ts args) ~:
expand(a in args):
println(a.describe())
return Result.Ok(~)
fn main() i32:
show_all(42, "Mostly Harmless") # i32 then string, in one call
show_all() # arity 0 is valid
return Result.Ok(0)
show_all(42, "Mostly Harmless") monomorphizes a concrete show_all__i32_string.pack2; a different
mix of types produces a different instance. There is no runtime type tag, no boxing, and no
heterogeneous container — the same zero-cost static-dispatch story as ordinary generics.
A pack can follow fixed parameters, just like ...T:
fn log<...Ts: Render>(string label, ...Ts items) ~:
println("{label}:")
expand(it in items):
println(" - {it.render()}")
return Result.Ok(~)
Perk constraints on packs¶
A pack is normally written perk-constrained (...Ts: Describe). The constraint is what makes the
body usable: every element has a different concrete type, so for expand(a in args): a.describe()
to type-check, the compiler needs a guarantee that .describe() is valid on every element type,
whatever they turn out to be. The perk bound is that guarantee, checked once at the definition —
the same upfront-checking model Sushi already uses for ordinary generics.
If a call supplies an element type that does not implement the perk, the call fails with CE2090 naming the offending element and constraint.
An unconstrained pack (...Ts with no bound) may be declared, but its expand body cannot call
anything on the elements — there is no statically-known API common to all possible types. Useful
work with unconstrained packs needs forwarding or indexing, which are
deferred.
expand semantics¶
expand(x in pack): is a compile-time-unrolled construct, distinct from the runtime foreach.
Conceptually each element produces one copy of the body with x bound to that element's concrete
value:
- The body runs once per element, in declaration order; arity 0 means the construct vanishes.
xis the concrete per-element value —x.method()dispatches statically to that type's impl.- The body is ordinary straight-line code: it can read and update surrounding locals, so
expandcan accumulate a result, not just produce output:
fn print_row<...Ts: Show>(...Ts cells) ~:
let string line = ""
expand(c in cells):
line := "{line}[{c.show()}]"
println(line)
return Result.Ok(~)
- Early
returnand the??propagation operator work insideexpand; any owned per-element temporaries are RAII-dropped exactly once, including on the early-exit paths.
How packs compile (monomorphization)¶
Packs reuse Sushi's generics pipeline — compile-time monomorphization with static dispatch:
- Pass 1.5 collects each call site's ordered tuple of concrete trailing-argument types plus the arity.
- Pass 1.6 monomorphizes one concrete function per
(arity, type-tuple), expanding the value pack into N ordinary parameters and unrolling theexpandbody — each expansion typed to its concrete element. After this step there is no pack node left for later passes to see. - The mangled name encodes the arity and ordered types (e.g.
show_all__i32_string.pack2), so distinct instances never collide and identical ones dedupe.
The result is straight-line, per-element-typed code with zero loops, type tags, or boxing for the expansion.
Packs across .slib libraries¶
A pack function is generic, so it ships across a library boundary exactly like a regular generic.
The producer serializes the function as a re-parsable source template in the .slib templates
section; the consumer re-parses it, registers it alongside its own definitions, and monomorphizes it
at its own call sites:
# in the library (built with `sushic --lib`)
perk Display:
fn display() string
public fn show_all<...Ts: Display>(...Ts args) ~:
expand(a in args):
println(a.display())
return Result.Ok(~)
# in a consumer program
use <lib/format_lib>
extend i32 with Display:
fn display() string:
return "i:{self}"
extend string with Display:
fn display() string:
return "s:{self}"
fn main() i32:
show_all(42, "hi") # monomorphized in the consumer
return Result.Ok(0)
The library ships the perk Display definition so the consumer need not redeclare it; the
consumer still supplies its own extend <type> with Display implementation for each type it
instantiates the pack with. See the Libraries guide for the full template mechanism.
Native
...Tcannot cross a library boundary (it is a single concrete runtime function with an array ABI, not a template) — that is the...Tvs...Tsdistinction, enforced by CE0116.
Extern variadics (...)¶
Inside an unsafe external "C" block, a bare trailing ... after at least one fixed parameter binds
the printf family. This is the only place untyped C varargs exist; it lowers to a true LLVM
var_arg call and follows C promotion rules. It is unrelated to the native forms above — see the
FFI guide for details and the four-guarantee story.
unsafe external "C" as libc because "formatted output":
fn printf(string fmt, ...) i32 = "printf"
Choosing a form¶
- Need an owned collection you can store, push to, iterate repeatedly, or pass on, and every
argument is the same type? Use
...T. - Need mixed argument types behind a common perk (a
printf/logging/formatting style API), or a generic function that works over a variable number of typed arguments? Use...Ts+expand. - Calling libc? Use extern
...inside anunsafe external "C"block.
Error codes¶
| Code | Meaning |
|---|---|
| CE0114 | ...T must be last, at most one per function; element type must not be a reference (a dynamic-array element ...T[] is allowed); also rejected in generic functions |
| CE0115 | a variadic parameter (...T or ...Ts) is not allowed in a perk or extension method |
| CE0116 | a public native ...T function cannot be exported through a .slib public API (does not apply to ...Ts packs) |
| CE0117 | a type-pack ...Ts must be the last type parameter; at most one pack per function |
| CE0118 | cannot mix a type-pack ...Ts with a native ...T in the same function |
| CE0119 | malformed expand statement |
| CE0120 | a bloom argument arr... used somewhere illegal (into a non-variadic parameter, or not the sole, last trailing argument) |
| CE2090 | a pack element type does not satisfy the pack's perk constraint |
| CE2006 | (reused) blooming a non-array value, or an array of the wrong element type, into a ...T slot |
Limitations and deferred features¶
- Perk-constrained packs only — an unconstrained
...Tscan be declared but itsexpandbody cannot operate on elements yet. - Plain functions only — packs (and
...T) are not allowed in perk or extension methods (CE0115). - Bloom source must be a bare variable —
arr...requiresarrto be aName; blooming an arbitrary expression (a call result, a field access, an inline literal) is not supported in v1. - No pack forwarding (
g(pack...)) and no pack indexing (args.0) — bloom spreads a single...Tarray, not a...Tspack — deferred. - Native
...Tis not exportable via.slib(CE0116);...Tspacks are.
The deeper design rationale lives in the Variadics design note.