Guides · 04
Functions and closures
Everything callable in Vyi comes from one declaration form: fn. Methods are fn
with a receiver, overloads are fns sharing a name, and closures are function values
you write inline with arrow syntax. This guide covers all of them.
Declaring functions
Parameters are name: Type, and the return type comes after the parameter list — no
arrow, no keyword:
fn add(a: i32, b: i32) i32 {
return a + b
}Omitting the return type means the function returns (), the empty tuple (Vyi has no
void keyword):
import { stdout } from "@io"
fn log(msg: [*]u8) { // returns ()
stdout.write(msg)
}fn declarations are hoisted: the name is visible throughout its scope, not just
below the declaration, so functions can call each other regardless of source order:
fn isEven(n: i32) bool {
if n == 0 { return true }
return isOdd(n - 1) // declared below — fine
}
fn isOdd(n: i32) bool {
if n == 0 { return false }
return isEven(n - 1)
}export fn makes a function importable from other files; without export it is
private to its file (guide 14 — Modules and packages).
Methods and receivers
A method is a function with a receiver written in parentheses before the name. There are four receiver shapes:
(p: Point)— a value receiver: the method gets a copy (never mutates the caller).(p: *mut Point)— a writable pointer receiver: may mutate the original; only on a writable place (or an existing*mut).(p: *con Point)— a read-only pointer receiver: may read without copying; callable on any instance of that type.(Point)— a type receiver: no value — called on the type itself (constructors).
import { stdout } from "@io"
struct Point {
x: i32,
y: i32,
}
fn (Point) origin() Point {
return Point{ x: 0, y: 0 }
}
fn (p: Point) lengthSquared() i32 {
return p.x * p.x + p.y * p.y
}
fn (p: *con Point) xCoord() i32 {
return p.x
}
fn (p: *mut Point) translate(dx: i32, dy: i32) {
p.x = p.x + dx
p.y = p.y + dy
}
fn main() !u8 {
mut p = Point{ x: 3, y: 4 }
con before = p.lengthSquared() // 25 — value method: copy
p.translate(1, 1) // *mut method: auto-ref of mut place
con after = p.lengthSquared() // 41
con x = p.xCoord() // *con method: auto-ref (no copy)
mut pm: *mut Point = &p
con viaPtr = pm.lengthSquared() // value method on a pointer: load a copy
con o = Point.origin() // type receiver
stdout.writeInt(i64(before)) // prints 25
stdout.write(" ")
stdout.writeInt(i64(after)) // prints 41
stdout.write(" ")
stdout.writeInt(i64(x)) // prints 4
stdout.write(" ")
stdout.writeInt(i64(viaPtr)) // prints 41
stdout.write(" ")
stdout.writeInt(i64(o.x)) // prints 0
stdout.write("\n")
return Ok(0)
}You write p.translate(1, 1), not (&p).translate(1, 1) — and
pm.lengthSquared(), not (*pm).lengthSquared(). A *mut method is not
callable on a con value or a *con pointer (reported as “no method”, with a
note that it exists for *mut). Inside a pointer-receiver method, field access
dereferences for you: p.x, not (*p).x. Full adaptation table and ranking:
methods & dispatch.
Methods are scope, not type members
The receiver position is the key idea: methods are free-standing top-level declarations, not members nested inside the type. A type's definition lists its fields and nothing else. Which methods a value has is determined by what's in scope where you make the call — and that has two consequences you won't find in most languages.
First, anyone can declare a method on any type, including primitives:
import { stdout } from "@io"
fn (n: i32) doubled() i32 {
return n * 2
}
fn main() !u8 {
con a: i32 = 7
stdout.writeInt(i64(a.doubled())) // prints 14
stdout.write("\n")
return Ok(0)
}Second, methods travel with the type through imports. Exported receiver methods ride along when you import the type — you don't import them by name:
// file: shape.vyi
export struct Shape { pub sides: i32 }
export fn (s: Shape) area() i32 {
return s.sides * 10
}
// file: main.vyi
import { stdout } from "@io"
import { Shape } from "./shape"
fn main() !u8 {
con s = Shape{ sides: 4 }
stdout.writeInt(i64(s.area())) // area came along with Shape → prints 40
stdout.write("\n")
return Ok(0)
}A third-party module can import Shape, declare more methods on it, and re-export
it — importers of that module see the extended method set. When two declarations of
the same method are in scope, the closer one wins (your own file beats an import; a
re-exporting module's override beats the original). The precise rules live in the
methods & dispatch reference.
Overloading by signature
Several functions may share a name as long as their full signatures differ. The call site selects by argument shape — arity first:
import { stdout } from "@io"
fn pick(x: i32) i32 { return x + x }
fn pick(a: i32, b: i32) i32 { return a + b }
fn main() !u8 {
con doubled = pick(7) // one arg → first overload → 14
con summed = pick(10, 18) // two args → second overload → 28
stdout.writeInt(i64(doubled)) // prints 14
stdout.write(" ")
stdout.writeInt(i64(summed)) // prints 28
stdout.write("\n")
return Ok(0)
}or by parameter type at the same arity:
import { stdout } from "@io"
struct Celsius { deg: i32 }
struct Fahrenheit { deg: i32 }
fn freezing(c: Celsius) bool { return c.deg <= 0 }
fn freezing(f: Fahrenheit) bool { return f.deg <= 32 }
fn main() !u8 {
con c = Celsius{ deg: 3 }
con f = Fahrenheit{ deg: 3 }
mut r = 0
if freezing(c) { r = r + 1 } // Celsius overload: false
if freezing(f) { r = r + 1 } // Fahrenheit overload: true
stdout.writeInt(i64(r)) // prints 1
stdout.write("\n")
return Ok(0)
}The receiver is part of a method's signature too, so two types can each have their own
tag() and calls dispatch on the receiver's type. If no single overload is the most
specific match for a call, the compiler rejects the call as ambiguous rather than
guessing.
Default arguments
A parameter can carry a default with = expr. A call may omit trailing arguments that
have defaults; the default expression is filled in at the call site:
import { stdout } from "@io"
fn bump(n: i32, step: i32 = 1) i32 {
return n + step
}
fn main() !u8 {
con a = bump(10) // step defaults to 1 → 11
con b = bump(10, 5) // explicit → 15
stdout.writeInt(i64(a)) // prints 11
stdout.write(" ")
stdout.writeInt(i64(b)) // prints 15
stdout.write("\n")
return Ok(0)
}Arguments are positional, so only trailing omission works — a default buried before a required parameter can never be omitted:
fn f(a: i32 = 1, b: i32) i32 { return a + b }
fn main() !u8 {
con x = f(5) // error: "f" expects 2 argument(s), got 1
return Ok(0)
}_ inference
_ is Vyi's one inference placeholder: write it anywhere a type goes and the compiler
solves for it. In a return slot, it is inferred from the body's return statements:
fn inc(x: i32) _ { // return type inferred: i32
return x + 1
}In closures, _ parameters and returns are solved from the surrounding code — from
the expected fn(...) type of a binding or of the parameter you're passing the
closure to:
import { stdout } from "@io"
fn apply(g: fn(i32) i32, x: i32) i32 { return g(x) }
fn main() !u8 {
con inc: fn(i32) i32 = (x: _) _ => x + 1 // both solved from the annotation
con a = apply((y: _) i32 => y * 2, 5) // y solved from apply's parameter
stdout.writeInt(i64(a)) // prints 10
stdout.write(" ")
stdout.writeInt(i64(inc(10))) // prints 11
stdout.write("\n")
return Ok(0)
}The same _ shows up again in generics, where it solves type arguments (guide
11 — Known values and generics).
Closures and function values
Functions are first-class values. The type of a function value is spelled fn(SIG) —
parameter types in parentheses, then the return type — and a top-level fn's name is
already a value of that type:
import { stdout } from "@io"
fn double(n: i32) i32 { return n * 2 }
fn apply(f: fn(i32) i32, x: i32) i32 {
return f(x)
}
fn main() !u8 {
stdout.writeInt(i64(apply(double, 21))) // pass double itself → prints 42
stdout.write("\n")
return Ok(0)
}Arrow syntax
Inline function values use arrows only: a parameter list, an optional return
type, =>, then an expression or a block. The declaration form fn name(...) {…}
is not valid as an expression (not even unnamed fn() T {…}) — that form is only
a declaration at top level or nested as a statement. fn(SIG) remains the spelling
of a function type (aliases, annotations, first-class types).
con add = (a: i32, b: i32) i32 => a + b // expression body
con thunk = () i32 => 42 // zero parameters
con id = (x: i32) => x // return type inferred
con sumDouble = (a: i32, b: i32) i32 => { // block body
con s = a + b
return s * 2
}
con total = add(3, 4) + thunk() + id(10) + sumDouble(5, 5) // 79fn main() !u8 {
// error: function values use arrow syntax `() T => …`, not `fn … { }`
con x = fn() i32 { return 1 }
return Ok(0)
}fn main() !u8 {
// error: same — `fn name` is declaration form only, not a value expression
con x = fn foo() i32 { return 1 }
return Ok(0)
}An omitted return type is inferred from the body: an expression body's type, or for
a block body the block's trailing expression when it ends in one, otherwise its
return statements — so (x: i32) => { return x + 1 } is a fn(i32) i32.
An arrow bound with con and a declared fn produce the same kind of value; they
differ only in scoping — fn hoists, while a con binding is visible from its line
down like any other value.
Capture is by value
A closure that mentions an enclosing binding snapshots it — the captured value is copied into the closure when the closure is created:
import { stdout } from "@io"
fn main() !u8 {
con base = 10
con addBase = (x: i32) i32 => x + base // base is snapshotted here
stdout.writeInt(i64(addBase(5))) // prints 15
stdout.write("\n")
return Ok(0)
}Struct captures copy the whole value, so the closure's copy is independent of the
original. And because a snapshot of a variable that later changes would silently show
stale data, capturing a mut binding is a compile error:
fn main() !u8 {
mut n = 10
con get = () i32 => n // error: closures cannot capture a mutable binding
n = 99
return Ok(0)
}If a closure needs to see or make mutations, say so explicitly: capture a pointer.
Capturing pointers
A captured pointer must be stable — it must point at storage that outlives the closure. Pointers received as parameters qualify, which gives you shared mutable state on purpose rather than by accident:
import { stdout } from "@io"
fn bumper(slot: *mut i32) fn() i32 {
return () i32 => {
*slot = *slot + 1
return *slot
}
}
fn main() !u8 {
mut n = 0
con bump = bumper(&n)
con a = bump() // n → 1
con b = bump() // n → 2
stdout.writeInt(i64(a)) // prints 1
stdout.write(" ")
stdout.writeInt(i64(b)) // prints 2
stdout.write("\n")
return Ok(0)
}Capturing the address of the closure's own frame's local would dangle once that frame returns, so it is rejected:
fn broken() fn() i32 {
mut local = 0
con p = &local
return () i32 => *p // error: captures a non-stable pointer
}
fn main() !u8 {
con f = broken()
return Ok(0)
}Closures escape freely — and never allocate
Because capture is a by-value snapshot (or a stable pointer), a closure can be returned, stored, and passed around without a heap: the captured environment lives inline in the function value and moves with it.
import { stdout } from "@io"
fn adder(n: i32) fn(i32) i32 {
return (x: i32) i32 => x + n // n rides inside the returned value
}
fn main() !u8 {
con add5 = adder(5)
stdout.writeInt(i64(add5(3))) // prints 8
stdout.write("\n")
return Ok(0)
}Concretely, a function value is two things carried together: a handle to the
compiled code, and the captured environment — a witness of what was in scope where
the closure was written. Assigning, returning, or storing the value copies both
parts. A slot of type fn(SIG) can hold any function of that signature, so the
compiler sizes the slot to the largest environment any closure of that signature
captures anywhere in the program; a plain named function is the degenerate case with
an empty environment. Closures of different signatures never affect each other's
size.
Not every mentioned binding ends up in the environment, either: a capture that is
compile-time known — a type, or a known value — selects a specialized body instead
of occupying a runtime slot (guide
11 — Known values and generics).
A closure cannot capture its own signature
Sizing "the largest environment of any closure of this signature" has one impossible
case: a closure that captures a fn value of its own signature. Its environment
would contain an fn(SIG) slot, and that slot must be big enough to hold this very
closure's environment — the size has no answer, exactly like a struct containing
itself by value. The compiler rejects the capture:
fn wrap(prev: fn() i32) fn() i32 {
return () i32 => prev() + 1 // error: captures a fn value of its own signature
}
fn main() !u8 {
con base = () i32 => 1
con w = wrap(wrap(base))
return Ok(0)
}The check is transitive — two closures of different signatures that each capture a fn value reaching the other are rejected the same way. As with recursive structs, the fix is indirection: a pointer stops the by-value containment, so capture a stable pointer to the fn value and call through the dereference:
import { stdout } from "@io"
fn wrap(prev: *con fn() i32) fn() i32 {
return () i32 => (*prev)() + 1
}
fn main() !u8 {
con base: fn() i32 = () i32 => 1
con w1: fn() i32 = wrap(&base)
con w2 = wrap(&w1)
stdout.writeInt(i64(w2())) // prints 3
stdout.write("\n")
return Ok(0)
}That is the whole model: fn for named functions, a receiver slot for methods,
signatures for overloads, and values for everything else.
See also: reference/methods-dispatch.