Axle v0.14.1

Object-oriented programming

Axle’s class system is class-based: single inheritance, explicit constructors and an optional drop() destructor, and trait contracts. Class-method calls resolve statically on the receiver’s declared type; a trait-typed or dyn Trait receiver dispatches dynamically through a runtime type tag (see below). The canonical form class Foo : Base, Trait places the base class and any number of traits in one : list; the : list is the only supertype syntax. Generics are monomorphised at compile time over six representations — i32, i64, u32, u64, f64, string — but feel like generic types in source.

A class

class Point {
    x : f64;
    y : f64;

    constructor(x : f64, y : f64) {
        self.x = x;
        self.y = y;
    }

    pub fn distanceTo(self, other : Point) : f64 {
        let dx : f64 = self.x - other.x;
        let dy : f64 = self.y - other.y;
        return (dx * dx + dy * dy).sqrt();
    }
}

fn main() : i32 {
    let a : Point = new Point(0.0, 0.0);
    let b : Point = new Point(3.0, 4.0);
    let d : f64   = a.distanceTo(b);            // 5.0
    return d.truncToInt() as i32;
}

Notes :

  • Fields are listed first, separated by commas.
  • constructor(...) is the canonical name for an initialiser ; one per class (no overloading).
  • An instance method names its receiver as the first parameter (self, mut self, or own self); constructors carry it implicitly. The body refers to the instance through self.
  • Method calls use . ; there’s no obj->method syntax.

Inheritance

use std::numeric::math;

class Shape {
    name : string;
    constructor(name : string) {
        self.name = name;
    }

    pub fn describe(self) : string {
        return self.name;
    }
}

class Circle : Shape {
    radius : f64;

    constructor(radius : f64) {
        super("Circle");
        self.radius = radius;
    }

    pub fn area(self) : f64 {
        return math::pi() * self.radius * self.radius;
    }
}

class Square : Shape {
    side : f64;

    constructor(side : f64) {
        super("Square");
        self.side = side;
    }

    pub fn area(self) : f64 {
        return self.side * self.side;
    }
}
  • The first : supertype declares the immediate base class.
  • super(...) invokes the base constructor — call it first in a derived constructor (skipping it leaves the base fields zero-initialised).
  • A derived method with the same signature replaces the base’s for receivers of the derived type : circle.area() finds Circle.area. Calls resolve against the receiver’s declared type — a value held as Shape sees only Shape’s methods, and a base method calling self.area() runs the base version. There is no virtual dispatch through a base-typed class variable — to reach an override, narrow the binding with an is test (see Runtime type tests below) or model the contract as a trait, which dispatches dynamically.

Single inheritance only

A class has one base. Multiple inheritance is not supported — use traits for cross-cutting capabilities.

Inheritance walks the field layout

class A {
    pub x : i32;
    constructor(x : i32) {
        self.x = x;
    }
}
class B : A {
    pub y : i32;
    constructor(x : i32, y : i32) {
        super(x);
        self.y = y;
    }
}
class C : B {
    pub z : i32;
    constructor(x : i32, y : i32, z : i32) {
        super(x, y);
        self.z = z;
    }
}

fn main() : i32 {
    let c : C = new C(1, 2, 3);
    return c.x + c.y + c.z;                     // 6
}

The runtime layout of C is [x, y, z] — base fields come first, in declaration order. Field accesses go through stable offsets, so upcasts are free.

Traits

A trait is the sole named capability contract. A class opts in with a : Trait clause, and a trait-typed receiver dispatches at runtime from the instance’s concrete type through its RTTI header.

Basic contract

trait Reader {
    fn read(self) : i32;
}

class Mem : Reader {
    v : i32;
    constructor(v : i32) {
        self.v = v;
    }
    pub fn read(self) : i32 {
        return self.v;
    }
}

class Const : Reader {
    constructor() {}
    pub fn read(self) : i32 {
        return 12;
    }
}

fn use_reader(r : Reader) : i32 {   // receiver held *as* the trait
    return r.read();                // dispatched at runtime from r's type
}

