Axle v0.14.1

Generics and traits

Generics let one declaration work over many types; a trait is the contract that says which types it may work over. Together they give you reusable, statically checked code with no boxing on the fast path.

This page covers:

  • the vocabulary — what a trait is, static vs dynamic dispatch, what monomorphisation means;
  • generic classes and the bounds on their type parameter, with the three ways to satisfy one;
  • the match type T compile-time type switch;
  • impl Self, which gives a primitive a trait;
  • @derive(...), which synthesises the standard boilerplate.

For trait declaration basics and dynamic dispatch see Object-oriented programming.

Concepts first — the vocabulary

If generics and traits are new to you, three ideas underpin everything below. This section explains them in plain terms; the syntax follows.

A trait is a capability contract

A trait names a set of methods a type promises to provide — a capability, not a data layout. trait Drawable { fn draw(self) : void; } says “anything Drawable can be drawn” and nothing about its fields. It is Axle’s equivalent of a Kotlin/Java interface, a Rust trait, or a TypeScript interface. A class bundles data (fields) with behaviour; a trait bundles behaviour only, and a class satisfies it by providing the methods.

ClassTrait
Carries fields (data)yesno
Carries method bodiesyesonly optional defaults
Can be instantiated (new)yesno
Reads asis a thingcan do a thing

Trait declaration, default bodies, and dyn Trait live in the OOP recipe; this page uses traits as bounds on generics.

Static vs dynamic dispatch — and the vtable

Dispatch is the act of picking which method body a call runs. There are two ways:

  • Static dispatch — the compiler knows the concrete type at the call site and wires in the one body directly. Zero runtime overhead. A call on a class-typed receiver (point.draw()) dispatches statically.
  • Dynamic dispatch — the concrete type is known only at runtime, so the call reads a runtime type tag on the value and looks the body up in a table. A call on a trait-typed receiver (fn render(d : Drawable) calling d.draw()) dispatches dynamically — d may be any implementer.

The lookup table is the vtable (virtual method table): one row per concrete type, one column per trait method, each cell a function to call. Axle builds it at compile time and selects the row by the value’s runtime tag (__type_id) — the receiver stays a plain object pointer, no fat pointer:

trait Drawable { draw; area }         vtable rows (trait Drawable):

                          column:   draw          area
        Circle  ───────►  row:      Circle.draw   Circle.area
        Square  ───────►  row:      Square.draw   Square.area
                          ▲
    value's __type_id ────┘   picks the row; the method's position picks the column
Static dispatchDynamic dispatch
Concrete type known atcompile timeruntime
Costnone (direct call)one tag read + one indexed load
Triggered byclass-typed receivertrait-typed / dyn Trait receiver
Rust analoguegeneric / impl Traitdyn Trait

Monomorphisation — one compiled copy per type argument

A generic class or function is a template written once over a type parameter T. Monomorphisation is the strategy of stamping out a separate concrete copy for each distinct type argument actually used: Box<i32> and Box<string> compile to two independent bodies, each with T replaced by the real type.

This is the opposite of type erasure (Java/Kotlin generics, where Box<Integer> and Box<String> share one Box<Object> body and the element type is forgotten at runtime). Axle monomorphises exactly like Rust and C++ templates: each instantiation is fully typed, its calls dispatch statically, and primitives are never boxed. The cost is code size (more copies); the benefit is zero abstraction overhead. TypeScript sits at neither extreme — its generics are erased and gone at runtime; Axle keeps and specialises them.

Generic free functions infer their type arguments — or take them explicitly. A free function may be generic — fn id<T>(v : T) : T — and the type argument is inferred from the call (id(42) instantiates T = i32). When a type parameter can’t be inferred — it appears only in the return type or only in the body (fn make<T>() : T, fn pick<T>() { match type T { … } }) — pass it explicitly with a turbofish call: make<i32>(). Inference with no usable position and no turbofish is rejected with E0603; a turbofish on a non-generic function is E0608. Each distinct instantiation is monomorphised, exactly like a generic class.

Generic classes

A class may take one or more type parameters in <…>. Fields, the constructor, and methods use them directly, and each distinct instantiation is compiled on demand:

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();                 // 42
}

Several type parameters are 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);
    return p.getKey();
}

Generic bounds — <T : A + B>

A type parameter may carry one or more bounds: constraints that any type argument must satisfy before the class can be instantiated. Bounds are written after a :, with + separating several. A bound is an acceptance gate checked at the instantiation site:

