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.


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, or eval 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.

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 only within the defining class or module
protAccessible within the defining class, subclasses, and the defining module

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

Default visibility

Member kindDefault
Instance / static / const / eval 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.

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

Destructors use the op delete operator syntax:

class Resource {
    var handle: unsafe *void

    fn Resource(self, h: unsafe *void) {
        self.handle = h
    }

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

Destructors can be defaulted or deleted:

fn op delete(self) = default   // compiler-generated, memberwise destroy in reverse declaration order
fn op delete(self) = delete    // prevent destruction (and therefore stack allocation)

Destructors run at the end of the enclosing scope in reverse declaration order.


Lifecycle Categories

Every class has a lifecycle category that determines how its instances can be transferred between variables. The category is implied by which transfer constructor the class defines.

Attribute syntax

@copy and @move are attributes attached to a constructor. The convention is one attribute per line above the declaration:

@copy
fn Buffer(self, other: Self) = default

Inline placement (@copy fn Buffer(self, other: Self) = default) is permitted but discouraged. See Attributes for the full attribute syntax.

The transfer constructor

A constructor with the signature fn Class(self, other: Self) is the transfer constructor. It governs how instances of the class are passed by value. The attribute on this constructor selects the category:

class Buffer {
    @copy
    fn Buffer(self, other: Self) = default
}

class UniqueFile {
    @move
    fn UniqueFile(self, other: Self) = default
}

A transfer constructor without an attribute defaults to @copy. A class with no transfer constructor at all is implicitly COPY with all four special members compiler-generated.

A class cannot define both @copy and @move transfer constructors pick one:

class Bad {
    @copy fn Bad(self, other: Self) = default
    @move fn Bad(self, other: Self) = default   // compile error
}

The four categories

CategoryTriggerSemantics
COPY@copy ctor (explicit or implicit)Allows copy; AMT may silently elide a copy into a move when the source is unused after the transfer
MOVE@move ctorAllows move only; copying is a compile error
NON_TRANSFERboth @copy and @move are = deletedStack-only; cannot be assigned, copied, or moved
DEFAULTno transfer ctor declaredTreated as COPY with compiler-generated members

A NON_TRANSFER class is the canonical scope guard:

class ScopeLock {
    fn ScopeLock(self) = default

    @copy
    fn ScopeLock(self, other: Self) = delete

    @move
    fn ScopeLock(self, other: Self) = delete

    fn op delete(self) { /* ... */ }
}

The Rule of Three

The user writes at most three special members:

  1. The default constructor (fn Class(self))
  2. One transfer constructor (@copy or @move, never both)
  3. The destructor (fn op delete(self))

Each may have a user body, = default, or = delete. Anything not written is compiler-generated.

class Buffer {
    var data: unsafe *u8
    var size: usize

    fn Buffer(self, size: usize) {
        self.data = std::alloc<u8>(size)
        self.size = size
    }

    @move
    fn Buffer(self, other: Self) {
        self.data  = other.data
        self.size  = other.size
        other.data = null
        other.size = 0
    }

    fn op delete(self) {
        std::free(self.data)
    }
}

Assignment is auto-derived

op = is generated from the transfer constructor the user does not write it. Writing it explicitly is a compile error.

Transfer ctor formGenerated op =
= default= default
= delete= delete
{ body }destroys self, then runs the ctor body, then returns self

The body case handles self-assignment by destroying the current state before reconstructing it.

This implies a consistency rule: if the transfer ctor has a body and op delete is = deleted, the class fails to compile the auto-derived op = needs to invoke the destructor and cannot. Fix by providing a destructor or by changing the transfer ctor to = default / = delete.

Defaulted moves of non-movable members

= default on a @move ctor performs memberwise move: each member is moved through its own @move ctor. If a member is COPY-only (no @move available), that member is copied as part of the move matching C++ semantics. This is silent and almost always what you want; if a member must be moved or nothing, write the transfer ctor body explicitly and let the compiler error on the COPY-only field.

Implicit-copy-with-custom-destructor warning

A class with a custom destructor but no explicit @copy or @move transfer constructor compiles, but emits a warning. The class is implicitly copyable, and a custom destructor almost always means the class owns a resource silent copies of that resource cause double-free and aliasing bugs.

class UniqueFile {
    var handle: unsafe *void

    fn op delete(self) { close_file(self.handle) }
    // warning: custom destructor with implicit copy constructor
    //   help: declare as @move:
    //           @move fn UniqueFile(self, other: Self) = default
    //   help: or explicitly delete copy:
    //           @copy fn UniqueFile(self, other: Self) = delete
}

AMT copy elision

For COPY classes, AMT may emit a move instead of a copy when it proves the source is unused after the transfer. This is a pure optimization with no observable semantic difference the source is destroyed either way, just earlier when elided. AMT does not elide if the move constructor is deleted. Users do not opt into or out of this optimization.


Inheritance

Classes inherit from other classes using derives:

class Animal {
    var name: string

