Skip to content
CryoCryo home
LanguageFoundations

4Functions

4.1 Function Declarations

import std::fmt::{ printf };

function add(a: int, b: int) -> int {
    return a + b;
}

function greet(name: string) -> void {
    printf("Hello, %s!\n", name);
}

function main() -> int {
    greet("Cryo");
    return 0;
}

The return type follows the parameter list, separated by ->. If you omit it, the function returns void. Parameters take the form name: Type and may not be inferred.

Functions can be recursive, and there is no forward-declaration requirement: the compiler collects every function signature in a dedicated pass before type-checking any body, so call ordering in source is irrelevant.

4.2 Generic Functions

Type parameters appear in angle brackets after the name. They are available throughout the signature and body.

function identity<T>(x: T) -> T {
    return x;
}

function swap<T>(a: T*, b: T*) -> void {
    const temp: T = *a;
    *a = *b;
    *b = temp;
}

At the call site, supply the concrete type after :: - the turbofish:

const n: int    = identity::<int>(42);
const s: string = identity::<string>("hello");

Each call produces a fully specialised version of the function. See section 11.6 Monomorphisation.

In expression position < opens generic arguments only immediately after ::; anywhere else it is the comparison operator. So identity::<int>(42), Array::<int>::new(), Pair::<int, int> { first: 1, second: 2 }, obj.method::<T>(x) and Scope::method::<T>(x) name a specialization, while g(LIMIT < x, MAXV > (y)) is two comparisons whatever LIMIT and MAXV are - the same text never means two things by where its names were declared. Every :: after the arguments is an ordinary path step. Type position is unaffected: Array<int> in an annotation, a where bound or a static match arm, and new Array<int>(..), are written as before, because a < after a type name is never a comparison.

To require capabilities of T (such as the ability to compare it with <), use a where clause:

function smaller<T>(a: T, b: T) -> T
    where T: Ord {
    return if (a.compare(&b) == Ordering::Less) { a } else { b };
}

4.3 Variadic Functions

A trailing ... marks a function as variadic. The signature mirrors C's variadic calling convention, so variadic functions stay ABI-compatible with the printf-family at the FFI boundary.

// An FFI declaration: a bare `...` bucket, no body.
extern "C" {
    function printf(fmt: u8*, ...) -> i32;
}

A user-defined variadic function names the bucket (args...). The compiler emits va_start/va_end around the body and binds args to the raw va_list pointer. Wrap it in a VaArgs (std::core::varargs, in the prelude) to read typed values without hand-rolling va_arg:

function sum(count: i32, args...) -> i64 {
    mut va: VaArgs = VaArgs::new(args);
    mut total: i64 = 0;
    for (mut i: i32 = 0; i < count; i++) {
        const v: i64 = va.next();      // ![implicit]: T inferred from `v`'s type
        total += v;
    }
    return total;
}

va.next::<T>() is the explicit form; va.next() infers T from the expected type at the call site (see section 16 on ![implicit]). va.as_ptr() returns the raw va_list pointer for forwarding to a C v*printf callee - equivalently, pass the original args identifier.

Two limits are inherited from C varargs and no wrapper can remove them:

  • Not count-safe. Nothing records how many arguments were passed or their types; the callee must learn that out of band (a format string, a leading count, a sentinel).
  • Default argument promotions apply. A variadic call promotes i8/i16/boolean to i32 and f32 to f64. VaArg is therefore implemented only for the promoted scalar set (i32, u32, i64, u64, f64, string); va.next::<i8>() is a compile error - read it as i32 and narrow. Pass i64-typed values when reading with next::<i64>().

4.4 Extern Functions

extern declares a function whose body is provided by the linker, typically a C library symbol.

extern function exit(code: int) -> void;

extern "C" {
    function puts(s: string) -> int;
    function atoi(s: string) -> int;
}

