Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Generics and traits

Generics in Vx are monomorphized: each instantiation becomes its own concrete function or type at compile time. There is no boxing, no vtable and no dynamic dispatch unless you ask for it.

Generic types and functions

#![allow(unused)]
fn main() {
struct Pair<T> {
    first: T,
    second: T,
}

enum Maybe<T> {
    Just(T),
    Nothing,
}
}

Instantiate by naming the argument:

enum Maybe<T> {
    Just(T),
    Nothing,
}

fn main() -> i32 {
    let m = Maybe<i32>::Just(42);

    let mut result = 0;
    match m {
        Maybe<i32>::Just(val) => { result = val; },
        Maybe<i32>::Nothing => { result = 0; },
    }
    return result;
}

Generic impls

An impl block can be generic, or specific to one instantiation:

#![allow(unused)]
fn main() {
impl<T> Pair<T> {
    fn first(self: &Pair<T>) -> T {
        return self.first;
    }
}

impl Pair<i32> {
    fn sum(self: &Pair<i32>) -> i32 {
        return self.first + self.second;
    }
}
}

When several impls could apply, the most specific one wins.

Bounds

Constrain a parameter with :, and write several with +:

#![allow(unused)]
fn main() {
impl<T : Float> Tensor<T, [?, ?]> {
    // ...
}
}
#![allow(unused)]
fn main() {
fn describe<T : Doubles + Tags>(x : T) -> i32 {
    return x.twice() + x.tag();
}
}

A bound can give the trait type arguments, or say what its associated types must be. A bound may also name another parameter’s associated type:

#![allow(unused)]
fn main() {
fn first<I : Iterator<Item = i64>>(it : I) -> i64 { /* ... */ }
fn total<I : Iterator, S : Sum<I::Item>>(it : I) -> S { /* ... */ }
}

Every bound has to hold at the call site, and each one that does not is reported. Note that a bound constrains callers, not the body: a generic body is checked after monomorphization against the concrete type, so it can call any method that type has, whether or not a bound named it.

Traits

A trait method may carry a body. An impl that does not write that method inherits it:

#![allow(unused)]
fn main() {
trait Counts {
    fn value(self : Self) -> i32;
    fn doubled(self : Self) -> i32 {
        return self.value() * 2;
    }
}
}

self.value() inside the default dispatches to whichever impl inherited it, so one body serves every implementor. An impl that writes doubled itself keeps its own.

Traits describe shared behaviour, and are implemented with impl ... for:

#![allow(unused)]
fn main() {
impl<T> Iterator<VecIter<T>, T> for VecIter<T> {
    // ...
}
}

That is how Vec participates in for loops and in the iterator adaptors — map and friends are ordinary generic functions over the Iterator trait rather than compiler magic.

Const generics

A value can be a type parameter, not only a type. This is what makes statically-shaped tensors work:

#![allow(unused)]
fn main() {
fn dot<const N : i32>(a : Tensor<f32, [N]>, b : Tensor<f32, [N]>) -> f32 {
    let mut acc = 0.0;
    for i in 0..N {
        acc += a[i] * b[i];
    }
    return acc;
}
}

Because N is part of the type, passing two tensors of different lengths to dot is a compile error rather than a runtime check you forgot to write. Each distinct N monomorphizes to its own function, so the loop bound is a constant the optimizer can see.

How this stays fast to compile

Every symbol, nominal type and monomorphized instantiation is identified by a flat 256-bit identifier rather than by a pointer into a shared tree. Combined with a nominal type system and mandatory boxing for recursive types, that decouples modules from one another: the frontend resolves and checks them in parallel across cores, with no query engine, no locks and no shared mutable state.

The observable consequence is that the same source produces byte-identical MLIR whether it is compiled serially or in parallel — which is asserted in the test suite rather than assumed.