    fn Animal(self, name: string) {
        self.name = name
    }

    fn speak(self) {
        std::println(f"{self.name} makes a sound")
    }
}

class Dog derives Animal {
    var breed: string

    fn Dog(self, name: string, breed: string) {
        Animal::Animal(self, name)   // call base constructor
        self.breed = breed
    }

    fn speak(self) {
        std::println(f"{self.name} barks")
    }
}

Inheritance visibility

By default, inheritance is public. Append pub, prot, or priv after derives to control how base members are exposed in the derived class:

class Derived derives pub Base { ... }    // base members keep their visibility
class Derived derives prot Base { ... }   // all base members become protected
class Derived derives priv Base { ... }   // all base members become private

Multiple inheritance

class Serializable {
    fn serialize(self) -> [byte] { ... }
}

class Printable {
    fn print(self) { ... }
}

class Document derives Serializable, Printable {
    // inherits both serialize() and print()
}

class Widget derives pub Drawable, prot EventHandler { ... }

Calling base class methods

There is no super keyword. Call base methods explicitly using the qualified name:

class Derived derives Base {
    fn method(self) {
        Base::method(self)
    }
}

Diamond and virtual inheritance

If B and C both derive from A, and D derives from both B and C, then D contains two copies of A’s subobject. Disambiguate with qualified names:

class A {
    fn method(self) { std::println("A") }
}

class B derives A { fn method(self) { std::println("B") } }
class C derives A { fn method(self) { std::println("C") } }

class D derives B, C {
    fn method(self) {
        B::method(self)
        C::method(self)
    }
}

To share a single A subobject, use derives virtual. The most-derived class is responsible for initializing the virtual base directly; intermediate classes’ calls to the virtual base constructor are ignored:

class A {
    var value: i32
    fn A(self, v: i32) { self.value = v }
}

class B derives virtual A {
    fn B(self, v: i32) { A::A(self, v) }
}

class C derives virtual A {
    fn C(self, v: i32) { A::A(self, v) }
}

class D derives B, C {
    fn D(self, v: i32) {
        A::A(self, v)    // D initializes the virtual base
        B::B(self, v)
        C::C(self, v)
    }
}

Lifecycle category matching

A derived class’s lifecycle category must be compatible with its base’s:

Base categoryAllowed derived categories
COPYCOPY, NON_TRANSFER
MOVEMOVE, NON_TRANSFER
NON_TRANSFERNON_TRANSFER

A copyable base with a move-only derived class is a compile error slicing a derived instance through a base pointer would otherwise silently copy the base subobject of a class that promised never to be copied.

See Casting for upcasting and downcasting.


Virtual Dispatch

By default, methods are statically dispatched. To enable dynamic dispatch via a vtable, mark the method virtual in the base class:

class Shape {
    virtual fn area(self) const -> f64 {
        return 0.0
    }
}

class Circle derives Shape {
    var radius: f64

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

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

var shape: *Shape = &Circle(5.0)
shape->area()   // 78.539... dynamic dispatch

virtual creates a vtable slot. override in the derived class replaces the entry in that slot. A class only has a vtable pointer (8 bytes at offset 0) if it declares or inherits at least one virtual method. Non-polymorphic classes have no vtable overhead.

Abstract classes (pure virtual)

A method declared with = virtual has no body and must be overridden by any non-abstract derived class:

class Shape {
    fn area(self) const -> f64 = virtual
    fn perimeter(self) const -> f64 = virtual
}

class Circle derives Shape {
    var radius: f64

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

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

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

// var s = Shape()   // compile error: Shape has pure virtual methods
var c = Circle(5.0)  // ok: all pure virtuals overridden

= virtual implies vtable participation no virtual prefix is needed on the declaration. A class with any = virtual method cannot be instantiated directly.

final

final prevents further overriding of a method or derivation from a class:

final class Singleton { /* ... */ }

// class Derived derives Singleton { }   // compile error

class Base {
    virtual fn process(self) { ... }
}

class Middle derives Base {
    final override fn process(self) { ... }   // overrides, then locks
}

class Bottom derives Middle {
    // override fn process(self) { ... }   // compile error: final in Middle
}

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(self) const -> u64
}

class UserId impl Hashable {
    var id: u64

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

