Const Generics and const fn
Generics and Trait Bounds Deep Dive parameterized code over types. Const generics parameterize over values, most commonly an array length, so a single piece of code works for [T; 2], [T; 16], and [T; 1024] without losing the compile-time-known size. Its companion, const fn, lets ordinary functions run at compile time, computing constants and array sizes before the program ever starts. Together they push more work, and more correctness checks, from runtime to compile time. This tutorial covers what each does and the boundaries on what's allowed.
Const Generics: Parameterizing Over a Value
A const generic parameter is declared with const NAME: Type in the angle brackets, alongside (or instead of) type parameters. The most important use is writing code generic over array length, something that was impossible to do cleanly before const generics stabilized.
struct Buffer<const N: usize> {
data: [u8; N],
}
impl<const N: usize> Buffer<N> {
fn new() -> Self {
Buffer { data: [0; N] }
}
fn len(&self) -> usize {
N // the length is known at compile time, usable as a value
}
}
let small: Buffer<16> = Buffer::new();
let large: Buffer<4096> = Buffer::new();
Buffer<16> and Buffer<4096> are distinct types, each carrying its size in the type system. N is usable inside the impl as an ordinary usize value (here, returned from len), but it's fixed at compile time, there's no runtime field storing the length, the array [u8; N] simply is that size. This is what makes a fixed-size buffer zero-overhead compared to a Vec: no capacity field, no heap allocation, the size is a property of the type itself.
The Problem Const Generics Solve
Before const generics, code couldn't be generic over array length, so the standard library had to implement traits for each size individually, infamously, [T; 0] through [T; 32] were special-cased, and arrays longer than 32 simply didn't get Debug, Default, or PartialEq. Const generics replaced all that boilerplate with a single impl:
// roughly: one impl now covers every array length
impl<T: Debug, const N: usize> Debug for [T; N] {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { /* ... */ }
}
That's why, on a modern compiler, [0u8; 100] and [0u8; 5000] both print with {:?} and compare with ==, the arbitrary 32-element ceiling is gone. When you write your own trait that should work for arrays of any size, a const N: usize parameter is how you get the same coverage in one impl instead of thirty-three.
const fn: Functions That Run at Compile Time
A const fn is a function the compiler is allowed to evaluate at compile time. It can still be called at runtime like any function, the const just additionally permits its use in const contexts: array lengths, const/static initializers, and const generic arguments.
const fn next_power_of_two(mut n: usize) -> usize {
let mut p = 1;
while p < n {
p *= 2;
}
p
}
const BUFFER_SIZE: usize = next_power_of_two(500); // computed at compile time: 512
static LOOKUP: [u8; next_power_of_two(100)] = [0; next_power_of_two(100)]; // size = 128
Because next_power_of_two is const, it can compute an array's length, a place where only compile-time-known values are allowed. A regular fn couldn't be used there at all. The value is baked into the binary; there's no runtime cost to the computation, it already happened during compilation.
What const fn Can and Can't Do
const fn runs in a restricted environment, because compile-time evaluation can't do things that only make sense at runtime. The allowed surface has grown a lot over recent Rust versions, but the boundary is real. Broadly:
- Allowed: arithmetic,
if/match,while/loop,letbindings, calling otherconst fns, most operations on primitives, and (increasingly) basic operations on&/&mut. - Not allowed: heap allocation (no
Vec,Box, orString, there's no allocator at compile time), calling non-constfunctions, trait methods that aren'tconst, and anything involving I/O, threads, or randomness.
const fn sum_to(n: u32) -> u32 {
let mut total = 0;
let mut i = 1;
while i <= n {
total += i;
i += 1;
}
total
} // fine: arithmetic and a loop, no allocation or runtime-only operations
A useful intuition: a const fn must be a pure computation over values the compiler can produce on its own. The moment it needs the heap, the OS, or a non-const callee, it can't be const. When the compiler rejects a const fn, the error names the specific disallowed operation, the fix is usually to move that part out to a regular function and keep only the pure computation const.
When to Reach for Each
Const generics earn their place when a size is genuinely known at compile time and you want it reflected in the type, fixed-size buffers, matrices with compile-time dimensions, cryptographic blocks, embedded code where heap allocation isn't available. If a size is only known at runtime, that's still Vec's job; const generics aren't a replacement for dynamic sizing.
const fn is worth it for values you'd otherwise hardcode as magic numbers or compute once at startup: lookup-table sizes, bit masks, configuration derived from other constants. Marking a function const costs nothing if it stays within the allowed subset, so when a small pure helper can be const, making it const adds flexibility (it becomes usable in const contexts) with no downside. Don't contort a function to fit the const subset just to earn the keyword, though, if it naturally needs allocation or runtime data, leave it a regular fn.
Key Takeaways
- Const generics (
const N: usize) parameterize code over a value, most importantly array length, makingBuffer<16>andBuffer<4096>distinct, zero-overhead types that carry their size in the type system. - They replaced the standard library's old per-size array impls (the infamous 0–32 ceiling), one
const Nimpl now covers arrays of every length, including for your own traits. - A
const fnmay be evaluated at compile time, which lets it produce array lengths,const/staticinitializers, and const generic arguments that a regularfncan't. const fnruns in a restricted subset: arithmetic, control flow, and otherconst fncalls are fine; heap allocation, non-constcalls, and I/O are not. Think "pure computation over compile-time-known values."- Use const generics when a size is compile-time-known and belongs in the type (not as a
Vecreplacement for runtime sizes); make a small pure helperconstwhen it can be, for the extra flexibility at no cost.