Andrew WatsonAndrew Watson
awwsmm.com / blog / what-are-const-generics-and-how-are-they-used-in-rust

What Are Const Generics and How Are They Used in Rust?

Understanding const generics in the Rust programming language with a few examples

[ history ]

I was working through an example in the repo for the Bevy game engine recently and came across this code

/// Update the speed of `Time<Virtual>.` by `DELTA`
fn change_time_speed<const DELTA: i8>(mut time: ResMut<Time<Virtual>>) {
    let time_speed = (time.relative_speed() + DELTA as f32)
        .round()
        .clamp(0.25, 5.);

    // set the speed of the virtual time to speed it up or slow it down
    time.set_relative_speed(time_speed);
}

This is a function (fn) which takes a mutable argument called time. The type of time, ResMut<Time<Virtual>>, comes after the colon, :.

The thing that caught my eye here was the generic parameter: <const DELTA: i8>. What is that?

Here's another example from Bevy

pub unsafe fn read<T>(self) -> T {
    let ptr = self.as_ptr().cast::<T>().debug_ensure_aligned();
    // -- snip --
}

The read function takes a generic type parameter T and uses it in two places: in the body of the function, and as a return type. Programmers who are familiar with generics know that an unconstrained T is a placeholder that means "any type"; it could be String or bool or anything else.

In languages with a global type hierarchy, like Java, a value t: T has some operations which can be performed on it, like .toString(), because every type T in Java extends the base Object type. Rust has no such global type hierarchy, and no root Object type, so there's not much at all you can do with an instance of an unconstrained type.

Going back to the first example, const DELTA: i8 clearly already has a type, appearing after the colon, :. (It is i8, an 8-bit signed integer.) So what is it doing sitting between those angle brackets (<>) where generic parameters usually sit?

In this position, const DELTA: i8 is acting as a const generic.

What Are Const Generics?

