Functions & closures
Normative rules: spec §3 Grammar, §5.8 Coercions, §7.4 Async closures.
Functions
fun declares a function. The last expression in the body is the return
value, and ret returns early:
fun clamp(value: i32, low: i32, high: i32): i32 {
if value < low {
ret low;
}
if value > high {
ret high;
}
value
}
fun main() {
print(clamp(15, 0, 10));
}
Notice there is no return on the last line. A bare expression at the
end of a block is the block’s value. You’ll see this everywhere in Vilan.
if, match, and plain blocks all work the same way. An early exit is
ret, and the compiler says so if you write return, fn or -> i32
out of habit: each is refused with the vilan spelling, and the editor
offers the rewrite.
The : i32 is optional. Leave it off and the return type is inferred
from the body — from the final expression and from every ret, which
have to agree. A body that only ever leaves by ret is typed by those
rets:
fun sign(x: i32) {
if x < 0 {
ret -1;
}
if x > 0 {
ret 1;
}
ret 0;
}
fun main() {
print(sign(-7));
}
If a ret disagrees with the rest of the body — a ret "s" next to a
final 2, a bare ret in a body that ends in a value, a ret 1 beside
an if with no else that can fall through with nothing — the compiler
reports it at that ret. Declaring the return type is always an option,
and turns inference into checking.
Generic functions take type parameters. Bounds say what the body is allowed to do with them:
fun largest<T: PartialOrd>(a: T, b: T): T { … }
Parameters are immutable by default, like let bindings. Write mut to
make one a scratch copy the body can rebind and mutate — the caller
never sees it, because parameters arrive by value (to change the
caller’s value, take &mut instead). It works on self and closure
parameters too:
fun bump(mut x: i32): i32 {
x = x + 1;
x
}
fun main() {
let original = 7;
print(bump(original)); // 8
print(original); // 7 — untouched
}
Taking any number of arguments
Prefix the last parameter with ... and callers write its elements out
flat instead of building a tuple. The parameter itself is an ordinary
tuple parameter — fun f(...items: T) is fun f(items: T), and
f(a, b) is f((a, b)) — so T is the whole pack, a tuple of
however many arguments arrived, and its bound decides how many are
allowed:
// `(..)` admits any arity, including none at all.
fun how_many<T: (..)>(...items: T): i32 {
1
}
// A fixed parameter first; the spread takes whatever is left.
fun first_of(head: i32, ...rest: (i32, i32)): i32 {
head
}
fun main() {
print(how_many());
print(how_many("a", true, 3));
print(first_of(7, 8, 9));
}
T: (2..) would reject how_many(1) with the arity bound’s own error,
and T: (..: Display) would require every argument to be printable.
Since the arguments are collected into a value the call site builds,
... never combines with own, &, or &mut; mut ...items is fine,
and means what mut always means. ... belongs to a plain fun — not
to a closure, a trait method, an impl member, or an external fun.
Passing a pack on
Since f(a, b) means f((a, b)), a .. spread at the call site means
f((..pair)) — the arguments you already hold, written out flat. That is
how a pack is forwarded to another spread function, and it is not the
same as passing the tuple itself, which would arrive as a pack of one:
fun how_many<T: (..)>(...items: T): i32 {
1
}
fun forward<T: (..)>(...items: T): i32 {
how_many(..items)
}
fun main() {
let pair = (1, 2);
print(how_many(..pair)); // a pack of 2
print(how_many(..pair, 3)); // a pack of 3
print(forward(1, 2, 3)); // a pack of 3, passed on
}
The bound is checked on what the spread actually contributes, so a
T: (3..) would reject how_many(..pair) for having only two. A spread
at a call to a function without a spread parameter builds no tuple and
is an error — write the tuple yourself (takes_a_tuple((..pair))).
Named arguments
Give the pack’s tuple labels and the call site can name its arguments. The collected tuple is a labelled literal, so the names match by name, in any order, and positional calls keep working:
fun draw(...at: (x: f64, y: f64)): f64 {
at.x * 10 + at.y
}
fun main() {
print(draw(1, 2));
print(draw(x = 1, y = 2));
print(draw(y = 2, x = 1)); // the same call
}
Name every argument the pack collects, or none. A name only means something to a spread parameter — anywhere else it is refused — and there are no defaults: every slot is written.
Lazy parameters
Prefix a parameter with lazy and the call site stops evaluating its
argument. It packages the expression instead, and the callee runs it the
first time it reads the parameter — at most once, however many times it
reads, and never at all if it never does:
mut built = 0;
fun describe(): str {
built += 1;
"seven"
}
fun expect_positive(value: i32, lazy complaint: str): i32 {
if value > 0 {
ret value;
}
print(complaint);
print(complaint);
0
}
fun main() {
print(expect_positive(7, i"{describe()} is not positive"));
print(i"built {built}"); // 0
expect_positive(-1, i"{describe()} is not positive");
print(i"built {built}"); // 1
}
The happy path never reads complaint, so the message is never built:
that is what lazy is for. The failing path reads it twice and builds it
once — a lazy argument is evaluated at most once, late, never twice.
Inside the callee the parameter is an ordinary str; nothing about the
body says which of its parameters are lazy.
lazy is part of the signature, not a private decision of the body,
because it is the caller that builds the deferred expression. So an
impl of a trait that declares a lazy parameter must declare it too, and
hover shows it beside the type.
Passing a lazy parameter on to another lazy position forwards the same deferred value rather than forcing it — one evaluation, however long the chain. Passing it anywhere else is a read, so it forces there:
fun inner(lazy message: str, read: bool): i32 {
if read {
print(message);
}
0
}
fun outer(lazy message: str, read: bool): i32 {
inner(message, read) // forwarded, not forced
}
fun eager(message: str): i32 {
print(message);
0
}
fun forces(lazy message: str): i32 {
eager(message) // an eager position: forced here
}
fun main() {
outer("never printed", false);
outer("printed once", true);
forces("printed once too");
}
Three things a lazy argument may not do, each because the expression runs inside the callee rather than where it is written:
- own a resource. The package that carries the expression would own it, which is exactly what a closure may not do. Resources stay eager.
- suspend. The forcing point is a plain read, and a read must not be
a suspension point. Deferred async has its own spelling: write
async <expr>and pass theTask. - read an ambient context. By the time it forces, the call site’s
contexts may be gone — the same self-containment a
dropbody obeys. Pass a closure explicitly and call it where the context is threaded.
lazy belongs to a plain fun, an impl method and a trait
signature. It combines with none of own, &, &mut, mut or ...,
and it is written first: fun f(lazy message: str).
Five std members take a lazy argument, and they are the ones whose
argument is only ever needed on the unhappy path: Option::expect(lazy message), Option::unwrap_or(lazy fallback), Result::expect(lazy message), Result::unwrap_or(lazy fallback) and
Result::expect_err(lazy message). So
opt.unwrap_or(expensive()) runs expensive() only when opt is None,
and res.expect(i"no row for {key}") builds no message when the row is
there. unwrap_or_else is still there for a fallback you would rather
write as a closure — and on a Result it is the one that sees the error.
Closures
A closure is an inline function value. Where JavaScript writes
x => x * 2, Vilan writes |x| x * 2. Parameter types are usually
inferred from where the closure is used. Annotate them when they aren’t:
fun apply(seed: i32, transform: |i32| i32): i32 {
transform(seed)
}
fun main() {
print(apply(21, |n| n * 2));
let label = |count: i32| i"{count} items";
print(label(3));
}
Closure types are written |T| U. A closure with no parameters is
|| U, and one that returns nothing is || void. These appear as
parameter types, in let annotations, and as struct fields.
ret inside a closure returns from the closure, and the closure’s
return type is inferred exactly the way a function’s is: from the final
expression when the body can reach it, and from every ret, which have
to agree — a body that only ever leaves by ret is typed by those
rets. A ret that disagrees is reported at that ret.
A closure captures the bindings around it, not their values — the one alias in Vilan you get without asking for one. The closure and its creator share the binding, so a write on either side shows up on the other, and even reassigning the whole binding is visible inside:
fun main() {
mut label = "before";
let show = || label;
label = "after";
print(show()); // after — same binding, not a copy of it
}
That is usually what you want: it is how a click handler reads the
counter you just bumped. Three things it does not extend to. A
resource cannot be captured at all — a closure would be a second
owner of it. A view (&x, &mut x) cannot either: the closure may
outlive the place, so read the value out with * first, or take the
view as a parameter of the closure. And an ambient
context value is the one thing genuinely copied
when the closure is made, so a deferred body reads the context it was
written in.
When two closures need to share mutable state that outlives the frame
they were made in, they hold a Shared cell together. The
memory model explains that pattern, and
spec §6.9 is the rule.
Named functions as closures
When a function already does what your closure would do, pass the function itself:
fun exclaim(text: str): str {
text + "!"
}
fun main() {
let words = ["hello", "there"];
let loud = words.map(exclaim); // instead of .map(|w| exclaim(w))
print(loud[0]);
}
You can also just name one and call it later. A binding holding a function is a function:
fun exclaim(text: str): str {
text + "!"
}
fun main() {
let shout = exclaim;
print(shout("hey"));
}
This works for plain Vilan functions. It does not work for generic
functions, methods, async functions, or externs — those have no value
form, so you can neither store one nor call it through a binding. For
those, write the small wrapping closure. The compiler will tell you when
you hit one, and which of the four you hit.
Async closures
You can skip this section until you start storing callbacks that do async work.
A closure type can carry an async marker: async |T| U. Calls through
a value of that type are awaited automatically, the same way direct
calls to async functions are (see Async). Write it anywhere
a closure type is declared (a parameter, a let annotation, a struct
field, a return type):
fun draft<T: PartialEq>(initial: T, commit: async |T| Option<str>): Draft<T> // a parameter
struct Poller {
tick: async || i32, // a struct field
}
let commit: async |T| Option<str> = stored; // a let annotation
let outcome = commit(value); // …and this call awaits
An unannotated let needs no marker at all: a binding that holds an
async closure adopts its asyncness. Async
covers the same seams from the async side.
There is one more rule, and it works in your favor. Passing an async
closure where a plain closure is expected is an error if the plain type
returns a value: you would receive a promise pretending to be the
value. But if the plain type returns void, it is allowed, and the call
becomes fire-and-forget. This is why UI event handlers can await things
freely without any ceremony.
Context clauses
You will use this feature constantly without writing it. It is how the UI framework passes things like “the current owner” invisibly. You only write it yourself when building framework-level helpers, so feel free to skim this on a first read.
A parameter’s closure type can declare that the closure reads an ambient context:
fun mount_root(id: str, body: (sync || View) context owner_scope): Owner
fun batch<T>(body: (sync || T) context turn_scope): T
fun turn<T>(policy: FlushPolicy, body: (|| T) context turn_scope): T
The sync marker on the first two says the closure must stay
synchronous; Async explains it.
turn carries none, and the difference is the feature: its body is
asyncness-polymorphic, so an awaiting one holds every notification until
it completes, where a batch body must stay synchronous.
When you pass a closure literal into such a parameter, the ambient value
(the current Owner, the current Turn) is threaded to it at the call
site, through any depth of ordinary function calls in between. This is
the machinery behind “every effect registers with the nearest
boundary” in the UI layer. Your component functions never mention
owners, and ownership still flows to the right place.
Going deeper. Two rules keep contexts sound. First, closures capture their contexts when they are created. A closure created outside a scope and called inside it would see nothing, so the compiler rejects that shape outright. Second, a function that reads a context can’t be passed around as a plain value, because the context channel would be severed. Both produce clear errors when you hit them.