fn main() : i32 {
    let m : Mem   = new Mem(30);
    let c : Const = new Const();
    return use_reader(m) + use_reader(c);   // 30 + 12 = 42
}
  • The compiler verifies conformance: a class that names a trait but omits one of its methods is E0026, and a provided method whose signature doesn’t match the contract is E0029 (the check re-walks the base chain, so a drifting override is caught too). A static method never satisfies an instance contract — it has no receiver, so the contract method reads as missing (E0026). Calling through a trait-typed receiver a method not in its contract is E0030.
  • A trait may name super-traits (trait Reader : Closeable), and a sub-trait value flows into a super-trait slot: a Reader is accepted where a Closeable is expected.
  • Dispatch is dynamic: each instance carries a hidden type tag (a leading RTTI header), and a call through a trait-typed receiver selects the concrete class’s body from it — Mem and Const flowing into the same Reader slot run different read bodies.
  • A trait method’s declared error set is part of the contract: a checked exception that can escape a trait-typed call must be handled or re-declared in the caller’s ! set, exactly like a direct call — E0005 otherwise.

Unified class header — class Foo : Base, Trait

The : form unifies the supertype list: base class and traits are listed together, separated by commas, after a single :. Sema classifies each entry as a base class or a trait from its declaration; at most one base class is allowed (E0033 otherwise).

class Shape {
    name : string;
    constructor(name : string) {
        self.name = name;
    }
    pub fn describe(self) : string {
        return self.name;
    }
}

trait Renderable {
    fn render(self) : string;
}

trait Serialisable {
    fn toJson(self) : string;
}

// Shape is the base class; Renderable and Serialisable are traits.
class Sprite : Shape, Renderable, Serialisable {
    x : i32;
    y : i32;

    constructor(name : string, x : i32, y : i32) {
        super(name);
        self.x = x;
        self.y = y;
    }

    pub fn render(self) : string {
        return self.name + " at " + self.x + "," + self.y;
    }

    pub fn toJson(self) : string {
        return "{\"name\":\"" + self.name + "\"}";
    }
}

The : supertype list is the only form the class header accepts: the first entry may be a base class, and every entry (or anything after a comma) is a trait.

Default method bodies

A trait method may carry a default body — an implementation a class inherits when it does not supply its own:

trait Printable {
    fn show(self) : string;

    fn print(self) : void {                   // default body
        println(self.show());
    }
}

class Label : Printable {
    text : string;
    constructor(text : string) {
        self.text = text;
    }
    pub fn show(self) : string {
        return self.text;
    }
    // `print()` is inherited from the trait default
}

fn main() : i32 {
    let lbl : Label = new Label("hello");
    lbl.print();          // prints "hello"
    return 0;
}

A class omitting a defaulted method satisfies the contract (the compiler grafts the trait body onto the class); a class may still override the default by providing its own method of the same name and matching signature. The inherited default is callable both on a concrete binding (lbl.print() above) and through a trait-typed receiver (fn run(p : Printable) { p.print(); }), and resolves regardless of declaration order — a defaulted method may be called before its class is declared in the file. One edge remains (see the roadmap): a default body on a trait imported from another crate / the stdlib is not grafted (the body is not carried in the package metadata).

impl Self — equipping types without declaring a trait

An impl Self block inside a trait records concrete types that satisfy the trait without a : Trait clause — this is how a primitive (i32, string) can satisfy a user trait’s bound. (Hashable and Comparable are illustrative trait names, not built-ins: the stdlib ships no such trait and uses no impl Self block of its own — a trait with an impl Self table is something you declare.)

Each branch is Type => body, in one of three callable forms plus a rejection marker:

  • an inline expression (i32 => self * 2) — self is the receiver value;
  • a { … } block (i32 => { return …; });
  • a bare-name delegation (i32 => someFn), which desugars to someFn(self, <method params>) — the target receives the value-self first, then the trait method’s parameters;
  • unsupported "msg", which lets the type satisfy a bound but is not callable (selecting it on a call is a compile error).

For example, with the inline-expression form:

trait Describable {
    fn describe(self) : string;

    impl Self {
        i32    => "an integer",
        string => "a string",
    }
}

