Classes

Classes

Classes are Kairo's primary mechanism for encapsulating state and behavior. The object model layout, vtables, ABI follows the platform's C++ convention. The surface syntax is cleaner, self is always explicit, and the copy/move model is expressed through attributes rather than reference qualifiers.

In this section


Declaration

class Foo {
    var x: i32
    var y: f64

    fn Foo(self, x: i32, y: f64) {
        self.x = x
        self.y = y
    }

    fn sum(self) -> f64 {
        return self.x as f64 + self.y
    }
}

var obj = Foo(10, 3.14)
obj.sum()   // 13.14

Members are declared with var, const, static, eval, or thread inside the class body. Methods are declared with fn and take self as the first parameter for instance methods. Omitting self requires the static modifier. If self is present, it is an instance method; if not, it must be static.


self and Self

self is the instance parameter. It behaves like a reference to the current object use self.member to access fields and self to pass the object to other functions. self is not a pointer; you cannot perform pointer arithmetic on it or reassign it.

Members always need a qualifier

Unlike C++, Kairo has no implicit this->. Inside a function body a member of the enclosing type is not in unqualified scope: an instance member is reached through self, a static member through Self:: (or the type's own name). A bare name is a local, a parameter, or something from an enclosing scope, and never a member:

class Counter {
    var hits: i32
    static total: i32 = 0

    fn Counter(self) { self.hits = 0 }

    fn bump(self) {
        self.hits += 1        // ok
        Self::total += 1      // ok (`Counter::total` works too)
        // hits += 1          // error: use of member 'hits' requires an explicit qualifier
        // total += 1         // error: use of static member 'total' requires an explicit qualifier
    }
}

The one exception is a name that denotes a scope rather than a value nested types, type aliases, and nested modules. Naming a type is not an access through an object, so a qualifier is not required. Prefer Self::Node anyway: a bare nested type, type alias or associated type of the enclosing type compiles, but the compiler warns and offers a fix-it that inserts Self::, because nothing at the use says the name is a member rather than something from the module:

class Tree {
    struct Node { var value: i32 }
    type Depth = i32

    fn root(self) -> Self::Depth {
        var n = Self::Node { value: 0 }   // preferred
        var m = Node { value: 1 }         // ok, but warns: write `Self::Node`
        var d: Depth = 0                  // ok, but warns: alias, same rule
        return d
    }
}

The warning applies anywhere in the body, a field type or method signature included. It names the innermost type only: inside Node, a bare Depth (a member of Tree, not of Node) and Node itself are not warned, since Self:: there would mean Node::.

The rule does not depend on where a method is written. It is identical in a class, struct, union or interface body, in an extend block, in an out-of-line fn Type::method definition, and inside a closure in any of those.

Self (capitalized) is a type alias for the enclosing class. In a generic class class <T> Foo, Self resolves to Foo<T>. Use Self in parameter types, return types, and anywhere you need the class's own type without spelling out generic arguments:

class <T> Container {
    var items: [T]

    fn Container(self) {
        self.items = []
    }

    fn merge(self, other: Self) -> Self {
        var result = Container<T>()
        for item in self.items {
            result.items.push(item)
        }
        for item in other.items {
            result.items.push(item)
        }
        return result
    }
}

Self always refers to a reference to the class type. For a pointer, use *Self or *ClassName. The bare class name works anywhere Self does Self is syntactic sugar, not a distinct type.


Visibility

Visibility modifiers control access to members from outside the class:

KeywordScope
pubAccessible from any module
privAccessible within the defining class, and anywhere in the file that declares it, free functions included
protWhat priv allows, plus every class that derives from the defining class, in any module

Modifiers are applied per-declaration. There are no visibility blocks (public: sections).

Kairo checks visibility itself: using a priv member from another file, or a prot one outside a subclass, is an error that offers to make the member pub. Operators are members like any other: a priv fn op + is usable only where a priv method would be. A base's members reached through a derives priv (or derives prot) edge are private (protected) to the deriving class and its file.

A subclass in another file uses a prot member the way C++ allows a protected one:

  • an instance field or method only through an object of the subclass's own type, or of a type deriving from it (self.hits, other_counter.hits), not through a *Base or a sibling subclass;
  • a prot constructor only as the base initializer Base::Base(self, ...) at the start of its own constructor, not to build a separate Base.

prot statics and nested types have no such restriction.

The C++ that Kairo generates keeps these levels: a priv member is private: and a prot one protected:, and the class names its file's functions and types as friends. A C++ program that includes the module sees the members as Kairo has them (see What crosses an include).

Default visibility

Member kindDefault
Instance / static / const / eval / thread variablespriv
Methods, constructors, destructorspub
Operator overloadspub
Static methodspub
Nested typespub
class Account {
    var balance: f64                  // priv by default
    pub var owner: string             // explicitly public

    fn Account(self, owner: string) { // pub by default
        self.owner = owner
        self.balance = 0.0
    }

    pub fn deposit(self, amount: f64) {
        self.balance += amount
    }

    priv fn audit_log(self) {
        // internal only
    }
}

Visibility also applies at the top level a priv class is only accessible within its file, a prot class within its module and subclasses. See Modules for how top-level visibility interacts with imports.

Note

Kairo has no friend keyword. If external code needs access to internals, expose it through a public method or adjust module-level visibility. priv at the top level restricts access to the current file, which covers most factory and serialization patterns.


Constructors

Constructors use the class name as the function name and take self as the first parameter:

class Point {
    var x: f64
    var y: f64

    fn Point(self, x: f64, y: f64) {
        self.x = x
        self.y = y
    }
}

var p = Point(1.0, 2.0)

Constructors support all the features of regular functions default parameters, named arguments, overloading by parameter types, generic type parameters. See Functions.

Const members in constructors

Class-level const members can be initialized in the constructor body. They get exactly one assignment; after construction, they are frozen:

class Config {
    const MAX_RETRIES: i32
    var timeout: f64

    fn Config(self, retries: i32, timeout: f64) {
        self.MAX_RETRIES = retries   // one-shot assignment, legal
        self.timeout = timeout
    }
}

var cfg = Config(3, 30.0)
// cfg.MAX_RETRIES = 5   // compile error: MAX_RETRIES is const

If a const member has an initializer at the declaration site, it cannot be reassigned in the constructor.

Fields without a default

A field with no initializer whose type has no default value (bool, an enum with payloads, a class with no default constructor, or an array of any of these; see Variables) must be initialized by every constructor, like a const member. The constructor's first write of the whole field is its initialization; later writes are ordinary assignments:

enum Frame {
    Burst { base: i32, count: i32 },
    Idle,
}

class Batch {
    var frames: [Frame; 3]
    var ready: bool

    fn Batch(self, base: i32) {
        self.frames = [.Burst { base: base, count: 3 }, .Idle, .Idle]  // initialization
        self.ready = false
        self.log()                                                      // the object exists from here
        self.frames[2] = .Burst { base: 1, count: 1 }                  // ordinary assignment
    }

    fn log(self) { }
}

Until every such field is initialized, the object does not exist yet, so the constructor can use self only to initialize fields and to read the ones already initialized: no method call, no return. A field the constructor leaves uninitialized on some path is an error at the constructor.

An array field can also be built element by element: writing every element once, self.frames[k] = ... for each k from 0 to N - 1 with a constant index, initializes it. The writes may come in any order and be spread over branches, as long as every path writes every element exactly once, outside any loop, before the array (or self) is read. Each element is built where its write is, so the right-hand sides run in source order with the statements around them. From the last element on, the array exists and further element writes are ordinary assignments:

class Pair {
    var frames: [Frame; 2]

    fn Pair(self, base: i32) {
        self.frames[1] = .Idle                              // builds element 1
        self.frames[0] = .Burst { base: base, count: 2 }    // builds element 0: frames exists from here
        self.frames[1] = .Burst { base: 1, count: 1 }      // ordinary assignment
    }
}

Leaving an element out on some path, writing one twice, an index that is not a constant, a write inside a loop, or reading the array before its last element is an error naming the elements still missing or the offending write. Only whole elements count: a write into an element (self.toks[0].n = 1) needs the element to exist already, and an array of arrays cannot be built element by element; assign those whole.

Giving the field an initializer at its declaration (var frames: [Frame; 3] = [.Idle, .Idle, .Idle]) lifts the requirement.

An array is not assignable, so an array field's one whole write in a constructor is its initialization: it must happen on every path through the constructor or on none, and never in a loop. A second whole write is an error; write the elements instead.

Default and deleted constructors

class Defaults {
    var x: i32

    fn Defaults(self) = default    // compiler-generated default constructor
}

class NoDefault {
    var x: i32

    fn NoDefault(self) = delete    // no default construction allowed
}

var a = Defaults()     // ok: x is zero-initialized
var b = NoDefault()    // compile error: default constructor is deleted

See Variables for default initialization rules.


Destructors are covered in Destructors - Delete Operators.

Lifecycle categories are covered in Lifecycle Categories.

Inheritance and virtual dispatch are covered in Inheritance and Virtual Dispatch.


Interfaces

A class can declare interface conformance with impl. The compiler verifies at declaration time that the class satisfies all interface requirements:

interface Hashable {
    fn hash(const self) -> u64
}

class UserId impl Hashable {
    var id: u64

    fn UserId(self, id: u64) {
        self.id = id
    }

    fn hash(const self) -> u64 {
        return self.id
    }
}

Interface conformance is structural a class that has the required methods satisfies the interface whether or not impl is declared. The impl keyword triggers the check at the declaration site rather than at the point of use.

class Point {
    var x: f64
    var y: f64

    fn hash(const self) -> u64 { ... }
}

fn <T impl Hashable> insert(set: {T}, item: T) { ... }

insert(my_set, Point(1.0, 2.0))   // compiles: Point has hash()

See Interfaces and Requires Clauses.


Generic Classes

Type parameters are declared in angle brackets before the class name:

struct StackUnderflow {
    fn op panic (const self) -> string { return "stack underflow" }
}

class <T> Stack {
    var items: [T]

    fn Stack(self) {
        self.items = []
    }

    fn push(self, item: T) {
        self.items.push(item)
    }

    fn pop(self) panic -> T {
        if self.items.length() == 0 {
            panic StackUnderflow {}
        }
        return self.items.pop()
    }
}

var s = Stack<i32>()
s.push(42)
Note

A panic method of a generic class, like pop here, is not supported yet (SC116E): a generic body's panic set will be inferred per instantiation. See Status.

Generic classes can inherit from other generic classes:

class <T> Base {
    var data: T
}

class <T> Derived derives Base<T> {
    var extra: i32
}

Type parameter constraints are covered in Generics.


Static Members

Static members belong to the class, not to any instance. They are declared with static and accessed via ClassName::member:

class Counter {
    static count: i32 = 0

    fn Counter(self) {
        Counter::count += 1
    }

    static fn get_count() -> i32 {
        return Counter::count
    }
}

var a = Counter()
var b = Counter()
Counter::get_count()   // 2

Static variables are declared static name: Type, with no var, and require an explicit type annotation. They can be initialized at the declaration site or at program startup. Static methods do not take self and cannot access instance members.


Const Methods

A method that takes const self promises not to modify the object (except mutable members). It is the "can I change the pointee?" question asked about the receiver. Only const methods can be called through a *const T pointer or on a const binding:

class Sensor {
    var reading: f64
    mutable var read_count: i32

    fn value(const self) -> f64 {
        self.read_count += 1   // ok: mutable member
        return self.reading
    }

    fn calibrate(self, offset: f64) {
        self.reading += offset
    }
}

const sensor: *const Sensor = &some_sensor
sensor->value()        // ok: const method
// sensor->calibrate(1.0)  // compile error: not const

const and non-const methods with the same name and parameter types cannot coexist use distinct names like get() and get_mut(). See Variables.

Mutable members

The mutable qualifier allows a member to be modified even through a const reference or in a const method:

class Cache {
    var data: [i32]
    mutable var hit_count: i32

    fn Cache(self) {
        self.data = []
        self.hit_count = 0
    }

    fn lookup(const self, index: i32) -> i32 {
        self.hit_count += 1   // ok: mutable
        return self.data[index]
    }
}

mutable is only valid on instance variables inside class and struct bodies. It cannot appear on top-level variables, local variables, or const/eval/static declarations.

Warning

mutable breaks the semantic guarantee that const methods do not modify the object. Use it sparingly caching, reference counting, and lazy initialization are the canonical use cases. If you find yourself marking many members mutable, reconsider the const boundary.


inline Methods

inline puts the body in the interface: importers compile it themselves. Use it for small functions on hot paths, and for functions the C++ compiler calls on your behalf (coroutine promises and awaiters).

pub class Gate {
    pub var open: bool
    pub inline fn is_open(self) -> bool { return self.open }
}

Every module that uses Gate compiles is_open itself, so a call to it can be inlined there. Every inline method of a class goes with the class, whether or not Kairo code calls it, because the C++ compiler may call methods Kairo never sees (await_ready, initial_suspend, ...). The class definition itself is unchanged: the bodies are emitted after it, outside it. See Functions for the rules.

inline is allowed on virtual and override methods. If every virtual method of a class is inline, the class has no key function, so its vtable is emitted in every object that uses the class. That is correct; it costs only size.


Nested Classes

Classes can be declared inside other classes. Nested classes can access private members of the enclosing class:

class Tree {
    priv class Node {
        var value: i32
        var left: unsafe *Node
        var right: unsafe *Node
    }

    var root: unsafe *Node

    fn Tree(self) {
        self.root = &null
    }
}

A type nested in a generic class belongs to each instance: L<i32>::P and L<f64>::P are different types, and a member of L<i32>::P typed with L's parameter reads with i32. Name it through the instance from outside the class (var q: L<i32>::P = l.make(7), L<i32>::P { k: 1 }, L<i32>::Color::Red); inside the class, Self::P is the current instance's. A generic function can take one and deduce the instance's arguments from it (fn <T> first(r: L<T>::P) -> T).


Forward declarations and out-of-line definitions are covered in Forward Declarations and Out-of-Line Definitions.


Operator Overloading

Operators are overloaded with fn op syntax inside the class body:

class Vec2 {
    var x: f64
    var y: f64

    fn Vec2(self, x: f64, y: f64) {
        self.x = x
        self.y = y
    }

    fn op +(self, other: Vec2) -> Vec2 {
        return Vec2(self.x + other.x, self.y + other.y)
    }

    fn op ==(const self, other: Vec2) -> bool {
        return self.x == other.x && self.y == other.y
    }
}

var a = Vec2(1.0, 2.0)
var b = Vec2(3.0, 4.0)
var c = a + b   // Vec2(4.0, 6.0)

Special operators beyond arithmetic:

SyntaxPurpose
fn op delete(self)Destructor
fn op as(self) -> TargetTypeCustom type conversion via as
fn <T> op await(self, obj: std::forward<T>) -> TCustom awaitable

op = is not user-definable it is auto-derived from the transfer constructor. See Lifecycle Categories.

See Operators for the full list of overloadable operators and restrictions.


Memory Layout and Allocation

Class layout follows the platform's C++ ABI:

  • Non-polymorphic classes: members laid out in declaration order with standard padding and alignment.
  • Polymorphic classes (at least one virtual method): vtable pointer at offset 0 (8 bytes on 64-bit), followed by members.
  • Inheritance: base class subobject precedes derived members.

Layout can be controlled with attributes:

@packed
class Compact {
    var a: u8    // offset 0
    var b: u32   // offset 1 (no padding)
}

@align(16)
class Aligned {
    var data: [u8; 12]
}

A plain var declaration allocates on the stack:

var obj = Foo(42)   // stack-allocated

Heap allocation uses @create T(), which returns a pointer. Tether determines whether the returned pointer is raw or promoted to a smart pointer based on usage analysis:

var *ptr = @create Foo(42)

See Pointers and Tether.


Structs vs Classes

Structs and classes are distinct types:

ClassStruct
Member default visibilitypriv for variablespub for all members
Methods in bodyYesNo (use extends)
Aggregate initializationOnly if the class declares no constructorYes (Foo { field: value })
InheritancederivesNot supported
Virtual dispatchYesNo
Lifecycle categoriesYesN/A (no transfer constructors; copyable whenever every field is)

See Structures.


Summary

// Basic class
class Point {
    var x: f64
    var y: f64

    fn Point(self, x: f64, y: f64) {
        self.x = x
        self.y = y
    }

    fn distance(const self, other: Self) -> f64 {
        var dx = self.x - other.x
        var dy = self.y - other.y
        return std::sqrt(dx * dx + dy * dy)
    }
}

// Move-only class
class UniqueFile {
    var handle: unsafe *void

    fn UniqueFile(self, path: string) {
        self.handle = open_file(path)
    }

    @move
    fn UniqueFile(self, other: Self) {
        self.handle  = other.handle
        other.handle = &null
    }

    fn op delete(self) {
        close_file(self.handle)
    }
}

// Inheritance with virtual dispatch
class Shape {
    virtual fn area(const self) -> f64
}

class Circle derives Shape {
    var radius: f64

    fn Circle(self, r: f64) { self.radius = r }

    override fn area(const self) -> f64 {
        return 3.14159 * self.radius * self.radius
    }
}

// Generic class with interface conformance
class <T impl Comparable> PriorityQueue impl Iterable {
    priv var heap: [T]

    fn PriorityQueue(self) { self.heap = [] }
    fn push(self, item: T) { ... }
    fn pop(self) panic -> T { ... }   // `panic` in a generic class: not supported yet (SC116E)
}

// Final class
final class Immutable {
    const value: i32

    fn Immutable(self, v: i32) { self.value = v }
}

See also