trait Marker {
    fn mark(self) : i32;
    impl Self {
        i32 => markI32,          // i32 satisfies Marker (delegates to markI32)
    }
}

// A delegate target receives the receiver value as its ordinary first
// parameter (`i32 => markI32` desugars to `markI32(self)`), so it takes an
// `i32`. The parameter is a normal name — not a typed `self`.
fn markI32(x : i32) : i32 {
    return 1;
}

class Tagged<K : Marker> {
    v : K;
    constructor(v : K) {
        self.v = v;
    }
}

fn main() : i32 {
    let ok : Tagged<i32> = new Tagged<i32>(7);   // accepted: i32 is Marker
    return 0;
}

new Tagged<f64>(…) is rejected with E0601 — f64 does not satisfy Marker. The diagnostic points at the instantiation and names the missing bound.

Three ways to satisfy a bound

A type argument satisfies a bound by any of three paths; the compiler accepts the first match:

  1. Explicit : TraitName in the class header — the class declares the trait.
  2. impl Self table inside the trait — a primitive or named type listed there satisfies the trait without a : Trait clause (how i32 satisfies Marker above).
  3. Structural conformance — the class already has every method the trait declares, even without naming the trait; the compiler checks the signatures and accepts the match. A class accepted this way gets a real dispatch table built for it at the instantiation site, so a call through the type parameter dispatches correctly — a structural match is not second-class.

A bound also makes the trait’s methods callable through the type parameter: inside the class body item.mark() (where item : K and K : Marker) resolves against Marker’s contract and, at each instantiation, dispatches to the type argument’s own method — a class argument dispatches dynamically off its RTTI header, and a primitive argument dispatches to its impl Self body. A primitive can satisfy a bound through impl Self only for a single-method trait (the table holds one body per type); a primitive offered for a multi-method trait’s bound is rejected with E0601.

Monomorphisation

Axle generics are fully monomorphised: each distinct type-argument list produces its own compiled version of the class or function. The instantiation cache is keyed on the identity of the concrete argument list, so Box<Point> and Box<Task> are two instances with two bodies — they never share code.

Stdlib generic classes (HashMap<K, V>, ArrayList<T>, HashSet<T>) work the same way: their method bodies travel in the stdlib metadata and are instantiated locally in your compilation, so any type argument works without a fixed precompiled matrix.

FFI dimensions

A stdlib generic’s mangled symbol is spelled from a coarser tag than the argument list — its FFI dimension. This is an ABI detail, not a source rule: it lets two arguments that cross the boundary identically share a symbol name. There are six tags:

DimensionCovers
i32i8 / i16 / i32 / bool / char
u32u8 / u16 / u32
u64u64
i64i64, and every class argument — a handle or a heap pointer is 64-bit at the ABI level
f64f32 / f64
stringstring

Unsigned widths keep a tag of their own rather than sharing the signed slot of the same width: the dimension is part of the mangled name, so collapsing u32 onto i32 would compile ArrayList<u32> to read its elements as signed — it would sort four billion below one.

Storing a class-typed T in a field takes an own constructor parameter to move ownership into the field; the instance’s synthesised destructor then frees that concrete field exactly once at scope exit.

match type T — compile-time type switch

match type T { Pattern => arm, … } (a bare type, no angle brackets) selects a value by the static identity of a type, resolved entirely at compile time. It is the type-level counterpart of a value match, and is written inside a generic body — a class method or a free function — switching on a type parameter in scope. The arm arrow is =>.

class Tag<T> {
    v : T;
    constructor(v : T) {
        self.v = v;
    }

    pub fn name(self) : string {
        return match type T {
            i32    => "integer",
            f64    => "float",
            string => "text",
            _      => "other",
        };
    }
}

fn main() : i32 {
    let a : Tag<i32> = new Tag<i32>(5);
    let b : Tag<f64> = new Tag<f64>(2.0);
    println(a.name());          // "integer"
    println(b.name());          // "float"
    return 0;
}

Rules:

  • Concrete scrutinee — when T is known at the instantiation, the compiler folds the match to the matching arm; no runtime code is emitted (exact type first, then _).
  • Abstract scrutinee — in the un-instantiated template the switch is carried as an HIR node and resolved per instantiation.
  • An arm body may be unsupported "message" to produce a deliberate compile-time error for an unsupported type.
  • A match type with no matching arm and no _ wildcard is a compile error (non-exhaustive).

