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.