Skip to content

Functions and parameters

Declare Prismio 0.1 functions, return values, overloads, and borrow, sink, or inout parameters.

Last verified

A function has a name, zero or more typed parameters, an optional return type, and a block body. Functions are top-level declarations; Prismio 0.1 does not nest named functions inside blocks.

prismio
fn add(left: Int, right: Int) -> Int {
    return left + right
}

The general shape is:

text
fn name([mode] parameter: Type, ...) [-> Type] {
    statements
}

Parameter types are required. The compiler does not infer a public signature from call sites. A trailing -> Type declares a returned value; omitting it declares a function that returns no value.

Calling a function

A call evaluates its arguments and transfers or borrows them according to the selected function signature.

prismio
import std.io

fn clamp_low(value: Int, minimum: Int) -> Int {
    if (value < minimum) { return minimum }
    return value
}

fn announce(value: Int) {
    println(value)
}

fn main() -> Int {
    let result = clamp_low(-3, 0)
    announce(result)
    return result
}

The number of arguments and their exact types must match an available declaration. There are no named arguments, default arguments, or automatic numeric conversions.

Return rules

Omitting -> Type declares a no-value-returning function. Non-void functions must return on every reachable path; a path may instead end in panic, unreachable or exit, which never return. Code after an unconditional return, or after one of those three, is rejected as unreachable.

prismio
fn log_ready() {
    println("ready")
    return
}

For a value-returning function, return expression must match the declared return type. A function body does not use its final expression as an implicit return, and the error for one that ends in an expression points at it and says to write return. A closure is the one place a final expression is the value.

prismio
fn classify(value: Int) -> Int {
    if (value > 0) {
        return 1
    }
}

fn main() -> Int { return classify(1) }

The compiler rejects classify because the path where value <= 0 reaches the end without returning an Int. An infinite loop or a return on every branch can satisfy definite-return analysis.

An unconditional return, break, or continue also ends the reachable statements in its local control-flow position. The compiler reports statements that can never execute rather than silently emitting them.

Parameter modes

  • value: T copies scalar/copy values and borrows a move-only value by default.
  • sink value: T consumes it.
  • inout value: T takes a mutable borrow.
prismio
struct Counter { value: Int }

fn read(counter: Counter) -> Int { return counter.value }
fn increment(inout counter: Counter) { counter.value = counter.value + 1 }
fn finish(sink counter: Counter) -> Int { return counter.value }

The caller writes no separate borrow operator. The callee's signature determines the mode.

Default parameters borrow move-only data

An ordinary parameter does not consume a string, list, or struct. It creates a callee-visible borrow for the duration of the call. The caller retains ownership and may use the value afterward.

prismio
struct Message { code: Int }

fn inspect(message: Message) -> Int {
    return message.code
}

fn main() -> Int {
    let message = Message { code: 7 }
    let code = inspect(message)
    return message.code - code
}

The callee cannot drop or move a borrowed parameter into an owned location. Make consumption explicit with sink when the callee needs ownership.

sink transfers ownership

sink documents a consuming API at the function boundary. Passing a move-only value marks the caller's binding as moved.

prismio
struct Message { code: Int }

fn consume(sink message: Message) -> Int {
    return message.code
}

fn main() -> Int {
    let message = Message { code: 7 }
    consume(message)
    return message.code
}

The last field access is a use after move. sink on a copy type is accepted as a signature mode where supported, but the observable value behavior remains copy-like; it is primarily meaningful for move-only values.

inout permits caller-visible mutation

inout forms an exclusive mutable borrow. It does not move the value, and changes made through the parameter are visible after the call. The argument has to be something the caller may change — a let mut binding or its own inout parameter — and an inout parameter may be a struct, a Vec, an array or a slice. An ordinary parameter is read-only: pushing to a Vec parameter or storing into an array parameter needs inout.

prismio
struct Counter { value: Int }

fn increment(inout counter: Counter) {
    counter.value = counter.value + 1
}

fn main() -> Int {
    let mut counter = Counter { value: 4 }
    increment(counter)
    return counter.value - 5
}

There is no &, &mut, or address-of spelling at the call site. The selected parameter declaration provides the borrowing contract.

Recursion

Functions may call themselves and other declared functions. Recursive code must still satisfy ordinary return and ownership rules.

prismio
fn factorial(value: Int) -> Int {
    if (value <= 1) { return 1 }
    return value * factorial(value - 1)
}

fn main() -> Int {
    return factorial(5) - 120
}

Tail-call optimization is not a source-language guarantee. Bound recursion explicitly when stack usage matters.

Overloading

Functions may share a name when their parameter count or exact parameter types differ. Resolution does not use implicit numeric coercions, and return type alone cannot distinguish overloads. Default arguments, named arguments and expression bodies are not implemented; variadic parameters are. Functions may be generic.

prismio
fn describe(value: Int) -> Int { return value }
fn describe(value: U64) -> U64 { return value }
fn describe(left: Int, right: Int) -> Int { return left + right }

