Const Generics in Rust
One of the things I particularly like about Rust is how much work its type system can do for you. If you know something about your data at compile time, there’s a good chance you can encode that information into a type and have the compiler enforce it for you.
Const generics are a particularly good example of this.
They allow you to parameterize types and functions over constant values rather than just types. This might sound like a small addition to generics, but it opens up some interesting possibilities, especially when you’re working with fixed-size arrays, buffers, matrices, and other data structures whose sizes are known at compile time.
Let’s understand what that means.
The Problem With Fixed-Size Arrays
Suppose you want to write a function that takes an array of integers and returns their sum.
You could write something like this:
fn sum(values: [i32; 3]) -> i32 {
values.iter().sum()
}
fn main() {
let values = [10, 20, 30];
println!("{}", sum(values));
}This works perfectly fine, except that our function only accepts arrays containing exactly three integers.
If you pass it an array with four elements, the compiler will complain.
let values = [10, 20, 30, 40];
println!("{}", sum(values));The reason is that Rust considers [i32; 3] and [i32; 4] different types. The array’s length is part of its type.
We could write separate functions for different array lengths, but that would be repetitive. We could also accept a slice, which is probably what we should do if all we want is to sum some integers.
However, what if we actually want to preserve the array’s length in our function’s type?
That’s where const generics come in.
What are Const Generics?
With const generics, we can rewrite our function like this:
fn sum<const N: usize>(values: [i32; N]) -> i32 {
values.iter().sum()
}
fn main() {
let a = [10, 20, 30];
let b = [10, 20, 30, 40, 50];
println!("{}", sum(a));
println!("{}", sum(b));
}The important part is:
<const N: usize>If you’ve worked with regular generics before, you’ve probably seen something like T used as a placeholder for a type.
Const generics work similarly, except that instead of a type, we’re introducing a placeholder for a constant value.
In our example, N represents the length of the array, and usize is the type of that length.
When we pass an array containing three elements, the compiler infers that N is 3. When we pass an array containing five elements, it infers that N is 5.
We don’t need to specify the length explicitly because the compiler can figure it out from the argument.
And that’s pretty much the fundamental idea behind const generics.
Combining Type Generics With Const Generics
We can take this a step further.
Our previous function works with arrays of any length, but it still only accepts i32 values. What if we want to support other types as well?
We can combine regular generics with const generics:
fn first<T, const N: usize>(values: &[T; N]) -> Option<&T> {
values.first()
}
fn main() {
let numbers = [1, 2, 3];
let names = ["Alice", "Bob"];
println!("{:?}", first(&numbers));
println!("{:?}", first(&names));
}Here, T represents the type of the elements, while N represents the number of elements.
These are two completely different kinds of generic parameters.
The compiler can instantiate our function for an array of three integers or an array of two string slices without us having to write separate implementations.
Now, you might be wondering why we didn’t simply use a slice here. And you’d be right to ask that. A slice would make more sense for this particular function because we don’t actually need the length to be part of its type.
The real value of const generics becomes much more apparent when we’re defining our own data structures.
Building a Fixed-Size Buffer
Let’s say we’re building a buffer that can hold a fixed number of bytes.
Without const generics, we’d have to decide on its capacity beforehand or use a dynamically sized collection like Vec.
With const generics, we can make the capacity configurable while still keeping it part of the type.
struct Buffer<const N: usize> {
data: [u8; N],
len: usize,
}
impl<const N: usize> Buffer<N> {
fn new() -> Self {
Self {
data: [0; N],
len: 0,
}
}
fn push(&mut self, byte: u8) -> Result<(), &'static str> {
if self.len == N {
return Err("Buffer is full");
}
self.data[self.len] = byte;
self.len += 1;
Ok(())
}
fn as_slice(&self) -> &[u8] {
&self.data[..self.len]
}
}
fn main() {
let mut buffer = Buffer::<4>::new();
buffer.push(10).unwrap();
buffer.push(20).unwrap();
println!("{:?}", buffer.as_slice());
}There are a few interesting things going on here.
First, the capacity of the buffer is part of its type. Buffer<4> and Buffer<8> are different types, even though they share the same implementation.
Second, the backing array has a fixed size. We’re not allocating a new backing array every time we insert an element.
Third, our implementation works for different capacities without having to duplicate any code.
Now, there’s an important distinction between capacity and length here. The capacity is known at compile time, but the number of elements currently stored in the buffer can change at runtime.
That’s why we still need the len field.
Const generics don’t magically make every property of our data known at compile time. They only let us encode the values we’ve chosen as const parameters into the type.
Also, notice that we’re still checking whether the buffer is full at runtime. Having a fixed capacity doesn’t automatically prevent us from trying to insert more elements than it can hold.
If we wanted the compiler to enforce that as well, we’d need a different design.
When the Compiler Can Catch Your Mistakes
Consider a function that takes two arrays of the same length and adds their corresponding elements together.
fn add<const N: usize>(
a: [i32; N],
b: [i32; N],
) -> [i32; N] {
std::array::from_fn(|i| a[i] + b[i])
}
fn main() {
let a = [1, 2, 3];
let b = [4, 5, 6];
let result = add(a, b);
println!("{result:?}");
}Both arguments must have the same length because they share the same const parameter, N.
Now try passing arrays with different lengths:
let a = [1, 2, 3];
let b = [4, 5];
let result = add(a, b);This won’t compile.
And that’s exactly what we want.
Instead of accepting two arbitrary arrays and checking their lengths at runtime, we’ve expressed the requirement directly in the function’s signature.
This is one of the things I appreciate about Rust’s type system. You can push certain correctness checks to compile time and make entire categories of invalid inputs impossible to pass to a function.
Of course, this only works when the relevant information is available at compile time. If you’re receiving two dynamically sized collections from a network request, for example, you’ll still need to validate their lengths at runtime.
Const Generics Aren’t the Same as Constants
One thing that can be confusing at first is the difference between a regular constant and a const generic parameter.
Consider this:
const BUFFER_SIZE: usize = 1024;
let buffer = [0u8; BUFFER_SIZE];Here, BUFFER_SIZE is a specific constant value.
But when we write:
struct Buffer<const N: usize> {
data: [u8; N],
}We’re not defining a constant called N with a particular value. We’re defining a parameter that will receive a constant value when the type is instantiated.
For example:
let small = Buffer::<128> {
data: [0; 128],
};
let large = Buffer::<1024> {
data: [0; 1024],
};Both values use the same generic struct definition, but their types have different capacities.
You can think of const generics as a way to make your types configurable at compile time.
What About Const Expressions?
At this point, you might be tempted to write something like this:
fn extend<const N: usize>(
values: [u8; N],
) -> [u8; N + 1] {
// ...
}Unfortunately, this is where things get a little more complicated.
Rust’s stable const generics still have restrictions on using generic parameters in const expressions that appear in types.
You can use a standalone const parameter such as N as an array length, but expressions like N + 1 aren’t generally supported in that position on stable Rust.
This doesn’t mean that you can’t perform arithmetic involving const generic parameters at all.
For example, this is fine:
fn print_double<const N: usize>() {
println!("{}", N * 2);
}
fn main() {
print_double::<10>();
}The restriction concerns where the expression is used, not whether you’re allowed to do arithmetic with N.
There are nightly Rust features for more advanced generic const expressions, but I wouldn’t recommend depending on them for an ordinary production project unless you have a specific reason to use nightly.
For most practical applications, the stable subset of const generics is already quite useful.
Should You Use Const Generics Everywhere?
Not really.
Just because you can encode a value into a type doesn’t mean you should.
If you’re writing a function that processes a collection of items and doesn’t care about its exact length, accepting a slice is usually the simpler choice.
If you need a collection that grows and shrinks dynamically, a Vec will probably be more appropriate than a fixed-size array.
Const generics become useful when the constant value is genuinely an important part of your abstraction.
A matrix with a fixed number of rows and columns is a good example. So is a fixed-capacity buffer or a cryptographic data structure that requires inputs of a particular size.
But making everything generic over constants just because the language allows it can unnecessarily complicate your APIs. It can also lead to additional code generation for different instantiations, depending on how the compiler optimizes them.
The goal isn’t to make the type system do as much work as possible. It’s to make it enforce the things that actually matter.
And I think that’s the best way to approach const generics in Rust: use them when they let you express a meaningful constraint at compile time, and stick with simpler abstractions when they don’t.