[T; N] without copying the code by hand;Learn Rust Series):At the very end of last episode I left you with a teaser. I said we would "push on that idea from a different angle entirely: what if a type could carry not just a property, but an actual value baked into it -- an array whose length is part of its type, checked before the program runs?" Well, today is that day. We have spent the last handful of episodes on the trait machinery -- markers, blanket impls, coherence -- and const generics are a smaller feature by comparison, but they close a gap you have almost certainly bumped into already without having the words for it.
Here is the shape of the whole thing in one sentence. Ordinary generics are parameterized by types (Vec<T> works for any T), and const generics add a second flavour of parameter: a value, almost always an integer, that is fixed and known at compile time. That one addition is what lets a single function accept an array of any length, and it is what lets you build fixed-size structures whose dimensions live in the type itself. When people say Rust "blurs the line between values and types", this is a big part of what they mean. Let me clear last episode's homework first, as always, and then we go from the simplest case to the genuinely useful one ;-)
Episode 25 was marker traits -- Sized, Send, Sync and Copy. Three exercises, and here is each one with full, runnable code.
Exercise 1 asked you to write fn announce<T: ?Sized + std::fmt::Display>(value: &T) that prints the value, call it with a str literal, a String and an i32, and then observe which call breaks when you drop the ?Sized:
use std::fmt::Display;
fn announce<T: ?Sized + Display>(value: &T) {
println!("announcing: {value}");
}
fn main() {
announce("a string slice"); // str is NOT Sized
announce(&String::from("owned")); // String IS Sized
announce(&2026); // i32 IS Sized
}
All three lines compile because ?Sized relaxed the invisible T: Sized bound that every generic normally carries. Take the ?Sized away, making it plain fn announce<T: Display>(value: &T), and the first call is the one that breaks: the type behind "a string slice" is the bare, unsized str, and without the relaxation the compiler refuses it. The String and i32 calls keep working either way, because both of those are sized. That is the whole lesson of Sized in miniature -- it is there on every generic, quietly, until you opt out.
Exercise 2 wanted a helper fn assert_sync<T: Sync>(_v: &T) {}, confirming an Arc<i32> passes it, then a struct Shared { data: Rc<i32> } that should be rejected by the same helper:
use std::sync::Arc;
use std::rc::Rc;
fn assert_sync<T: Sync>(_v: &T) {}
struct Shared {
data: Rc<i32>,
}
fn main() {
let a = Arc::new(5);
assert_sync(&a); // Arc is Sync: this compiles fine
let s = Shared { data: Rc::new(1) };
// assert_sync(&s); // would NOT compile: Rc field makes Shared non-Sync
println!("count for the Rc inside: {}", Rc::strong_count(&s.data));
}
Arc<i32> sails through because it is Sync. The Shared struct does not, and this is the auto-trait propagation from last episode doing its job: Send and Sync are derived structurally from a type's fields, so a single non-Sync field (the Rc) poisons the whole struct. Uncomment that middle line and the error names the Rc<i32> field as the culprit -- the compiler tracks the property up through composition without you lifting a finger.
Exercise 3 was about Copy as a marker: derive Clone, Copy on a small all-integer struct, prove assignment copies rather than moves, then add a String field and watch the derive fail:
#[derive(Clone, Copy)]
struct Pixel {
r: u8,
g: u8,
b: u8,
}
fn main() {
let p = Pixel { r: 255, g: 128, b: 0 };
let q = p; // Copy: p is NOT moved, it stays valid
println!("p.r = {}, q.g = {}", p.r, q.g); // both usable: 255, 128
// Adding `name: String` to Pixel and keeping #[derive(Copy)] fails to
// compile, because String owns a heap buffer that cannot be duplicated
// by a dumb bit-for-bit copy -- exactly what Copy promises the compiler.
}
Because Pixel is nothing but three u8s, it is bit-copyable, so let q = p; leaves p fully alive afterward. The moment you bolt a String onto it the Copy derive stops compiling -- String owns heap memory, and Copy means "duplicate me by copying my bytes", which would leave two owners of one buffer. That is the invariant Copy protects. Right, homework cleared -- now for values that live inside types ;-)
Cast your mind back to episode 7, where we met arrays. In Rust, [i32; 3] and [i32; 4] are different types. The length is baked into the type, which is wonderful for safety -- you can never index past the end of a fixed array by accident, because the size is known -- but it is awkward the moment you want to write one function that works on arrays of any size. Before const generics you had two bad options: write a separate function per length (madness), or give up and take a slice &[i32] (which throws away the compile-time length you were trying to keep).
Const generics give you the third option. You parameterize over the length itself, with a parameter declared const N: usize:
fn sum_array<const N: usize>(arr: [i32; N]) -> i32 {
arr.iter().sum()
}
fn main() {
println!("{}", sum_array([1, 2, 3])); // N inferred as 3 -> 6
println!("{}", sum_array([10, 20, 30, 40])); // N inferred as 4 -> 100
}
Read <const N: usize> as "this function is generic over a usize value called N". At each call site the compiler fills N in from the array's actual length -- you never pass it by hand, it is inferred from the argument. And just like a type generic gets monomorphized per type (episode 8), a const generic gets monomorphized per value: the compiler stamps out one specialized copy of sum_array for N = 3, another for N = 4, each with the length hard-coded. There is no runtime length parameter being threaded through; the size is a compile-time constant inside every stamped-out copy.
That is already useful, but notice we are still only consuming an array. The real leverage shows up when you put a const parameter on a type of your own.
Say you want a fixed-size byte buffer -- the kind of thing you write constantly in embedded work, in networking, in anything that touches raw bytes. You want the capacity known at compile time, you want it on the stack (no heap allocation), and you do not want to carry a separate length field around when the length is already a constant. A const generic parameter gives you exactly that:
#[derive(Debug)]
struct Buffer<const N: usize> {
data: [u8; N],
}
impl<const N: usize> Buffer<N> {
fn new() -> Buffer<N> {
Buffer { data: [0; N] } // [0; N] builds an N-length array of zeros
}
fn capacity(&self) -> usize {
N // the const parameter is usable as an ordinary value here
}
}
fn main() {
let buf = Buffer::<4>::new();
println!("capacity {}", buf.capacity()); // 4
println!("{:?}", buf.data); // [0, 0, 0, 0]
}
Two things are worth slowing down on. First, inside the impl block, N is available in two roles at once: as a plain value (the capacity method just returns it) and as an array length ([u8; N] and [0; N]). Same symbol, both meanings, no ceremony. Second, and this is the important bit, Buffer<4> and Buffer<8> are genuinely distinct types with different sizes -- a Buffer<4> is four bytes wide, a Buffer<8> is eight, and the compiler knows both before the program runs. Carrying the capacity in the type costs nothing at runtime, because there is nothing to carry: it is not a field, it is part of the type's identity.
Compare that to a Vec<u8>, which stores its length and capacity as runtime fields and keeps its bytes on the heap. A Vec is the right tool when the size genuinely varies at runtime. A const-generic Buffer is the right tool when the size is fixed and you want it proven and free.
You are not limited to one const parameter. You can have several, and the textbook example is a fixed-size matrix, where both the row count and the column count belong in the type:
struct Matrix<const R: usize, const C: usize> {
cells: [[f64; C]; R],
}
impl<const R: usize, const C: usize> Matrix<R, C> {
fn zeros() -> Matrix<R, C> {
Matrix { cells: [[0.0; C]; R] }
}
fn dims(&self) -> (usize, usize) {
(R, C)
}
}
fn main() {
let m = Matrix::<2, 3>::zeros();
println!("{:?}", m.dims()); // (2, 3)
}
Here [[f64; C]; R] reads as "an array of R rows, each row an array of C floats" -- both dimensions come straight from the const parameters. And now the payoff that makes numeric code so much safer: a Matrix<2, 3> is a completely different type from a Matrix<3, 2>. A function that expects one simply cannot be handed the other; it is a compile error, not a runtime surprise. Dimension mismatches -- a classic, maddening source of bugs in every matrix library ever written -- get caught by the type checker before you run anything. If you wrote a multiply that took a Matrix<R, K> and a Matrix<K, C> and returned a Matrix<R, C>, the shared K would force the inner dimensions to agree. The types do the bookkeeping you would otherwise do in your head (and get wrong at 2am).
Const parameters compose cleanly with ordinary type parameters, so you can be generic over both the element type and the length in the same function:
fn first<T: Copy, const N: usize>(arr: [T; N]) -> T {
arr[0]
}
fn main() {
println!("{}", first([10, 20, 30])); // T = i32, N = 3 -> 10
println!("{}", first(['a', 'b'])); // T = char, N = 2 -> a
}
first works for any element type T and any length N, both decided at compile time from the argument. The T: Copy bound is there only so we can return arr[0] by value without moving out of the array -- exactly the Copy marker from last episode, earning its keep. Notice the ordering convention: type parameters come first in the angle brackets, const parameters after. That is a rule, not a style choice -- Rust wants <T, const N: usize>, not the reverse.
There is one sharp edge worth naming. first would panic on a zero-length array, because arr[0] has nothing to index. The type system does not (yet) let you say "N must be at least 1" as a bound, so that guarantee is not expressible here on stable Rust. Keep it in the back of your mind for the exercises.
A const parameter can carry a default, so callers who want the common size do not have to spell it out every time:
struct Fixed<const N: usize = 8> {
items: [i32; N],
}
fn main() {
let a = Fixed::<3> { items: [1, 2, 3] };
let b: Fixed = Fixed { items: [0; 8] }; // no explicit N -> uses default 8
println!("{:?}, len {}", a.items, b.items.len()); // [1, 2, 3], len 8
}
Write Fixed::<3> when you want three, or just Fixed when the default of eight is fine -- the same defaulting mechanism you have seen on type parameters, now on values.
And a const parameter can drive a return type, which is how you generate fixed-size outputs generically. The classic case is anything cryptographic, where a hash is a [u8; 32] and a nonce might be a [u8; 12]:
fn zeros<const N: usize>() -> [u8; N] {
[0; N]
}
fn main() {
let hash: [u8; 32] = zeros(); // N inferred from the annotation on `hash`
let nonce: [u8; 12] = zeros();
println!("{} + {} bytes", hash.len(), nonce.len()); // 32 + 12 bytes
}
This time there is no argument to infer N from, so the compiler reads it backwards from the type annotation on the left-hand side. Annotate hash as [u8; 32] and N becomes 32; annotate nonce as [u8; 12] and N becomes 12. Type inference flowing from the destination is a small thing, but it is the mechanism behind a lot of ergonomic array APIs in the standard library, such as array::from_fn.
The pattern to internalize is simple: reach for const generics whenever a size belongs in the type. Fixed buffers in embedded and networking code, small matrices and vectors in math libraries, cryptographic types like a 32-byte hash or a fixed-length key -- all of these are cleaner, faster and safer with the length carried in the type instead of tracked at runtime. The standard library itself leans on this: since Rust 1.51 you can call .iter(), IntoIterator, and a pile of other trait impls on [T; N] for any N, where before the language literally hand-wrote impls for lengths 0 through 32 and then gave up.
But be honest with yourself about the current limits on stable Rust, because you will hit them:
bool, or char. You cannot yet make a struct generic over an arbitrary constant of your own type.fn concat<const A: usize, const B: usize>(x: [u8; A], y: [u8; B]) -> [u8; A + B] -- returning an array whose length is the sum -- is still maturing behind unstable features. Buffer<{N + M}> is not ready for everyday stable code.first above.For the overwhelmingly common case -- fix a size, carry it in the type, get stack allocation and dimension safety for free -- const generics are absolutely ready today, and have been for a good while. The frontier is arithmetic and richer constraints, and that frontier keeps moving forward release by release.
Since quite some of you came through the Learn Python Series first (as did I), a look sideways sharpens the picture -- and here the three-way split we keep seeing lines up nicely again.
Python has no equivalent at all, and cannot, because it has no compile-time types to bake a value into. A Python list does not know its length as part of any type; length is a runtime property you query with len():
def sum_array(arr):
return sum(arr)
# Any length, any element type, checked never -- it is all runtime.
print(sum_array([1, 2, 3])) # 6
print(sum_array([10, 20, 30, 40])) # 100
# A 2x3 "matrix" is just a list of lists; nothing enforces the shape,
# and a ragged [[1, 2], [3]] is a perfectly legal value at runtime.
Maximally flexible, zero guarantees -- pass a ragged matrix and Python shrugs until something downstream explodes. C++ sits at the opposite pole, and interestingly it is where const generics come from conceptually: C++ has had non-type template parameters since the 1990s, so template<size_t N> struct Buffer { uint8_t data[N]; }; is old news over there. C++ even lets you do arithmetic on those parameters in type position today, which stable Rust still holds back on. The trade-off is the usual C++ one: enormous power, famously inscrutable error messages, and no borrow checker keeping the surrounding code honest. Rust deliberately started conservative -- integers, bool, char, no free arithmetic -- and is widening the door slowly, so that the error messages stay readable and the feature stays sound. Three points on a line once more: Python checks nothing, C++ checks everything but will bury you in template spew, Rust checks the core cases with messages a human can actually read. I know which trade I want when a dimension mismatch would corrupt a matrix silently ;-)
const N: usize), alongside the type parameters you already knew.[T; N] for any N, with the length inferred at the call site and the function monomorphized per value, just as type generics are monomorphized per type.Buffer<const N: usize>) puts a fixed size in the type: stack-allocated, no separate length field, and Buffer<4> vs Buffer<8> are distinct types with zero runtime cost for carrying the size.Matrix<R, C>, turning dimension mismatches into compile errors -- and const parameters compose with type parameters (first<T, const N>) and support defaults and return-type inference.bool and char, with no arithmetic on them in type position yet and no numeric bounds -- powerful for fixing-and-carrying a size, still growing for the fancier cases.The deeper current here, running through the last several episodes, is that Rust keeps finding ways to move guarantees to compile time by encoding facts into types -- sometimes as behaviour-bearing traits, sometimes as the empty markers of episode 25, and now as literal values living in the type. That last idea is more powerful than it first looks. Once a type can depend on a value, you can start asking the compiler to enforce relationships between those values -- and that opens a door onto some of the most expressive corners of the language, where the type checker does work you might not have believed a type checker could do. We will keep walking through that door step by step. One thing at a time ;-)
Three exercises as always, from gentle to chewier. Full solutions open the next episode, so genuinely have a go first -- typing it yourself is where it sticks.
fn last<T: Copy, const N: usize>(arr: [T; N]) -> T that returns the last element of the array, and call it on arrays of two different lengths to confirm one function handles both. (Hint: N - 1 is a valid index expression at runtime -- think about what happens for N = 0.)fn get(&self, i: usize) -> Option<u8> to the Buffer<N> struct from this episode that returns Some(byte) when i is in range and None otherwise, without ever panicking. Test it with an in-range and an out-of-range index.Matrix<R, C> a method fn transpose(&self) -> Matrix<C, R> that returns a new matrix with rows and columns swapped, and call it on a Matrix<2, 3> to get back a Matrix<3, 2>. Notice how the return type Matrix<C, R> states the shape change in the type signature itself.Bedankt voor het meelezen, en tot de volgende keer! ;-)