Skip to content

Rust Generics, Bounds and Const Generics

Rust generics let one definition work with several concrete types, but they do not permit arbitrary operations on an unknown type. This lesson builds a borrowed maximum function, a typed reading and a fixed-length window, using Raspberry Pi fixtures without sensor access.

Prerequisites and outcome

Complete crates, modules and visibility, and be comfortable with borrowing and Option. You will declare type and constant parameters, state the operations a function requires, and distinguish a generic implementation from methods for one concrete type. We use existing standard-library traits here; defining your own traits comes next.

Start with the operation, then choose a bound

A function that finds the largest i32 can compare values because the compiler knows what i32 supports. Replacing i32 with an unconstrained T removes that knowledge. The signature must state the required comparison behaviour.

Here, T: Ord requires a total ordering. We return a reference to an existing element, so the function needs neither Copy nor Clone. An empty slice produces None instead of panicking. The algorithm retains the first maximum when values compare equal; that is our chosen policy, not a requirement of generics.

The Ord documentation defines the ordering contract. Ordinary f32/f64 values do not implement Ord because of cases such as NaN. Relaxing the bound to PartialOrd would also require deciding what an incomparable value means; changing the bound alone does not design that policy.

Build the complete Rust generic program

cargo new pi_generics
cd pi_generics

Keep edition = "2024" in Cargo.toml and replace src/main.rs with the complete program below. No external dependencies are needed.

use std::fmt::Display;

struct Reading<T> {
    value: T,
}

impl<T> Reading<T> {
    fn new(value: T) -> Self {
        Self { value }
    }

    fn value(&self) -> &T {
        &self.value
    }
}

// This method exists only for integer millidegree readings.
impl Reading<i32> {
    fn celsius(&self) -> f64 {
        f64::from(self.value) / 1_000.0
    }
}

type Millidegrees = Reading<i32>;

fn largest<T: Ord>(items: &[T]) -> Option<&T> {
    let mut best = items.first()?;
    for item in &items[1..] {
        if item > best {
            best = item;
        }
    }
    Some(best)
}

fn report<T>(label: &str, items: &[T])
where
    T: Ord + Display,
{
    match largest(items) {
        Some(value) => println!("{label}: {value}"),
        None => println!("{label}: empty"),
    }
}

struct Window<T, const N: usize> {
    values: [T; N],
}

impl<T, const N: usize> Window<T, N> {
    fn new(values: [T; N]) -> Self {
        Self { values }
    }

    fn as_slice(&self) -> &[T] {
        &self.values
    }

    fn len(&self) -> usize {
        N
    }
}

fn main() {
    let reading = Millidegrees::new(46_700);
    println!("reading={} ({:.1} C)", reading.value(), reading.celsius());

    let window = Window::<i32, 3>::new([46_700, 60_000, -500]);
    report("max", window.as_slice());
    println!("window length={}", window.len());

    // String is ordered but not Copy: largest only borrows these values.
    let labels = [String::from("Pi 4B"), String::from("Pi 5")];
    report("label", &labels);
    println!("still owned={}", labels[0]);

    let empty = Window::<i32, 0>::new([]);
    report("empty window", empty.as_slice());
}

#[cfg(test)]
mod tests {
    use super::{Millidegrees, Reading, Window, largest};

    #[test]
    fn empty_slice_has_no_maximum() {
        assert_eq!(largest::<i32>(&[]), None);
    }

    #[test]
    fn maximum_handles_negative_values_and_zero() {
        assert_eq!(largest(&[-500, 0, -100]), Some(&0));
    }

    #[test]
    fn equal_maxima_keep_the_first_element() {
        let values = [7, 7, 2];
        let chosen = largest(&values).expect("nonempty fixture");
        assert!(std::ptr::eq(chosen, &values[0]));
    }

    #[test]
    fn maximum_borrows_non_copy_strings() {
        let values = [String::from("Pi 4B"), String::from("Pi 5")];
        assert_eq!(largest(&values).expect("nonempty fixture"), "Pi 5");
        assert_eq!(values[0], "Pi 4B");
    }

    #[test]
    fn generic_reading_accepts_owned_text() {
        let reading = Reading::new(String::from("online"));
        assert_eq!(reading.value(), "online");
    }

    #[test]
    fn concrete_method_converts_millidegrees() {
        let reading = Millidegrees::new(-500);
        assert_eq!(reading.celsius(), -0.5);
    }

    #[test]
    fn const_lengths_include_zero_and_singleton() {
        let empty = Window::<i32, 0>::new([]);
        let one = Window::new([46_700]);
        assert_eq!(empty.len(), 0);
        assert_eq!(largest(empty.as_slice()), None);
        assert_eq!(one.len(), 1);
        assert_eq!(largest(one.as_slice()), Some(&46_700));
    }
}
1
2
3
4
cargo check
cargo test
cargo fmt --check
cargo run --quiet

Expected output:

1
2
3
4
5
6
reading=46700 (46.7 C)
max: 60000
window length=3
label: Pi 5
still owned=Pi 4B
empty window: empty

There are seven tests. The pointer-equality check observes which existing element is borrowed; it neither dereferences a raw pointer nor requires unsafe code.

