Rust’s const generics are a game-changer for creating powerful array abstractions without any runtime overhead. I’ve been using this feature to build generic types and functions that use constant values as parameters, and it’s opened up a whole new world of possibilities.
Let’s dive into how const generics work. At its core, this feature allows us to create types that are parameterized by constant values, not just other types. This means we can do things like create custom fixed-size array types, perform numeric computations at the type level, and design more expressive and efficient APIs.
Here’s a simple example to get us started:
struct Array<T, const N: usize> {
data: [T; N],
}
impl<T, const N: usize> Array<T, N> {
fn new(default: T) -> Self
where
T: Copy,
{
Array { data: [default; N] }
}
}
In this code, we’ve defined an Array
struct that’s generic over both a type T
and a constant N
. The N
parameter represents the size of the array, and it’s known at compile time. This allows us to create arrays of any size without runtime overhead.
One of the coolest things about const generics is how they allow us to eliminate runtime checks. For instance, if we’re working with arrays of different sizes, we can ensure at compile time that we’re only performing operations on arrays of the same size:
fn add<T, const N: usize>(a: &Array<T, N>, b: &Array<T, N>) -> Array<T, N>
where
T: std::ops::Add<Output = T> + Copy,
{
let mut result = Array::new(T::default());
for i in 0..N {
result.data[i] = a.data[i] + b.data[i];
}
result
}
This add
function can only be called with two Array
s of the same size. If we try to add arrays of different sizes, we’ll get a compile-time error. This is a huge win for both safety and performance.
Const generics also shine when it comes to numeric computations at the type level. We can use them to implement complex mathematical operations that are resolved entirely at compile time. Here’s an example of computing factorials:
struct Factorial<const N: u32>;
impl<const N: u32> Factorial<N> {
const VALUE: u32 = N * Factorial::<{ N - 1 }>::VALUE;
}
impl Factorial<0> {
const VALUE: u32 = 1;
}
fn main() {
println!("5! = {}", Factorial::<5>::VALUE);
}
This code computes factorials at compile time, with no runtime cost. It’s a powerful demonstration of how const generics can be used for complex computations.
One area where I’ve found const generics particularly useful is in creating more expressive APIs. For example, we can create a matrix type that knows its dimensions at compile time:
struct Matrix<T, const ROWS: usize, const COLS: usize> {
data: [[T; COLS]; ROWS],
}
impl<T, const ROWS: usize, const COLS: usize> Matrix<T, ROWS, COLS> {
fn transpose(&self) -> Matrix<T, COLS, ROWS>
where
T: Copy,
{
let mut result = Matrix { data: [[T::default(); ROWS]; COLS] };
for i in 0..ROWS {
for j in 0..COLS {
result.data[j][i] = self.data[i][j];
}
}
result
}
}
With this implementation, we can ensure at compile time that matrix operations are only performed on matrices of compatible dimensions. This catches a whole class of errors before they can even occur at runtime.
Const generics also allow us to write more generic code with less duplication. Before const generics, if we wanted to implement functionality for arrays of different sizes, we often had to resort to macros or code generation. Now, we can write a single implementation that works for any size:
fn sum<T, const N: usize>(arr: &[T; N]) -> T
where
T: std::ops::Add<Output = T> + Default + Copy,
{
arr.iter().fold(T::default(), |acc, &x| acc + x)
}
This sum
function works for arrays of any size, and the compiler will generate optimized code for each size it’s used with.
One of the most powerful aspects of const generics is how they interact with Rust’s trait system. We can use const generics to create traits that are parameterized by constants, allowing for even more expressive and type-safe APIs:
trait Vector<T, const N: usize> {
fn dot_product(&self, other: &Self) -> T;
}
impl<T, const N: usize> Vector<T, N> for [T; N]
where
T: std::ops::Mul<Output = T> + std::ops::Add<Output = T> + Default + Copy,
{
fn dot_product(&self, other: &Self) -> T {
self.iter().zip(other.iter())
.map(|(&a, &b)| a * b)
.fold(T::default(), |acc, x| acc + x)
}
}
This Vector
trait can be implemented for any fixed-size array, providing a dot product operation that’s guaranteed to only work between vectors of the same size.
While const generics are incredibly powerful, they do have some limitations. As of my last update, there were still some restrictions on what kinds of expressions could be used as const generic parameters. For example, you couldn’t use arbitrary const functions as parameters. However, the Rust team was actively working on expanding the capabilities of const generics.
One area where const generics really shine is in implementing numerical algorithms. For instance, we can implement a compile-time prime number checker:
struct IsPrime<const N: u32>;
impl<const N: u32> IsPrime<N> {
const VALUE: bool = N > 1 && !HasDivisor::<N, 2>::VALUE;
}
struct HasDivisor<const N: u32, const D: u32>;
impl<const N: u32, const D: u32> HasDivisor<N, D> {
const VALUE: bool = (N % D == 0) || (D * D < N && HasDivisor::<N, { D + 1 }>::VALUE);
}
impl<const N: u32> HasDivisor<N, N> {
const VALUE: bool = false;
}
fn main() {
println!("Is 17 prime? {}", IsPrime::<17>::VALUE);
println!("Is 25 prime? {}", IsPrime::<25>::VALUE);
}
This code checks primality at compile time, with no runtime cost. It’s a great example of how const generics can be used to implement complex algorithms that are resolved entirely during compilation.
Const generics also open up new possibilities for optimizations. Because the compiler knows the exact sizes of arrays at compile time, it can generate more efficient code. For example, it might be able to unroll loops or use SIMD instructions more effectively.
One practical application of const generics is in implementing fixed-size buffers for embedded systems or other memory-constrained environments. Here’s an example of a circular buffer implemented with const generics:
struct CircularBuffer<T, const N: usize> {
data: [T; N],
read_index: usize,
write_index: usize,
}
impl<T, const N: usize> CircularBuffer<T, N>
where
T: Default + Copy,
{
fn new() -> Self {
CircularBuffer {
data: [T::default(); N],
read_index: 0,
write_index: 0,
}
}
fn push(&mut self, item: T) {
self.data[self.write_index] = item;
self.write_index = (self.write_index + 1) % N;
if self.write_index == self.read_index {
self.read_index = (self.read_index + 1) % N;
}
}
fn pop(&mut self) -> Option<T> {
if self.read_index == self.write_index {
None
} else {
let item = self.data[self.read_index];
self.read_index = (self.read_index + 1) % N;
Some(item)
}
}
}
This implementation guarantees at compile time that the buffer will always have the correct size, eliminating the need for runtime checks and potential out-of-bounds errors.
Const generics can also be used to implement compile-time dimensional analysis. This can be incredibly useful in scientific computing or any domain where units of measurement are important. Here’s a simple example:
struct Length<const M: i32>;
struct Time<const S: i32>;
struct Velocity<const M: i32, const S: i32>;
impl<const M1: i32, const S: i32> std::ops::Div<Time<S>> for Length<M1> {
type Output = Velocity<M1, { S * -1 }>;
fn div(self, _rhs: Time<S>) -> Self::Output {
Velocity
}
}
fn main() {
let _distance = Length::<1000>; // 1000 meters
let _time = Time::<3600>; // 3600 seconds
let _velocity: Velocity<1000, -3600> = _distance / _time; // meters per second
}
This code ensures at compile time that we’re only performing operations between compatible units, catching potential errors before they can occur at runtime.
In conclusion, const generics are a powerful feature that allows us to write more expressive, efficient, and type-safe code in Rust. They enable us to create zero-cost abstractions for arrays and other fixed-size data structures, implement complex compile-time computations, and design more flexible and powerful APIs. As we continue to explore the possibilities of const generics, I’m excited to see how they’ll shape the future of systems programming in Rust.