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:

vyi
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):

vyi
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:

vyi
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).
vyi
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:

vyi
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:

vyi
// 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:

vyi
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:

vyi
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:

vyi
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:

vyi-error
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:

vyi
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:

vyi
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:

vyi
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).

vyi
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)   // 79
vyi-error
fn main() !u8 {
    // error: function values use arrow syntax `() T => …`, not `fn … { }`
    con x = fn() i32 { return 1 }
    return Ok(0)
}
vyi-error
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:

vyi
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:

vyi-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:

vyi
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:

vyi-error
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.

vyi
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:

vyi-error
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:

vyi
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.