    fn hash(self) const -> 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(self) const -> 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 Bounds.


Generic Classes

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

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.len() == 0 {
            panic std::Error::Runtime("stack underflow")
        }
        return self.items.pop()
    }
}

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

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

By default, a type parameter accepts any T. Whether a specific T is valid for a given instantiation depends on what the body does with it by-reference use accepts anything, copying requires a COPY type, moving requires COPY or MOVE, returning by value requires COPY or MOVE. Instantiation errors point at both the body operation that required the capability and the type that lacks it.

Explicit bounds document intent and fail fast at the constraint check rather than mid-body. Two kinds of bounds exist:

Kind bounds restrict T to a specific category of type declaration:

fn <T: class>  process(item: T)   { ... }
fn <T: struct> serialize(item: T) { ... }
fn <T: enum>   stringify(item: T) { ... }
fn <T: union>  inspect(item: T)   { ... }

Interface bounds restrict T to types that satisfy a given interface. The standard library will provide interfaces describing lifecycle capabilities, allowing constraints like “T must be copyable” or “T must be movable” to be written as interface bounds. The exact names and shapes of these interfaces are still being finalized in std write your own if you need them now:

interface Copyable {
    fn Copyable(self)               // default ctor

    @copy
    fn Copyable(self, other: Self)
}

fn <T impl Copyable> store(item: T) -> T { ... }

See Bounds for the full constraint system.


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


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
    }
}

Forward Declarations and Out-of-Line Definitions

A class can be forward-declared without a body sufficient for *Class uses, not for sizeof or member access:

class Parser

class Lexer {
    var parser: unsafe *Parser   // ok: pointer to incomplete type
}

class Parser {
    var lexer: Lexer
    // full definition
}

Member methods can also be declared inside the class body without a body and defined out-of-line using the Class::method qualified-name syntax:

class Parser {
    var tokens: [Token]
    var pos: usize

    pub fn Parser(self, tokens: [Token])
    pub fn advance(self) -> Token
    pub fn peek(self) const -> Token
    priv fn error(self, msg: string) panic
}

fn Parser::Parser(self, tokens: [Token]) {
    self.tokens = tokens
    self.pos = 0
}

fn Parser::advance(self) -> Token {
    var t = self.tokens[self.pos]
    self.pos += 1
    return t
}

fn Parser::peek(self) const -> Token = self.tokens[self.pos]

fn Parser::error(self, msg: string) panic {
    panic std::Error::Parse(msg, self.pos)
}

This pattern keeps the class body small and readable as an API surface, with implementation details defined separately.

Rules

  • The qualified definition’s signature must exactly match the in-class declaration: parameter types, return type, and all function modifiers (const, unsafe, panic, eval, async, inline, final, virtual, override, static).
  • Visibility is omitted from the out-of-line definition it is taken from the in-class declaration. Writing pub fn Parser::advance is a compile error.
  • Default arguments must appear on the in-class declaration, not the out-of-line definition.
  • Parameter names may differ between declaration and definition; the definition’s names are used in the body.

Generic classes

For generic classes, the type parameters are re-declared on the out-of-line definition and the class name carries its generic arguments:

class <T> Vec {
    var data: unsafe *T
    var len: usize
    var cap: usize

    pub fn push(self, value: T)
    pub fn get(self, i: usize) const -> T
}

fn <T> Vec<T>::push(self, value: T) {
    if self.len == self.cap { self.grow() }
    self.data[self.len] = value
    self.len += 1
}

fn <T> Vec<T>::get(self, i: usize) const -> T = self.data[i]

Operators, constructors, destructors

Operator overloads, constructors, and destructors follow the same pattern:

class Vec2 {
    var x: f64
    var y: f64

    pub fn Vec2(self, x: f64, y: f64)
    pub fn op delete(self)
    pub fn op +(self, other: Vec2) -> Vec2
}

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

fn Vec2::op delete(self) { /* ... */ }

fn Vec2::op +(self, other: Vec2) -> Vec2 = Vec2(self.x + other.x, self.y + other.y)

Nested types

For methods on a nested type, chain the qualifiers:

fn Outer::Inner::method(self) { /* ... */ }

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 ==(self, other: Vec2) const -> 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 std::create<T>(), which returns a pointer. AMT determines whether the returned pointer is raw or promoted to a smart pointer based on usage analysis:

var ptr = std::create<Foo>(42)

See Pointers and AMT.


Structs vs Classes

Structs and classes are distinct types:

ClassStruct
Member default visibilitypriv for variablespub for all members
Methods in bodyYesNo (use extends)
Aggregate initializationNo (must use constructor)Yes (Foo { field: value })
InheritancederivesNot supported
Virtual dispatchYesNo
Lifecycle categoriesYesN/A (always trivially copyable)

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 {
    fn area(self) const -> f64 = virtual
}

class Circle derives Shape {
    var radius: f64

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

    override fn area(self) const -> 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 { ... }
}

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

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