fn main() : i32 {
    let n : i32 = 42;
    println(n.describe());   // "an integer" — dispatched to the i32 branch
    return 0;
}

A method call on a concrete primitive (n.describe()) resolves statically to the matching impl Self branch, so i32 carries the trait method without any : Trait clause.

A primitive that satisfies a trait via impl Self can also be used anywhere a trait-typed value is expected — as a call argument (fn take(x : Describable) accepts take(42)), as a variable (let x : Describable = 42;), or as a return value (fn make() : Describable { return 42; }). The compiler boxes the primitive at each boundary (a tiny synthesised wrapper carrying the type tag) so the trait dispatch works unchanged. A primitive type argument can also receive a bound method through a type parameter (item.describe() where item : T and T = i32) — the same as a class type argument.

One edge. An escaping boxed primitive held through a trait-typed binding (returned, or stored in a trait-typed field) is conservatively kept alive rather than freed once — a small, bounded leak, never a dangling read or double free. Single-free under escape is tracked in the roadmap.

dyn Trait — explicit trait objects

dyn Trait is an alternative spelling for a trait-typed receiver, familiar to Rust and TypeScript users. It resolves to the same trait type and uses the same RTTI-backed dynamic dispatch — there is no difference at runtime.

fn process(r : dyn Reader) : i32 {    // same as `fn process(r : Reader)`
    return r.read();
}

Use dyn Trait when you want to make the dynamic dispatch explicit in a signature, or to match conventions in a codebase that prefers the dyn prefix.

Value types implementing traits — struct / enum

Traits are not a class-only capability. A struct and an enum are value types (no RTTI header, never heap-allocated on their own), yet each may declare inherent methods and implement a trait with the same : Trait clause a class uses. Conformance is verified identically (E0026 for a missing contract method, E0029 for a signature mismatch).

trait Area {
    fn area(self) : i32;
}

struct Rect : Area {
    w : i32;
    h : i32;

    pub fn area(self) : i32 {                 // inherent method, `self` receiver
        return self.w * self.h;
    }
}

enum Shape : Area {
    Circle(i32),
    Square(i32),

    pub fn area(self) : i32 {
        return match self {               // enum receiver is the value itself
            Shape::Circle(r) => r * r,
            Shape::Square(s) => s * s,
        };
    }
}

fn total(a : Area) : i32 {                // trait-typed slot — dynamic dispatch
    return a.area();
}

When a value type flows into a trait-typed slot it is boxed: the compiler wraps it in a tiny tagged class-box whose leading __type_id names the concrete type, so the same RTTI-backed dispatch a class uses works unchanged. A struct receiver crosses by pointer, an enum by value — matching how its inherent methods take self.

Trait defaults are grafted onto value types too. A struct or enum that omits a defaulted trait method inherits the trait’s body (lowered against the implementer: a struct with its pointer receiver, an enum with its by-value receiver, so match self in the default reads the variant tag). The behaviour is pinned by struct_implements_trait.axle, enum_implements_trait.axle, and enum_trait_default_inherited.axle under tests/samples/compile_pass/.

Only value types whose fields / payloads are trivially-copyable are boxed — a value that owns a resource (a heap string, an owning pointer) would risk a double-free on the box copy, so it is rejected at the trait boundary rather than boxed.

when T : Bound — conditional method groups

A when block gates a group of methods on a generic: the methods exist on an instantiation only when the named bound holds. This lets a generic class expose richer behaviour for capable type arguments without making the bound mandatory for every use.

class Holder<T> {
    item  : T;
    count : i32;
    constructor(own it : T, c : i32) {   // `own` moves a class-typed T into the field
        self.item = it;
        self.count = c;
    }

    when T : Printable {
        pub fn describeCount(self) : i32 {
            return self.count;   // only present when T : Printable
        }
    }
}

Holder<Label> exposes describeCount (because Label : Printable); Holder<i32> does not — calling it on a non-satisfying instantiation is an E0015 (method not found). The gated methods are materialised onto each satisfying instantiation during monomorphisation.

Traits are the runtime-polymorphism path. Class-method calls resolve against the declared type at compile time; a trait-typed (or dyn Trait) receiver dispatches at runtime. Take a trait parameter when one routine must run different bodies per concrete type.