Type parameters, inference and concrete methods

Reading<T> has one parameter. Each use substitutes a concrete type: Reading and Reading are different types. A single Reading cannot silently change from one to the other. A generic struct with two independent field types would need two parameters; reusing T for both fields requires the same type.

The declaration impl<T> introduces the parameter used by Reading<T>. It does not mean that a new generic parameter is chosen every time value() is called. In contrast, impl Reading<i32> adds a method only to that concrete instantiation. It is an inherent implementation, not unstable trait specialization. See the Book's generic data types and methods.

Rust infers T from Reading::new's argument and infers the one-element window's type and length. Window::<i32, 3> and largest::<i32> use explicit generic arguments, sometimes called turbofish syntax. Empty data may provide no element-type information, so add an annotation instead of guessing what the compiler should infer.

Option and Result are generic standard-library enums you already use. The type alias Millidegrees is another name for Reading, not a new nominal type or a proof that its number has a particular unit. Use distinct wrapper structs when separate units must be type-checked; see type aliases.

Bounds describe requirements, not runtime tests

The report function needs both ordering and formatting, expressed with T: Ord + Display. Its where clause is another location for the same bound syntax, useful when signatures become crowded. Bounds give the generic body access to those operations and constrain callers; they are not runtime checks that a particular value is “large enough”. See trait bounds and where clauses.

Ord and Display are standard-library traits; the generic parameters and bounds are language syntax. Bounds do not automatically make an enclosing type implement those traits. Reading stores an ordered value, but Reading itself has no Ord implementation here.

Generic type parameters normally have an implicit Sized bound. A borrowed generic API can sometimes relax it with T: ?Sized; that means Sized is not required, not that the type must be unsized. Our slice elements must have a known size, so this function is not a candidate for that change. Sized describes this distinction; later lessons revisit dynamically sized types.

Returning Option<&T> preserves ownership of the input. The reference cannot outlive its backing storage. This signature uses lifetime elision; explicit lifetime relationships are taught after traits rather than hidden behind claims that generics extend storage.

Const generics encode an array length

The parameter const N: usize supplies a compile-time value. Window stores [i32; 3], while Window stores [i32; 0]. They are different types; a runtime length cannot be substituted as N. An empty array is valid and our code handles it without indexing its first element.

Calling as_slice() deliberately erases the fixed length from the borrowed slice type, allowing one largest function to accept different lengths. Choosing Vec would instead allow a growable length at runtime; use the collection matching your requirement rather than forcing every buffer into a const generic.

Const generics are not the same as declaring a const item or const fn. On stable Rust, using N directly as an array length works, but an arbitrary generic expression such as [T; N + 1] is not supported by this form. Arithmetic with N in an ordinary function body is a different case. We use no nightly features. For restrictions and parameter kinds, consult the generic-parameter reference.

Generic functions are ordinarily monomorphized for concrete uses by the compiler. This is not an interpreter checking type names for each comparison. It also does not establish a speedup: compilation cost, generated code size and runtime behaviour depend on the actual program. This lesson makes no benchmark claim.

Deliberately failing: an unknown type cannot be compared

In a separate scratch project, replace src/main.rs with this complete program:

1
2
3
4
5
6
7
fn larger<T>(left: T, right: T) -> T {
    if left > right { left } else { right }
}

fn main() {
    println!("{}", larger(46_700, 60_000));
}

cargo check reports E0369. Even though main supplies integers, the generic function's body must type-check using its declared requirements. Adding T: PartialOrd makes these particular integer calls compile; choose a total-order bound and an explicit tie policy when that is your actual contract.

Exercises and troubleshooting

  1. Rewrite largest's bound using a where clause: all tests and output should remain unchanged.
  2. Change the main window to four elements, adding 0, and set N to 4: the maximum stays 60000 and the printed length becomes 4. Change only N to 4 without adding an element: expect E0308.
  3. Call largest on [46.7_f64, 60.0]: expect E0277 because f64 does not implement Ord. Do not replace this with an unexamined NaN policy.
  4. Construct Reading and call celsius(): expect E0599, because that inherent method exists only for Reading.
  5. Remove the type argument in largest::<i32>(&[]): expect E0283 because neither the empty input nor the comparison gives a unique element type.
  6. Repair the failing larger function with a PartialOrd bound. Verify its integer output is 60000, and explain why a float/NaN call needs a separate semantic decision.

If the error mentions a missing trait bound, identify the operation requiring it. Do not add Copy merely to fix a comparison error: returning a borrow already works with String. If a type parameter is unknown inside an impl, check that it was declared after impl. If an array length mismatches, compare the const argument with the actual element count.

Verification and next step

On October 10, 2026, the lesson was verified on a Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, all seven tests, formatting and debug/release output comparisons passed. The where-clause variant, four-element window and repaired integer comparison also passed. Missing comparison bounds, unavailable concrete methods, floating-point Ord, ambiguous empty input and mismatched array lengths produced the expected E0369, E0599, E0277, E0283 and E0308 diagnostics. These fixtures do not read temperature sensors or measure performance.

Next: trait definitions, implementations and associated types, defining and composing behaviour contracts.

Previous: modules and visibility · Course overview

Donate