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:
| Keyword | Scope |
|---|---|
pub | Accessible from any module |
priv | Accessible only within the defining class or module |
prot | Accessible within the defining class, subclasses, and the defining module |
Modifiers are applied per-declaration. There are no visibility blocks (public: sections).
Default visibility
| Member kind | Default |
|---|---|
| Instance / static / const / eval variables | priv |
| Methods, constructors, destructors | pub |
| Operator overloads | pub |
| Static methods | pub |
| Nested types | pub |
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.
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
| Category | Trigger | Semantics |
|---|---|---|
| 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 ctor | Allows move only; copying is a compile error |
| NON_TRANSFER | both @copy and @move are = deleted | Stack-only; cannot be assigned, copied, or moved |
| DEFAULT | no transfer ctor declared | Treated 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:
- The default constructor (
fn Class(self)) - One transfer constructor (
@copyor@move, never both) - 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 form | Generated 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 category | Allowed derived categories |
|---|---|
| COPY | COPY, NON_TRANSFER |
| MOVE | MOVE, NON_TRANSFER |
| NON_TRANSFER | NON_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.
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::advanceis 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:
| Syntax | Purpose |
|---|---|
fn op delete(self) | Destructor |
fn op as(self) -> TargetType | Custom type conversion via as |
fn <T> op await(self, obj: std::forward<T>) -> T | Custom 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
virtualmethod): 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)
Structs vs Classes
Structs and classes are distinct types:
| Class | Struct | |
|---|---|---|
| Member default visibility | priv for variables | pub for all members |
| Methods in body | Yes | No (use extends) |
| Aggregate initialization | No (must use constructor) | Yes (Foo { field: value }) |
| Inheritance | derives | Not supported |
| Virtual dispatch | Yes | No |
| Lifecycle categories | Yes | N/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 }
}