Constructors and super

class Base {
    id : i32;
    constructor(id : i32) {
        self.id = id;
    }
}

class Derived : Base {
    extra : string;
    constructor(id : i32, extra : string) {
        super(id);                              // MUST be first
        self.extra = extra;
    }
}

Rules :

  • Call super(...) as the first statement of a derived constructor.
  • A derived constructor that never calls super(...) still compiles — the base’s fields stay zero-initialised, which is rarely what you want.
  • A class with no base class in its : clause needs no super() call.

Field access and self

class Counter {
    value : i32;
    constructor() {
        self.value = 0;
    }
    pub fn bump(mut self) : void {
        self.value = self.value + 1;
    }
    pub fn reset(mut self, value : i32) : void {
        self.value = value;                     // disambiguates from the parameter
    }
}

self is required for field access inside methods — there’s no implicit field shadowing rule. value alone refers to the parameter ; self.value refers to the field.

Runtime type tests — is / !is and as-downcast

Each class instance carries a hidden type tag stamped at allocation, so runtime type tests are real comparisons.

  • x is T / x !is T test a value’s runtime type against T and its subclasses.
  • A successful is narrows the binding (smart-cast): inside the proven scope a base-typed Local / Param is seen as the subclass, so its members resolve. The narrowing is flow-scoped (only inside the then-branch / guarded body) and is dropped if the binding is reassigned; if (x !is T) narrows the else-branch symmetrically.
fn measure(s : Shape) : i32 {
    if (s is Square) {
        return s.area();          // s seen as Square here → Square.area()
    }
    return 0;
}
  • An upcast (subclass → ancestor) is always a free reinterpret and needs no check.
  • A downcast x as Sub is allowed only where an is has already proven the type — the is is the runtime check, so the as collapses to a zero-cost reinterpret. An unproven downcast is rejected; there is no unchecked downcast.
class Animal {
    pub legs : i32;
    constructor(legs : i32) {
        self.legs = legs;
    }
}

class Dog : Animal {
    pub barks : i32;
    constructor(barks : i32) {
        super(4);
        self.barks = barks;
    }
}

fn main() : i32 {
    let b : Animal = new Dog(5);
    if (b is Dog) {
        let d : Dog = b as Dog;       // proven → identity reinterpret
        return d.barks;
    }
    return 0;
}

Cross-trait tests — capability narrowing

is / as also work between traits. A value held as one trait (or a base class) can be queried at runtime for a different trait capability: the test asks whether the value’s concrete type is in the set of types that implement the queried trait. A proven is narrows the binding to that trait view, so its methods resolve and dispatch by RTTI.

A dyn Trait[] is a heterogeneous array — each element may be a different concrete implementer, stored behind the trait view. A dyn Trait[] (or any scalar T[]) literal is a dynamic array: it is materialised on the heap with a length header, so it can be passed to a T[] parameter and returned across a call (bounds-checked and freed off its header):

trait Mob {
    fn hit(mut self) : void;
}

trait Flies {
    fn flee(mut self) : void;
}

class Goblin : Mob {
    hp : i32;
    constructor() { self.hp = 10; }
    pub fn hit(mut self) : void { self.hp = self.hp - 1; }
}

class Bat : Mob, Flies {
    hp : i32;
    constructor() { self.hp = 3; }
    pub fn hit(mut self) : void { self.hp = self.hp - 1; }
    pub fn flee(mut self) : void { self.hp = 0; }
}

fn shockwave(mobs : dyn Mob[], n : i32) : void {
    for (i of 0..n) {
        let m : dyn Mob = mobs[i];
        m.hit();                       // Mob contract — every element has it
        if (m is Flies) {              // does this element's concrete type fly?
            (m as Flies).flee();       // proven → dyn Flies view, Flies dispatch
        }
    }
}

fn main() : i32 {
    let g : Goblin = new Goblin();     // Goblin : Mob
    let b : Bat    = new Bat();        // Bat : Mob, Flies
    let mobs : dyn Mob[] = [g, b];     // heterogeneous — mixed concrete types
    shockwave(mobs, 2);
    return 0;
}

