DEV Community

Cover image for What Are Const Generics and How Are They Used in Rust?
Andrew (he/him)
Andrew (he/him)

Posted on • Originally published at awwsmm.com

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

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);
}
Enter fullscreen mode Exit fullscreen mode

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 --
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

fn add(a: i8, b: i8) -> i8 {
  a + b
}
Enter fullscreen mode Exit fullscreen mode

...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
    }
}
Enter fullscreen mode Exit fullscreen mode

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>`
}
Enter fullscreen mode Exit fullscreen mode

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!()
    }
}
Enter fullscreen mode Exit fullscreen mode

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>`
}
Enter fullscreen mode Exit fullscreen mode

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> {}
Enter fullscreen mode Exit fullscreen mode

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

trait IsTrue {}

impl IsTrue for Assert<true> {}
Enter fullscreen mode Exit fullscreen mode

...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 {}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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)),
Enter fullscreen mode Exit fullscreen mode

...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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)