See section 17 for full FFI semantics, including extern "C" { ... } blocks and the extern module c := "C" { #include <header.h> } form.

4.5 Intrinsic Functions

intrinsic function declares a function that the compiler lowers directly to LLVM IR rather than emitting a real call. The standard library uses intrinsics for primitives such as heap allocation, bit operations, atomics, float operations, and the panic mechanism.

intrinsic function malloc(size: u64) -> void*;
intrinsic function free(ptr: void*) -> void;
intrinsic function popcount64(value: u64) -> u32;
intrinsic function sqrt64(value: f64) -> f64;
intrinsic function panic(message: string, loc: Location) -> never;

printf, print, println, eprint and eprintln are not intrinsics - they are ordinary functions in std::fmt. C library functions such as memcpy and strlen are extern "C" declarations in std::ffi::libc.

The intrinsics module, std::core::intrinsics, is not in the prelude. Reach an intrinsic by path after importing the module (import std::core::intrinsics;, then intrinsics::malloc(64)), or import it by name (import std::core::intrinsics::{ malloc, free };). The complete list is the file stdlib/core/intrinsics.cryo. User code does not typically declare its own intrinsics; they are a contract between the standard library and the compiler.

A place in the source is the value of the intrinsic function location(), a Location (both declared in std::core::intrinsics). panic, assert and the testing framework use it to report where a failure happened without the caller passing the place by hand:

type struct Location {
    file:     string;   // the source file, as the build was given it
    line:     u32;      // from 1
    column:   u32;      // from 1
    function: string;   // the readable qualified name of the enclosing function
}

Each call answers the place it is written: the line and column of the call itself, and the function it is written in, named by its qualified path (App::Main::main, App::Main::Probe::own), never by its linker symbol. Two calls on one line answer two columns. Code the compiler moves into a function of its own is named for the function it is written in: a lambda's or a closure's body names the enclosing function, an async function's body the async function, and every specialization of a generic function or method the generic as written (App::Main::Holder::at).

import std::fmt;
import std::core::intrinsics;

function main() -> i32 {
    const here: intrinsics::Location = intrinsics::location();
    fmt::println(f"{here.file}:{here.line}:{here.column} in {here.function}");
    return 0;
}

Inside a function marked ![caller_location], location() is instead the place of the call to that function: every call passes the address of its own Location as a hidden trailing argument, and a call made from inside another ![caller_location] function passes on the one it was given, so a chain of them reports the first call outside the chain - its file, line, column and function. A lambda written in such a body is a function of its own and not ![caller_location]: a place made in it is its own. The standard library's index bounds check is marked this way, so an out-of-range xs[i] reports the line of the xs[i]. Such a function cannot be taken as a value (E0151), by its name, its path, or a static method's path: a call through the value would have no location to pass. The type check refuses it, so cryo check and the editor report it. The directive is refused on a variadic function or method (E0151): the hidden argument follows the written ones, and a variadic call's written arguments have no fixed end.

![caller_location]
function expect_positive(n: i64) -> i64 {
    if (n <= 0) {
        core::panic("expected a positive number", intrinsics::location());
    }
    return n;
}

function main() -> i32 {
    expect_positive(-1);   // panics reporting this line
    return 0;
}

panic takes the place as a Location, core::panic(message, intrinsics::location()), and a panic's report names all of it: panicked at <file>:<line>:<column> in <function>: <message>, here the file, line and column of expect_positive(-1) and the qualified name of main. The checks the compiler inserts (division by zero, a match with no arm for its value, an out-of-range index) report their place the same way. A place with no column leaves it out, and one with no function leaves out in ....

4.6 Overloading

A function name may be declared more than once with different parameter types. A call selects the one overload its arguments fit; there is no ranking among overloads that fit equally, and no implicit conversion makes an argument fit.

function conv(x: i32) -> i32 { return 32; }
function conv(x: i64) -> i32 { return 64; }
function conv(x: f64) -> i32 { return 2; }

const a: i32 = conv(1);          // conv(i32): an integer literal is i32 here
const b: i32 = conv(1 as i64);   // conv(i64)
const c: i32 = conv(1.5);        // conv(f64): a float literal is f64 here

const s: i16 = 1 as i16;
const d: i32 = conv(s);          // error[E0214]: no overload of `conv` accepts these arguments

An untyped literal argument takes one fixed type where the overloads differ in that parameter: an integer literal is i32 and a float literal is f64, the same defaults a literal takes anywhere else. Write the type you mean with as (conv(1 as i64)). A qualified call (Json::conv(1)) selects among that module's overloads the same way.

A non-generic overload does not win over a generic one by being non-generic. A call both accept is ambiguous (E0154); a generic function whose where clause the arguments fail does not accept them. A written type argument names the generic function:

function same<U>(u: U) -> i32 { return 1; }
function same(u: i32) -> i32 { return 2; }

const x: i32 = 7;
const a: i32 = same(x);          // error[E0154]: `same` is ambiguous
const b: i32 = same::<i32>(x);   // the generic function

An overloaded name used as a value without a call is picked by its expected function type (section 2.5).