As with the class downcast, m as Flies is accepted only where a covering is Flies has proven it — an unproven capability downcast is E0001. A statically-known capability (a class to a trait it implements, a sub-trait to its super-trait) is always a free reinterpret and needs no is.

A heap T[] literal is currently offered for scalar and trait-object element types. An array of owning objects (a class with a destructor, a Shared<U>, an owned string) keeps the fixed-size inline form and cannot yet cross a call — its per-element teardown across the boundary is tracked in the roadmap.

Generics

class Box<T> {
    value : T;
    constructor(value : T) {
        self.value = value;
    }
    pub fn get(self) : T {
        return self.value;
    }
}

fn main() : i32 {
    let intBox  : Box<i32>    = new Box<i32>(42);
    let strBox  : Box<string> = new Box<string>("hi");

    return intBox.get();
}

The compiler instantiates one specialised version per representation : i32, i64, u32, u64, f64, string. The unsigned two are their own rather than sharing the signed slot of the same width, because the body is compiled for what the elements are — an ArrayList<u32> sorts [4000000000, 1] to [1, 4000000000], and an ArrayList<i32> over the same bits sorts them the other way. User-class type arguments (Box<Point>, ArrayList<Task>) all share the i64 representation at run time — every class instance is a 64-bit value at the ABI, handle or heap pointer alike — so they reuse the same compiled methods, while the compiler still enforces the source-level types so a Box<Point> and a Box<Task> never mix. Storing a class-typed T in a field takes an own constructor parameter to move ownership into the field; the instance’s synthesised destructor frees that concrete field exactly once at scope exit.

Multiple type parameters

A user generic class may take several type parameters, each monomorphised independently:

class Pair<K, V> {
    key   : K;
    value : V;
    constructor(k : K, v : V) {
        self.key = k;
        self.value = v;
    }
    pub fn getKey(self)   : K {
        return self.key;
    }
    pub fn getValue(self) : V {
        return self.value;
    }
}

fn main() : i32 {
    let p : Pair<i32, i64> = new Pair<i32, i64>(7, 100);
    println(p.getKey().toString() + " " + p.getValue().toString());
    return 0;
}

Fields use a type parameter directly (key : K, value : T). For ready-made multi-parameter containers the stdlib offers ArrayList<T>, HashMap<K, V>, etc. A class can also just hold a concrete instantiation as a field :

use std::collections::HashMap;

class Cache {
    map : HashMap<string, i32>,
    constructor() {
        self.map = new HashMap<string, i32>();
    }

    pub fn put(self, own key : string, value : i32) : void {
        self.map.put(key, value);          // `own` — the map takes the key
    }
    pub fn get(self, key : string) : i32 | null {
        return self.map.get(key);          // null on a miss
    }
}

@derive(...) — synthesised boilerplate

Decorate a class with @derive(Trait, …) to have the compiler synthesise the standard implementation of each listed trait. The expansion is field-by-field and stays correct as the class evolves.

@derive(Eq, ToString)
class Vector2 {
    x : f64;
    y : f64;
    constructor(x : f64, y : f64) {
        self.x = x;
        self.y = y;
    }
}

fn main() : i32 {
    let a : Vector2 = new Vector2(1.0, 2.0);
    let b : Vector2 = new Vector2(1.0, 2.0);
    if (a.equals(b)) {
        println(a.toString());
    }
    return 0;
}

Derivable traits: Eq (field-by-field equals), Ord (compareTo), Hashable (hashCode), ToString (toString), Clone (clone). A collision with a hand-written method of the same name is rejected at parse time. See Generics and traits for the full derivable-trait table and generic bounds.

Method overriding & static dispatch

A subclass method with the same name overrides the base method. There is no virtual table — a call resolves on the receiver’s declared type at compile time. A base class may supply a default body that subclasses override:

class Block {
    // default body
    pub fn surface(self) : i32 {
        return 0;
    }
    // inherited unchanged
    pub fn rank(self) : i32 {
        return 10;
    }
}