An unsupported "msg" arm turns an unwanted type argument into a compile-time error at the instantiation that selects it:

class Width<T> {
    constructor() {}
    pub fn bytes(self) : i32 {
        return match type T {
            i32 => 4,
            i64 => 8,
            f64 => 8,
            _   => unsupported "Width: unsupported element type",
        };
    }
}

impl Self — equipping a primitive with a trait

A trait’s impl Self table lets concrete types (primitives, stdlib types) carry a trait method without a : Trait clause. A branch names no method — it is Type => body — so the table serves a trait that declares exactly one method, and only that; on a multi-method trait nothing of it is kept. A method call on a concrete primitive resolves statically to the matching branch:

trait Doubler {
    fn double(self) : i32;
    impl Self {
        i32 => self * 2,         // `self` is the receiver value
    }
}

fn main() : i32 {
    let x : i32 = 21;
    return x.double();           // 42 — dispatched to the i32 branch
}

See OOP → impl Self for the branch forms. An inline expression, a { block }, and a bare-name delegation (i32 => someFn, desugaring to someFn(self, …)) all produce a callable method; unsupported "msg" satisfies a bound but is not callable.

Conditional methods — when T : Bound

A when block gates a group of methods on a generic so they exist on an instantiation only when the bound holds, letting a generic class expose richer behaviour for capable type arguments without making the bound mandatory for every use. The gated methods are materialised onto each satisfying instantiation during monomorphisation; on a non-satisfying one the method is absent (a call is E0015).

trait Printable {
    fn print(self) : void;
}

class Label : Printable {
    text : string;
    constructor(t : string) {
        self.text = t;
    }
    pub fn print(self) : void {
        println(self.text);
    }
}

class Wrapper<T> {
    value : T;
    constructor(own v : T) {                 // `own` — a class-typed T moves into the field
        self.value = v;
    }

    pub fn get(self) : T {
        return self.value;
    }

    when T : Printable {
        // present only on an instantiation whose T satisfies Printable
        pub fn tag(self) : i32 {
            return 1;
        }
    }
}

fn main() : i32 {
    let w : Wrapper<Label> = new Wrapper<Label>(new Label("hi"));
    return w.tag();                          // 1 — Label is Printable, so tag() exists
}

On a non-satisfying instantiation the method is absent — new Wrapper<i32>(5).tag() is rejected with E0015 (i32 is not Printable, so Wrapper<i32> never grows a tag method).

@derive(...) — synthesised boilerplate

@derive(Trait, …) on a class asks the compiler to synthesise the standard implementation of each listed trait. The expansion is field-by-field and happens at parse time, so the methods stay correct as the class evolves:

@derive(Eq, Ord, Hashable, ToString)
class Point {
    x : i32;
    y : i32;
    constructor(x : i32, y : i32) {
        self.x = x;
        self.y = y;
    }
}

fn main() : i32 {
    let a : Point = new Point(1, 2);
    let b : Point = new Point(1, 2);
    let c : Point = new Point(3, 4);

    if (a.equals(b)) { println("equal"); }
    if (a.compareTo(c) < 0) { println("a < c"); }
    println(a.toString());
    return 0;
}

Derivable traits:

TraitSynthesisesBehaviour
Eqequals(other : Class) : boolField-by-field ==, ANDed; true for a field-less class
OrdcompareTo(other : Class) : i32Lexicographic on fields; negative / zero / positive
HashablehashCode() : i64Field-by-field polynomial hash combination
ToStringtoString() : stringField-by-field string concatenation
Cloneclone() : ClassField-by-field copy through the constructor

@derive applies to a concrete class only. The parser refuses it on an exception — an exception implements no trait — and on a generic class, whose synthesised members would have to carry the class’s own type arguments. On a struct or an enum sema rejects it with E0012, the same error an annotation on the wrong site earns anywhere else.

Three more parse errors surround it: a name that is not a derivable trait, a non-identifier argument (@derive("Eq")), and a collision with a hand-written method the trait would have synthesised — equals for Eq, compareTo for Ord, hashCode for Hashable, toString for ToString, clone for Clone.

See also

  • OOP recipe — trait declaration, default bodies, impl Self, dyn Trait, class : Base, Trait
  • Enums — value-type variants, payloads, methods, and trait implementation
  • Pattern matching — the value match that match type T mirrors
  • Collections recipe — using generic stdlib containers
  • Concept index — quick lookup for every keyword
genericstraitsboundsderivematch typedynwhen