Functions
Functions in Kairo follow a consistent declaration syntax for both free functions and class methods. The full
grammar covers visibility, ABI linkage, generics, modifiers, bounds, and return types all optional except
the fn keyword, the name, and the parameter list.
Declaration Syntax
fn name(param1: Type1, param2: Type2) -> ReturnType {
// body
}
The full grammar:
function_declaration ::= visibility? abi_mod? "fn" generics? identifier
"(" parameter_list? ")" function_mods? ("->" return_type)? constraint? body
visibility ::= "pub" | "priv" | "prot"
abi_mod ::= ("ffi" string_literal) | "static" | "virtual" | "override"
generics ::= "<" generic_param_list ">"
function_mods ::= ("const" | "volatile" | "unsafe" | "eval" | "async" | "final" | "panic" | "inline")*
constraint ::= "requires" expression
return_type ::= type | "!"
body ::= block | "=" expression | ε ; ε = forward declaration
All parts except fn, the name, and the parenthesized parameter list are optional. When the return type is
omitted, it defaults to void.
Parameters
Parameters are declared as name: Type. Each parameter requires an explicit type annotation there is no
parameter type inference.
fn greet(name: string, loud: bool) {
if loud {
std::println(f"HELLO, {name}!")
} else {
std::println(f"Hello, {name}.")
}
}
Default parameters
Parameters can have default values. When a caller omits a defaulted argument, the default is used:
fn greet(name: string = "world") {
std::println(f"Hello, {name}!")
}
greet() // "Hello, world!"
greet("Alice") // "Hello, Alice!"
Defaults are evaluated at the call site. Parameters with defaults must appear after non-defaulted parameters.
Named arguments
Arguments can be passed by name at the call site. Positional arguments must come before named arguments, matching C++ conventions:
fn create_user(name: string, age: i32 = 18, country: string = "USA") -> User {
return User { name, age, country }
}
create_user("Alice") // age=18, country="USA"
create_user("Bob", 25) // country="USA"
create_user(name: "Eve", country: "UK") // age=18
create_user("Grace", 22) // country="USA"
create_user(name: "Frank", age: 30, country: "CA") // all explicit
Parameter passing modes
A parameter can be declared in one of three modes. Together they cover every parameter form C++ can declare, so any C++ signature has an exact Kairo spelling.
| Kairo | C++ | Callee may | Caller afterwards |
|---|---|---|---|
x: T | T or const T& | read | unchanged |
@inout x: T | T& | read, write | sees the callee’s writes |
@move x: T | T&& | read, write, consume | cannot use the argument |
fn read(x: i32) -> i32 { return x + 1 } // T / const T&
fn bump(@inout x: i32) { x += 1 } // int&
fn consume(@move b: Buffer) -> usize { /* ... */ } // Buffer&&
fn use() {
var n: i32 = 5
read(n) // 6, n unchanged
bump(&n) // n is now 6
read(n) // 7
var b = Buffer{}
consume(b) // no marker; ownership moves
b.size() // compile error: use after move
}
Modes are not types
@inout and @move are only allowed on parameters of a fn declaration. They cannot appear on locals,
fields, return types, generic arguments, tuple elements, or in any other type position. No type @inout T
exists, and a mode cannot be stored or returned.
var y: @inout i32 // compile error: '@inout' is a parameter mode, not a type
var @inout y: i32 // compile error
How x: T is passed
For a plain x: T parameter of a Kairo function, the compiler picks between passing by value and passing by
const T&. Small trivially-copyable types go by value; everything else goes by reference. Callers can’t
observe the difference, since the callee can’t modify the argument either way. This is why Kairo has no
separate mode for const T&.
For a function declared in an imported C++ header, the compiler doesn’t choose. The parameter is passed exactly as the header declares it.
Call-site syntax
| Parameter | Call |
|---|---|
x: T | f(a) |
@inout x: T | f(&a) |
@move x: T | f(a) |
| receiver | a.f() |
In argument position, & marks an @inout argument. It is not the address-of operator and never produces
a *T. The marker is required, so every mutation of a caller’s variable is visible where the call is made,
and it is rejected anywhere else:
read(&n) // compile error: parameter 'x' of 'read' is not @inout
bump(n) // compile error: '@inout' argument requires '&'
const var k: i32 = 5
bump(&k) // compile error: '@inout' requires a non-const lvalue
bump(&10) // compile error: '@inout' requires an lvalue
@move takes no marker. The compiler already rejects any later use of the argument, so a marker would add
nothing.
The receiver is never marked. fn f(self) is a non-const method and fn f(const self) is a const method
(void f() const). Both are called as a.f():
class Counter {
priv var n: i32
fn get(const self) -> i32 { return self.n } // int get() const
fn tick(self) { self.n += 1 } // void tick()
}
var c = Counter{ n: 0 }
c.tick()
var v = c.get()
Overload resolution
Mode resolution follows C++ ([over.ics.rank]), so an imported overload set picks the same function in Kairo as it does in C++:
| Argument | x: T | @inout x: T | @move x: T |
|---|---|---|---|
non-const lvalue | viable | viable, preferred | not viable |
const lvalue | viable | not viable | not viable |
| rvalue (prvalue or xvalue) | viable | not viable | viable, preferred |
// std::vector<i32>::push_back(const int&) and push_back(int&&)
var v: std::vector<i32>
var n: i32 = 7
v.push_back(n) // lvalue -> push_back(const int&)
v.push_back(1) // prvalue -> push_back(int&&)
Overloading on mode
Modes are part of a function’s signature. x: T, @inout x: T, and @move x: T are three distinct
overloads. A C++ class that declares set(const int&), set(int&), and set(int&&) gets three separate
out-of-line definitions:
// cxx.hh:
// class Widget {
// void set(const int&);
// void set(int&);
// void set(int&&);
// int at(size_t) const;
// int& at(size_t);
// };
ffi "c++" import "cxx.hh"
fn Widget::set(x: i32) { } // const int&
fn Widget::set(@inout x: i32) { } // int&
fn Widget::set(@move x: i32) { } // int&&
fn Widget::at(const self, i: usize) -> i32 { /* ... */ } // int at(size_t) const
fn Widget::at(self, i: usize) -> *i32 { /* ... */ } // int& at(size_t)
The at pair shows the one exception to the const overloading restriction:
when an imported declaration has a const/non-const pair, as standard containers do for at, front,
back, begin, end, and data, receiver const-ness is part of the signature and both members can be
defined out of line.
C++ can’t overload f(T) against f(const T&), and in Kairo both are spelled x: T, so that ambiguity never
arises in Kairo code. If an imported header declares both, the compiler reports an error at the import,
naming the header, not at each call.
Overriding an imported virtual uses the same spelling:
// cxx.hh: class Base { virtual void foo(int& x); };
class Derived derives Base {
override fn foo(@inout x: i32) { x = 42 }
}
var d = Derived{}
var n: i32 = 0
d.foo(&n) // n is 42
Reference returns from C++
Modes don’t exist in return position. An imported function returning a reference is seen as returning a pointer, and the value category keeps the reference’s meaning:
| C++ return | Kairo type | Value category |
|---|---|---|
T& | *T | lvalue |
const T& | *const T | lvalue |
T&& | *T | xvalue |
Because a T&& return is an xvalue, f(g()) where g returns T&& selects f’s @move overload, as it
does in C++.
Unlike v[0], which dereferences implicitly because [] is a
place-returning operator, a named method returning T& yields a *T
that you dereference yourself.
@move is checked
@move is checked against the parameter’s type, not just recorded:
- Move-only class:
@moveis redundant, since passing already consumes the argument (see Ownership), but it’s allowed and checked. - Copy-only class:
@moveis a compile error. - Generic
T:@moveis the only way to declare that the function consumes its argument. The body can then consumexwhateverTturns out to be.
In every case the call counts as the consuming use, and any later use of the argument in the caller is a use-after-move error.
Imported-only forms
Some C++ forms can be imported and called from Kairo but can’t be declared in Kairo source, including as out-of-line definitions:
const T&¶meters. They bind rvalues without consuming them, and C++ uses them almost only as deletion targets.- Functions returning a reference. Kairo has no
std::move, because whether a transfer moves is decided by the type, not by the expression, so it never needs to return one.
Kairo also deliberately has no call-site marker for @move and no separate mode for const T&.
Return Types
Explicit return
fn add(a: i32, b: i32) -> i32 {
return a + b
}
Implicit void
When no return type is specified, the function returns void:
fn log(msg: string) {
std::println(msg)
}
fn log_explicit(msg: string) -> void { // equivalent
std::println(msg)
}
No-return !
Functions that never return they always panic, loop forever, or call a no-return function use ! as
their return type:
fn fatal(msg: string) -> ! {
std::println(f"Fatal: {msg}")
std::crash(1)
}
fn event_loop() -> ! {
loop {
process_events()
}
}
! is the no-return type. It is a subtype of every type, meaning it can appear anywhere a value is
expected:
var x: i32 = if valid { compute() } else { fatal("bad state") }
// fatal() returns ! which coerces to i32
No-return functions cannot have a panic specifier. Since panic acts as an alternative return path, it
contradicts the guarantee that the function never returns. The compiler rejects fn f() panic -> !.
Special return types
These return type modifiers interact with Kairo’s concurrency and type system. Each is covered in detail on its respective page:
| Return type | Description | Details |
|---|---|---|
yield T | Coroutine yields values of type T cooperatively | Concurrency |
atomic T | Atomic wrapper thread-safe operations | Concurrency |
thread T | Thread-local storage | Concurrency |
fn generate_numbers() -> yield i32 {
for i in 0..10 {
yield i // yield, not return function must have a yield return type
}
}
Functions with a yield return type cannot use return to produce values only yield. A bare return
(no operand) is permitted to terminate the coroutine early.
Expression-Bodied Functions
Single-expression functions can use the = shorthand, omitting braces and return:
fn add(a: i32, b: i32) -> i32 = a + b
fn square(x: f64) -> f64 = x * x
fn greeting(name: string) -> string = f"Hello, {name}!"
The return type annotation is optional for expression-bodied functions it is
inferred from the expression when omitted. This is the only form of return type
inference in Kairo; block-bodied functions always require an explicit return type
(or default to void).
fn add(a: i32, b: i32) = a + b // inferred as i32
fn greeting(name: string) = f"Hi, {name}!" // inferred as string
Explicit annotations are still useful when you want to constrain or widen the inferred type:
fn promote(x: i32) -> i64 = x as i64 // would otherwise infer i32
Function Return
return exits the current function with a value. For void functions, return takes no operand.
fn max(a: i32, b: i32) -> i32 {
if a > b {
return a
}
return b
}
fn log(msg: string) {
std::println(msg)
return // explicit return from void function valid but optional
}
Early returns are permitted anywhere in a function body. The compiler verifies that all code paths return a value of the declared return type.
Function Overloading
Functions can be overloaded by parameter types, matching C++ overload resolution rules:
fn add(a: i32, b: i32) -> i32 = a + b
fn add(a: f64, b: f64) -> f64 = a + b
fn add(a: string, b: string) -> string = a + b
Parameter modes also participate in overloading. See Overloading on mode.
Unsafe overloads
The unsafe modifier creates a separate overload in its own namespace. Safe and unsafe versions of the same
function coexist the caller explicitly selects which one to invoke:
fn add(a: i32, b: i32) -> i32 {
return a + b
}
fn add(a: i32, b: i32) unsafe -> i32 {
return a << 1 + b << 1 // faster but semantically different
}
var x = add(10, 20) // calls the safe version
var y = unsafe add(10, 20) // calls the unsafe overload
unsafe overloads are not “unsafe memory” AMT still guarantees memory safety. The unsafe qualifier
signals that the function may not uphold other invariants that the safe version does. See
Unsafe for the full unsafe model.
Const overloading restriction
const and non-const methods with the same name and parameter types cannot coexist, use distinct names like get() and get_mut(). This restriction applies to named methods declared in Kairo source. An imported C++ class may have such a pair, and both members can be defined out of line (see Overloading on mode). Operators are exempt, because they cannot be renamed: a place-returning operator may declare both a self overload (returning *T) and a const self overload (returning *const T), dispatched by receiver const-ness. See Operators.
class Foo {
fn bar(const self) -> i32 { return 42 }
fn bar(self) -> i32 { return 24 } // compile error: cannot overload const
fn bar(const self, a: i32) -> i32 { return a } // ok: different parameter list ok
fn op [](const self, index: i32) -> i32 { return 42 } // ok: operator overload
fn op [](self, index: i32) -> i32 { return 24 } // ok: const-ness dispatch
}
Variadic Functions
The ... prefix on a parameter name accepts an arbitrary number of arguments of the same type. The parameter
is accessible as a tuple inside the function body:
fn sum(...numbers: i32) -> i32 {
var total = 0
for num in numbers {
total += num
}
return total
}
sum(1, 2, 3) // 6
sum(10, 20, 30, 40) // 100
Generic variadic functions
Combine ... with generic type packs to accept arguments of different types:
fn <...T> print_all(...args: T) {
for arg in args {
std::println(arg as string)
}
}
print_all(42, "hello", true) // prints each on a new line
The parameter is a tuple of heterogeneous types. Each element in the pack must satisfy the constraints used
in the function body in the example above, every T must be convertible to string via as.
Pack expansion and forwarding
A pack expands with a postfix ... in expression position — declaration is prefix (...args),
use is postfix (args...). The most common case is forwarding a pack to another call:
fn <...T> log_all(...args: T) {
print_all(args...) // forwards every element as a separate argument
}
The same postfix form appears in type position for pack-typed signatures. The split mirrors the
token-macro splat: ... before a name declares a pack, ... after an expression expands one.
Generic Functions
Generic functions declare type parameters in angle brackets before the function name:
fn <T> identity(x: T) -> T {
return x
}
Constrain type parameters with impl (interface conformance) or derives (class inheritance):
fn <T impl Comparable> max(a: T, b: T) -> T {
return if a > b { a } else { b }
}
fn <T derives Serializable> serialize(value: T) -> [byte] {
return value.to_bytes()
}
impl checks structural conformance the type satisfies the interface’s required method signatures
without needing an explicit impl declaration. derives checks polymorphic inheritance the type is a
subclass of the specified class. See Interfaces and
Requires Clauses for details.
Requires Clauses
For constraints beyond type parameter bounds, attach a requires clause after the return type:
fn <T impl ToString> print_value(value: T) requires sizeof T <= 64 {
std::println(value as string)
}
requires is a compile-time gate: the condition must hold at compile time or the program does not
compile — no runtime fallback, no dispatch. See Requires Clauses for the
full constraint system and Where Clauses for runtime-conditional overload
dispatch.
Function Modifiers
Modifiers appear after the parameter list and before the return type arrow. Multiple modifiers can be combined, subject to the compatibility rules below.
fn compute(x: i32) inline -> i32 { return x * x }
fn dangerous() unsafe -> void { /* ... */ }
fn compile_time() eval -> i32 { return 42 }
fn may_fail() panic -> i32 { /* ... */ }
fn background() async -> Data { /* ... */ }
Modifier reference
| Modifier | Free functions | Methods | Description |
|---|---|---|---|
const | Yes | Method does not modify self. See Variables | |
volatile | Yes | Yes | Prevents certain compiler optimizations; for hardware interaction |
unsafe | Yes | Yes | Separate overload namespace for alternative implementations |
eval | Yes | Yes | Must be evaluable at compile time. See Eval |
async | Yes | Yes | Asynchronous execution. See Concurrency |
panic | Yes | Yes | May panic; callers must handle. See Panic |
inline | Yes | Yes | Hint to inline at call sites |
final | Yes | Prevents override in subclasses. See Classes |
Modifier compatibility
Not all modifiers can be combined:
| Combination | Valid | Reason |
|---|---|---|
const + volatile | ok: | |
const + unsafe | unsafe: implies a separate overload with different invariants | |
const + eval | eval: implies compile-time evaluation const self is meaningless | |
const + async | ok: | |
unsafe + any other | ok: | unsafe: creates a separate overload the other modifiers apply to both versions as appropriate |
eval + unsafe | ok: | eval and unsafe are orthogonal modifiers |
eval + any other (except unsafe) | eval implies compile-time evaluation incompatible with runtime modifiers | |
async + const | ok: | |
async + volatile | ok: |
Visibility
| Keyword | Scope |
|---|---|
pub | Accessible from any module |
priv | Accessible only within the defining module (default) |
prot | Accessible within the defining module and by subclasses in other modules |
pub fn public_api() { /* ... */ }
priv fn internal_helper() { /* ... */ }
prot fn for_subclasses() { /* ... */ }
Visibility applies to both free functions and methods. See Modules for how visibility interacts with imports.
ABI and Linkage
ABI modifiers control name mangling, dispatch mechanism, and symbol visibility at the object code level.
| Modifier | Description |
|---|---|
ffi "c" | C linkage no name mangling. See C/C++ Interop |
ffi "c++" | C++ linkage Itanium or MSVC mangling. See C/C++ Interop |
static | Internal linkage; no vtable dispatch. Cannot be virtual or override |
virtual | Dynamic dispatch via vtable. See Classes |
override | Overrides a virtual method from a base class; implies virtual |
static, virtual, and override are mutually exclusive with each other. ffi can be combined with any
of them.
class Shape {
virtual fn area(const self) -> f64 { return 0.0 }
}
class Circle : Shape {
var radius: f64
override fn area(const self) -> f64 {
return 3.14159 * self.radius * self.radius
}
}
class Math {
static fn sqrt(x: f64) -> f64 { /* ... */ }
}
Math::sqrt(16.0) // called without an instance
Function Pointers
Functions are first-class values. The type of a function pointer is fn(ParamTypes) -> ReturnType:
fn add(a: i32, b: i32) -> i32 = a + b
fn sub(a: i32, b: i32) -> i32 = a - b
var op: fn(i32, i32) -> i32 = add
op(3, 4) // 7
op = sub
op(10, 3) // 7
Functions can be nested inner functions are scoped to the enclosing function:
fn outer(x: i32) -> i32 {
fn inner(y: i32) -> i32 = y * 2
return inner(x) + 1
}
Closures
Anonymous functions (lambdas) capture variables from the enclosing scope. Default capture is by copy; use
|&| for capture-by-reference or specify per-variable:
var multiplier = 3
var scale = fn (x: i32) -> i32 { return x * multiplier } // captures multiplier by copy
scale(10) // 30
See Closures for capture modes (|&|, |a, &b|), lifetime rules, and how
closures interact with AMT.
Operator Functions
Operators are overloaded with the fn op syntax:
class Vec2 {
var x: f64
var y: f64
fn op +(self, other: Vec2) -> Vec2 {
return Vec2 { x: self.x + other.x, y: self.y + other.y }
}
}
See Operators for the full list of overloadable operators,
special operator syntax (l++/r++, op as, op in, op delete), and restrictions.
Forward Declarations
A function can be declared without a body a signature followed by no block:
fn parse_expression(tokens: [Token]) -> Expr
fn parse_statement(tokens: [Token]) -> Stmt
Within a single module, forward declarations are rarely needed. Kairo hoists all top-level declarations before type checking, so mutual recursion works without them:
fn is_even(n: u32) -> bool = if n == 0 { true } else { is_odd(n - 1) }
fn is_odd(n: u32) -> bool = if n == 0 { false } else { is_even(n - 1) }
Forward declarations are used when the definition lives elsewhere:
-
FFI imports the body is provided by a C or C++ library. See C/C++ Interop.
-
Separate translation units the declaration is visible to callers; the definition is linked in from another
.krofile. -
Out-of-line class methods declared in the class body, defined outside it using the
Class::methodqualified-name syntax:class Parser { var pos: usize fn advance(self) -> Token fn peek(const self) -> Token } fn Parser::advance(self) -> Token { var t = self.tokens[self.pos] self.pos += 1 return t } fn Parser::peek(const self) -> Token = self.tokens[self.pos]See Classes for the full rules.
Signature matching
When a definition follows a forward declaration, the two must match exactly:
- Parameter types, return type, and all function modifiers (
const,unsafe,panic,eval,async,inline,final,volatile) - Visibility (
pub,priv,prot) - ABI linkage (
ffi "c",ffi "c++",static,virtual,override)
Parameter names and default values may differ the declaration’s names and defaults are used at call sites that see only the declaration; the definition’s are used everywhere else. For consistency, keep them the same.
A mismatch in any other element is a compile error.
Summary
// Basic function
fn add(a: i32, b: i32) -> i32 = a + b
// Default parameters + named arguments
fn connect(host: string = "localhost", port: i32 = 8080) { /* ... */ }
connect(port: 9090)
// Parameter modes
fn append(@inout s: string, t: string) { s += t }
append(&name, "!")
fn sink(@move s: string) { /* ... */ }
// Generic with bounds
fn <T impl Printable> show(value: T) { std::println(value as string) }
// Variadic
fn <...T> log(...args: T) { /* ... */ }
// Overloaded
fn process(x: i32) -> i32 { /* ... */ }
fn process(x: string) -> string { /* ... */ }
// Unsafe overload
fn process(x: i32) unsafe -> i32 { /* ... */ }
// Method with modifiers
class Server {
pub fn start(self) async panic { /* ... */ }
pub fn status(const self) -> string { /* ... */ }
pub static fn default_port() -> i32 = 8080
}
// Function pointer
var handler: fn(Request) -> Response = handle_request
// No-return
fn abort() -> ! { std::crash(1) }