class Dirt : Block {
    depth : i32;
    constructor(depth : i32) {
        self.depth = depth;
    }
    // override
    pub fn surface(self) : i32 {
        return 1;
    }
}

dirt.surface() on a Dirt binding runs Dirt.surface; the same call through a Block-typed binding runs Block.surface. To dispatch on the concrete type behind a base-typed binding, narrow it with an is test (if (b is Dirt) { b.surface(); }), or model the capability as a trait — a trait-typed receiver dispatches at runtime off the RTTI header.

Comparing classes and traits

Want to …Use
Share fields + method bodiesBase class (class Child : Base)
Document a contract several classes satisfyA trait, satisfied with : Trait in the class header
Runtime polymorphism (different body per concrete type)A trait-typed or dyn Trait parameter (RTTI-dispatched), or is-narrowing a base binding
Shared implementation that any type can opt into without a : Trait clauseTrait impl Self table
Layered specialisation (Shape → Circle → ColoredCircle)Inheritance chain
Synthesise equals / compareTo / hashCode / toString / clone@derive(Eq, Ord, Hashable, ToString, Clone)

Class-method calls resolve against the declared type; for runtime polymorphism, take a trait-typed (or dyn Trait) parameter or narrow a base binding with is.

Lifetime patterns

Most classes are heap-allocated (new Foo(...)). Reach for Shared<T> only when ownership genuinely needs to be shared :

// Stack/heap (compiler picks) — single owner, escapes via return.
fn makePoint(x : f64, y : f64) : Point {
    return new Point(x, y);
}

// Shared — one logical instance, multiple holders.
fn buildIndex() : Shared<Cache> {
    let cache : Shared<Cache> = new shared Cache();
    cache.put("alpha", 1);
    return cache;
}

See Memory model for tier selection.

Cleanup — drop(), defer, and close()

A class may declare a destructor named drop() — the compiler runs it deterministically when the instance’s lifetime ends (scope exit for an owned stack/heap instance, refcount-zero for a Shared<T>). Memory itself never needs a hand-written hook: a class that owns heap fields, owned objects, or Shared<T> / Weak<T> handles gets a compiler- synthesised destructor that frees them. Reach for an explicit drop() only to release a non-memory resource the compiler can’t see.

For a resource you’d rather release at a precise point — or on a borrowed handle the callee doesn’t own — the close()-method convention every stdlib I/O class follows, paired with defer, stays the explicit tool :

class FileHandle {
    fd : i32;
    constructor(fd : i32) {
        self.fd = fd;
    }
    pub fn close(mut self) : void {
        // release self.fd here
        self.fd = 0;
    }
}

fn runWith(h : FileHandle) : i32 {
    defer h.close();
    return h.fd;            // computed before the deferred close runs
}

defer statements run at function exit in LIFO order, on every exit path — normal return, early return, or a propagating exception. Memory itself needs no hand-written hook : the compiler inserts the drop when a heap instance leaves scope, and a Shared<T> frees when its refcount hits zero.

Common pitfalls

Forgetting super(…) in a derived constructor

class Derived : Base {
    extra : string;
    constructor(extra : string) {
        self.extra = extra;                     // base fields stay zero-initialised !
    }
}

This compiles — the base constructor simply never runs, so id silently reads as 0. Call super(id) explicitly.

Mutating a field through self in the wrong constructor

class A {
    x : i32;
    constructor() {
        // bug — initialiser depends on `x` before init
        self.x = self.x + 1;
    }
}

The field is zero-initialised before the constructor body, so x reads as 0 here. Cleaner :

class A {
    x : i32;
    constructor(start : i32) {
        self.x = start + 1;
    }
}

Comparing class instances with ==

class Point {
    x : f64;
    y : f64;
    constructor(x : f64, y : f64) {
        self.x = x;
        self.y = y;
    }
}

fn main() : i32 {
    let a : Point = new Point(1.0, 2.0);
    let b : Point = new Point(1.0, 2.0);
    if (a == b) {                                // identity check, not value
        return 1;
    }
    return 0;
}

== on class types compares pointer identity. For value equality, add an equals(other : Point) : bool method.

See also

oopclassesinheritancetraits