At a call site, the compiler filters by arity and exact parameter types. If no candidate matches, it reports the mismatch. If more than one candidate is equally applicable, it reports ambiguity rather than choosing from the return context.

prismio
fn measure(value: Int) -> Int { return value }

fn main() -> Int {
    let value: U8 = 2
    return measure(value)
}

Write measure(value as Int) to select the Int overload deliberately.

Cold functions

Some code runs rarely: an error report, a table that has to grow, the first insertion of a key. Keeping it in its own function is only half the job. When a function has a single caller, the optimiser folds it back into that caller whatever its size -- and the caller, now large, stops being inlined into the loop that calls it. The fast path pays for the slow one.

Mark such a function cold:

prismio
import std.io

cold fn reportOverflow(value: Int) -> Int {
    println("value out of range")
    return 0
}

fn clamp(value: Int) -> Int {
    if (value > 1000) { return reportOverflow(value) }
    return value
}

fn main() -> Int {
    let mut total = 0
    for i in 0..<100 {
        total = total + clamp(i * 3)
    }
    println(total)
    return 0
}

A cold function is never inlined into its callers, and a branch that calls one is laid out as the unlikely path, so the code around it stays small enough to be inlined and vectorised where it is used. It is LLVM's cold and noinline together -- Rust's #[cold] with #[inline(never)].

cold goes after any visibility marker and directly before fn, at top level or inside an impl block:

prismio
private cold fn grow(list: Vec<Int>) { ... }
impl Table {
    cold fn rehash(self) { ... }
}

It is contextual, so a variable or function may still be named cold. It changes where code is placed, never what it does: removing every cold leaves a program with the same behaviour.

Use it for paths that are rare at run time, not merely long. The standard library marks the insert half of Map.set; marking a hot function cold moves its callers' fast path the wrong way.

Inline functions

cold keeps a function out of its callers. inline is the opposite: the function is folded into every caller, whatever its size.

The optimiser normally decides this from a cost model, and the model is usually right. It is wrong when a function is small in what it does but large in what it says: a loop whose body grew a check, so that it no longer fits the budget although its caller runs it millions of times and wants the body in view. Mark that function inline:

prismio
inline fn square(value: Int) -> Int {
    return value * value
}

fn sumOfSquares(count: Int) -> Int {
    let mut total = 0
    for i in 0..<count {
        total = total + square(i)
    }
    return total
}

fn main() -> Int {
    return sumOfSquares(10)
}

It is LLVM's alwaysinline -- Rust's #[inline(always)]. Like cold, it goes after any visibility marker and directly before fn, at top level or inside an impl block, and it combines with the other function modifiers in either order (export inline fn):

prismio
private inline fn probe(slots: Vec<Int>, at: Int) -> Int { ... }
impl Table {
    inline fn slotOf(self, key: Int) -> Int { ... }
}

It is contextual, so a variable or function may still be named inline, and it changes where code is placed, never what it does. A generic function keeps the marker in every instantiation. A build with debug information (-g) ignores it, so that a function can be stepped into.

cold and inline contradict each other, so a function cannot carry both:

text
error[P3201]: a function cannot be both `cold` and `inline`

Use it sparingly. Inlining a large function into many callers grows the program, and the compiler's own choice is better than a guess for most code. The standard library marks the probe of Map, which every lookup and update runs in a loop.

Entry point

An executable uses a top-level main function, and it can be written two ways: fn main() and fn main() -> Int. The returned integer becomes the process exit status. A main with no return type exits with status 0: reaching the closing brace is return 0, and so is a bare return. Writing -> Int says the status is yours, so that main is held to the rule every -> Int function has: each path must return a number, and falling off the end or a bare return is an error. Any other return type is refused (main returns an Int, the exit status, or nothing). The 0.1 compiler does not document command-line argument parameters on main; obtain environment or argument data through supported runtime or FFI functions instead of copying signatures from C, Rust, or older drafts.

Nested functions

A fn may be written inside another function's body. It is an ordinary function that sits where it is used: private, visible in the block that declares it, callable before its declaration and from itself, and it captures nothing. A name from the enclosing function is unknown inside it, as it would be anywhere else; use a closure to capture.

prismio
import std.io

fn total(n: Int) -> Int {
    fn square(x: Int) -> Int { return x * x }
    fn sumTo(k: Int) -> Int {
        if k <= 0 { return 0 }
        return square(k) + sumTo(k - 1)
    }
    return sumTo(n)
}

fn main() -> Int {
    println(total(3))     // 14
    return 0
}

Two functions may each declare a helper, a method may declare one, and a nested function may declare its own. The compiler names each outer$inner, so none can collide with a name a program wrote.

Not implemented

Named functions cannot currently define optional/default arguments or use expression bodies, and take any number of arguments only through a pack. Functions may declare type parameters with one or more trait bounds. Closures provide anonymous capturing callables, and method-call syntax plus impl blocks provide type-oriented operations; each has its own documented constraints.