Const generic parameters are a new (ish) kind of generic parameter in Rust, similar to type parameters (e.g. T) and lifetime parameters (e.g. 'a). In the same way that a function (or method, struct, enum, impl, trait, or type alias) can use a generic type parameter, it can also use const generic parameters.

Const generic parameters are what power [T; N] type annotation of arrays in Rust. They are why [T; 3] (an array of three T values) and [T; 4] (an array of four T values) are different types, but different types which can be handled generically as specific implementations of [T; N].

Const generic parameters allow items to be generic over constant values, rather than over types.

The difference can be subtle. Here's a simple example

fn add<const b: i8>(a: i8) -> i8 {
  a + b
}

Here, b is not a "type parameter"; it is a value, and so it can be treated exactly as a value, used in expressions, and so on. But since it is const, the value of b must be known at compile time. For example, the following will not compile

fn example(a: i8, b: i8) -> i8 {
    add::<b>(a) // error: attempt to use a non-constant value in a constant
}

The logic in this function, of course, could also be expressed like

fn add(a: i8, b: i8) -> i8 {
  a + b
}

...so what's the benefit of const generics? Let's look at some other examples

Using Const Generics to Enforce Correctness

There are a few examples from linear algebra where const generics are very helpful. For example, the dot product of two vectors a and b, is defined for any two vectors of any dimensionality (length), provided they have the same dimensionality

struct Vector<const N: usize>([i32; N]);

impl<const N: usize> Vector<N> {
    fn dot(&self, other: &Vector<N>) -> i32 {
        let mut result = 0;
        for index in 0..N {
            result += self.0[index] * other.0[index]
        }
        result
    }
}

We get a compile-time error if we try to find the dot product of two vectors with different numbers of elements

fn main() {
    let a = Vector([1, 2, 3]);
    let b = Vector([4, 5, 6]);
    
    assert_eq!(a.dot(&b), 32); // ok: a and b have the same length
    
    let c = Vector([7, 8, 9, 10]);
    
    a.dot(&c); // error: expected `&Vector<3>`, but found `&Vector<4>`
}

Const generics can be applied to matrix multiplication, as well. Two matrices can be multiplied only if the first one has M rows and N columns and the second has N rows and P columns. The resulting matrix will have M rows and P columns.

struct Matrix<const nRows: usize, const nCols: usize>([[i32; nCols]; nRows]);

impl<const M: usize, const N: usize> Matrix<M, N> {
    fn multiply<const P: usize>(&self, other: &Matrix<N, P>) -> Matrix<M, P> {
        todo!()
    }
}

Here, we again get a compile-time error if we ignore this constraint

fn main() {
    let a = Matrix([[1, 2, 3], [4, 5, 6]]); // 2 x 3 matrix
    let b = Matrix([[1, 2, 3, 4], [2, 3, 4, 5], [3, 4, 5, 6]]); // 3 x 4 matrix
    
    a.multiply(&b); // ok: 2 x 4 matrix
    
    let c = Matrix([[1, 2, 3], [2, 3, 4]]); // 2 x 3 matrix
    
    a.multiply(&c); // error: expected `&Matrix<3, <unknown>>`, but found `&Matrix<2, 3>`
}

These constraints can be enforced at runtime without const generics, but const generics can help shift these issues left, catching them earlier in the development process, tightening the inner dev loop.

Using Const Generics to Conditionally Implement traits

(Adapted from Nora's example here.)

Const generics also enable really powerful patterns, like compile-type checks on values in signatures. For example...

struct Assert<const COND: bool> {}

...this struct takes a constant generic bool parameter, COND. If we define a trait IsTrue...

trait IsTrue {}

impl IsTrue for Assert<true> {}

...we can conditionally implement traits by requiring some Assert to impl IsTrue, like so

trait IsOdd<const N: i32> {}

impl<const N: i32> IsOdd<N> for i32 where Assert<{N % 2 == 1}>: IsTrue {}

The above Assert<{N % 2 == 1}> requires #![feature(generic_const_exprs)] and the nightly toolchain. See https://github.com/rust-lang/rust/issues/76560 for more info.

Above, trait IsOdd is implemented for the i32 type, but only on values N which satisfy N % 2 == 1. We can use this trait to get compile-time checks that constant (hard-coded) i32 values are odd

fn do_something_odd<const N: i32>() where i32: IsOdd<N> {
    println!("oogabooga!")
}

fn do_something() {
    do_something_odd::<19>();
    do_something_odd::<42>(); // does not compile
    do_something_odd::<7>();
    do_something_odd::<64>(); // does not compile
    do_something_odd::<8>(); // does not compile
}

The above will generate a compiler error like

error[E0308]: mismatched types
  --> src/main.rs:70:5
   |
70 |     do_something_odd::<42>();
   |     ^^^^^^^^^^^^^^^^^^^^^^^^ expected `false`, found `true`
   |
   = note: expected constant `false`
              found constant `true`

Using Const Generics to Avoid Complex Return Types

Finally, const generics can be used to make code more readable, and more performant. The example from the beginning of this post comes from Bevy, and the reason const generics are used there is because Bevy is expecting a function pointer as an argument to a method

fn main() {
    App::new()
        // -- snip --
        .add_systems(
            Update,
            (
                // -- snip --
                change_time_speed::<1>.run_if(input_just_pressed(KeyCode::ArrowUp)),
                // -- snip --
            ),
        )
        .run();
}

change_time_speed::<1>, above, is a function pointer. We can rearrange this method to take an argument, rather than using a const generic parameter...

change_time_speed_2(1).run_if(input_just_pressed(KeyCode::ArrowUp)),

...but then we would have to change the return type as well

/// Update the speed of `Time<Virtual>.` by `DELTA`
fn change_time_speed_2(delta: i8) -> impl FnMut(ResMut<Time<Virtual>>) {
    move |mut time| {
        let time_speed = (time.relative_speed() + delta as f32)
            .round()
            .clamp(0.25, 5.);

        // set the speed of the virtual time to speed it up or slow it down
        time.set_relative_speed(time_speed);
    }
}

To many, the original function may be more readable

/// Update the speed of `Time<Virtual>.` by `DELTA`
fn change_time_speed<const DELTA: i8>(mut time: ResMut<Time<Virtual>>) {
    let time_speed = (time.relative_speed() + DELTA as f32)
        .round()
        .clamp(0.25, 5.);

    // set the speed of the virtual time to speed it up or slow it down
    time.set_relative_speed(time_speed);
}

Remember, as well, that Rust uses monomorphization of generics to improve runtime performance. So not only is the const generic version of this function more readable, but it's possible (though I haven't benchmarked) that it's more performant as well. Either way, it's good to know that there are multiple ways to attack a problem, and to be able to weigh the pros and cons of each approach.

Hopefully this discussion has helped you to understand what const generics are, and how they can be used in Rust.

awwsmm.com / blog / what-are-const-generics-and-how-are-they-used-in-rust