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, orown self); constructors carry it implicitly. The body refers to the instance throughself. - Method calls use
.; there’s noobj->methodsyntax.
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()findsCircle.area. Calls resolve against the receiver’s declared type — a value held asShapesees onlyShape’s methods, and a base method callingself.area()runs the base version. There is no virtual dispatch through a base-typed class variable — to reach an override, narrow the binding with anistest (see Runtime type tests below) or model the contract as atrait, 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
staticmethod 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: aReaderis accepted where aCloseableis 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 —
MemandConstflowing into the sameReaderslot run differentreadbodies. - 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) —selfis the receiver value; - a
{ … }block (i32 => { return …; }); - a bare-name delegation (
i32 => someFn), which desugars tosomeFn(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 nosuper()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 Ttest a value’s runtime type againstTand its subclasses.- A successful
isnarrows the binding (smart-cast): inside the proven scope a base-typedLocal/Paramis 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 Subis allowed only where anishas already proven the type — theisis the runtime check, so theascollapses 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, aShared<U>, an ownedstring) 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 bodies | Base class (class Child : Base) |
| Document a contract several classes satisfy | A 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 clause | Trait 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
- Language tour § Classes
- Generics and traits — bounds
<T : A + B>,match type,@derivefull table,when T : Bound std/collectionsreference — generic class examples- Error handling — exception subclasses
- Memory model — heap vs shared decisions
Shared<T>reference counting — when to opt in- Concept index — every class-related concept on one page