Generics and Monomorphization

On this page

Generics allow an algorithm to apply to a set of types that satisfy constraints. Constraints serve both parties: the caller must provide a type that meets the requirements, and the function body can only rely on the capabilities promised by those requirements. The compiler will not allow string-specific operations that were not declared, even if a particular call happens to pass a String.

From Definition Checking to Instantiation

Check generic definitionThe function body can only rely on declared type constraints1Collect required instancese.g., choose:: and choose::23From generic definition to final machine codeGenerate and optimize for concrete typesInlining, constant folding, code removal, or merging are possibleThe instantiation model does not guarantee that the final binary will retain a countable set of independent function symbols.
fn choose<T: Ord>(a: T, b: T) -> T {
    if a >= b { a } else { b }
}

fn main() {
    assert_eq!(choose(3, 8), 8);
    assert_eq!(choose(String::from("a"), String::from("z")), "z");
}

This algorithm requires a total order comparison, hence the Ord requirement. Changing it to PartialOrd requires explicitly defining a strategy for incomparable values, such as floating-point NaN; you cannot uniformly interpret a > b being false as a <= b.

rustc collects generic instances for concrete types as needed, then proceeds to code generation. Lifetime parameters primarily serve static checking; machine code is not generated for every possible borrow duration. Monomorphization

Instances during generation do not correspond one-to-one with final symbols: unused code may not exist, function bodies may disappear after inlining, and identical implementations may be merged. You cannot assert that there are three independent functions in the binary just based on three calls.

Constraints are not implicit specialization inferred backwards from current calls

When defining fn f<T>(value: T), T represents any type satisfying known constraints. Even if all current calls pass String, the function body cannot directly call String-specific methods; you need to declare the actual requirements, such as AsRef<str>, or write the parameter directly as &str. This allows callers to understand the scope of applicability just by looking at the signature.

Similarly, T: Add only indicates that an addition implementation exists; it does not guarantee that the result is still T. If the algorithm requires the result to be closed within T, you should write the associated output constraint Add<Output = T>. Generic constraints should stem from the operations and type relationships required by the algorithm, rather than blindly adding Clone, Copy, and Default whenever a compiler error occurs. Trait bounds

The same algorithm, ownership choices change the interface

The choose function above takes two Ts by value and returns the winner, dropping the other value; it has no Clone constraint because it does not require copying values. If the caller only wants to compare existing objects while keeping them, they can provide a borrowing version.

fn choose_ref<'a, T: Ord + ?Sized>(a: &'a T, b: &'a T) -> &'a T {
    if a >= b { a } else { b }
}
fn main() {
    let first = String::from("alpha");
    let second = String::from("omega");
    assert_eq!(choose_ref(&first, &second), "omega");
    assert_eq!(first, "alpha");
    assert_eq!(second, "omega");
    // str is a DST, the function only borrows it; ?Sized relaxes the implicit Sized constraint.
    assert_eq!(choose_ref::<str>("a", "b"), "b");
}

The 'a constraint here ensures the returned reference does not outlive the borrow of the input; it does not extend the lifetime of the two string objects; nor does it require the two objects to have the same lifetime from birth to destruction. At the call site, you can re-borrow within the shorter range where both are valid.

?Sized does not require T to be dynamically sized; rather, it allows T to not satisfy the implicit Sized bound. The function still accesses T via a reference, and the reference itself is a value of known size; this does not allow arbitrary unknown-sized T to be handled directly by value as local variables.

InterfaceWhat is given upApplicable goal
fn choose<T: Ord>(a: T, b: T) -> TOwnership of both valuesWhen the other input is no longer needed after selection
fn choose_ref(... &T ...) -> &TShared borrow of inputsDo not copy, keep both original values
fn choose_cloned<T: Ord + Clone>(...) -> TBorrow, and copy the selected valueExplicitly need an independent return value or shared ownership defined by Clone

If the actual need is just to output the name, but you add Clone to return T, then copy a large buffer, the generic signature may mask unnecessary work. Determine the ownership of the return value first, then choose trait constraints.

Static dispatch and the actual meaning of "zero cost"

Concrete T provides the compiler with layout and method implementation information, facilitating inlining and optimization; this does not guarantee inlining will happen, nor does it guarantee that the algorithm, memory allocation, or bounds checking have no cost. You should compare the overhead with a concrete implementation of equivalent semantics.

Generics can also handle trait objects: T: Trait + ?Sized can take T = dyn Trait, where internal methods may still use dynamic dispatch. Static generics and dynamic objects can be combined; they are not mutually exclusive language patterns.

PartialOrd gaps require explicit handling

Floating-point numbers contain NaN, so there is only a partial order. When a comparison results in None, the algorithm can reject, skip, or adopt a definite total order; these strategies are not automatically obtained by changing Ord to PartialOrd.

use std::cmp::Ordering;
fn choose_partial<T: PartialOrd>(a: T, b: T) -> Option<T> {
    match a.partial_cmp(&b)? {
        Ordering::Less => Some(b),
        Ordering::Equal | Ordering::Greater => Some(a),
    }
}
fn main() {
    assert_eq!(choose_partial(1.5, 2.0), Some(2.0));
    assert_eq!(choose_partial(f64::NAN, 2.0), None);
}

This version discards both inputs and returns None when they are incomparable; if the caller wants to keep them, the error branch should carry the original values. Traits express "what operations can be done," but the function must also clarify how the operation results affect the business logic.

Controlling the scope of generic propagation

If a large part of the work no longer depends on T, you can move it into a concrete type function. The following function accepts different AsRef<str> inputs, but the scanning logic only needs &str:

fn count_words(input: impl AsRef<str>) -> usize {
    count_words_inner(input.as_ref())
}

fn count_words_inner(input: &str) -> usize {
    input.split_whitespace().count()
}

fn main() {
    assert_eq!(count_words("one two"), 2);
    assert_eq!(count_words(String::from("three")), 1);
}
&strType-specific adapterStringType-specific adapterOther AsRef<str>Type-specific adaptercount_words_inner(&str)Narrowing type dependencies: multiple entry points, sharing the same data representationSubsequent algorithms no longer depend on the entry type TThis is a type boundary at the source code level; final inlining, function merging, and machine code count are still determined by the compiler.

This structure reduces the propagation of type dependencies; whether it actually reduces machine code depends on inlining and optimization. Allocating a String for each element first to eliminate generics may introduce higher runtime costs; you should prioritize borrowing or existing common representations.

Common representations should preserve original semantics

Converting multiple types to &str allows sharing logic that depends only on strings; but if the input is a path, raw bytes, or a structured object, forcibly converting to String may cause allocation, loss of non-UTF-8 information, or loss of errors that could originally be expressed. Boundary shrinking should occur where the algorithm truly only needs the common representation.

Cross-crate generics also participate in code generation based on actual instances; you cannot simply assume that because the dependency library was compiled once, all downstream instances no longer need processing. On the other hand, different lifetime parameters usually do not result in separate copies of machine code, and untriggered type combinations should not be mechanically counted in the output.

Where does generic propagation stop?

This compares how far type information propagates. When the algorithm only needs &str, a thin generic entry can borrow that representation and call a shared scanner. Block counts do not predict final machine-code copies: inlining, elimination and merging still apply.

Preparing the visual
Where does generic propagation stop?

After expressing shared scanning as &str, only the thin adapter depends on T.

Use associated types to constrain input items, rather than fixing containers

If the algorithm only needs to iterate over u32, there is no need to force receiving Vec<u32>; IntoIterator<Item = u32> allows arrays, Vec, or other sources that produce u32 by value. The default iteration item for borrowed slices is &u32; in this case, you can explicitly use .copied() to make "whether to copy elements" appear at the call site.

fn total<I>(input: I) -> u64
where
    I: IntoIterator<Item = u32>,
{
    input.into_iter().map(u64::from).sum()
}
fn main() {
    assert_eq!(total([1, 2, 3]), 6);
    assert_eq!(total(vec![4, 5]), 9);
    let values = [6, 7];
    assert_eq!(total(values.iter().copied()), 13);
    assert_eq!(values, [6, 7]);
}

Here, converting u32 to u64 does not lose individual numeric values, but infinite or sufficiently long inputs may still cause the sum to overflow; if the total might exceed u64, you need checked accumulation and error returns. Generalizing the input container does not expand the range of numerical safety.

const generics: constants are also type parameters

struct Buffer<T, const N: usize> {
    data: [T; N],
}

impl<T, const N: usize> Buffer<T, N> {
    fn as_slice(&self) -> &[T] { &self.data }
}

fn main() {
    let small = Buffer { data: [1u8, 2, 3, 4] };
    let empty = Buffer::<u8, 0> { data: [] };
    assert_eq!(small.as_slice(), &[1, 2, 3, 4]);
    assert!(empty.as_slice().is_empty());
}

Buffer<u8, 4> and Buffer<u8, 8> are different types; N is not a field that changes arbitrarily at runtime. When runtime length is needed, Vec or slices are usually more suitable; do not force all dynamic requirements into type parameters.

There are boundaries to the types of parameters and expressions supported by stable const generics. Using N directly in an array length is not equivalent to using N + 1; arbitrary generic constant expressions cannot be assumed to be stably available. Generic parameter rules

Length in type parameters vs. runtime length

The N in Buffer<u8, 4> is fixed when the instance type is determined, so the compiler can know the layout; len obtained from user input cannot be directly written as Buffer<u8, len>. If only a few fixed sizes are supported, you can branch at runtime to select different static instances; if sizes vary arbitrarily, Vec, slices, or a fixed maximum buffer with an effective length are more natural.

Const parameters do not automatically prove that all arithmetic is valid. N=0 may be legal or may violate some algorithmic requirements; overflow of indices like N-1, N*M, and the resource overhead of large arrays on the stack still need explicit handling. Moving numbers into types does not mean all business conditions become compile-time proofs.

impl Trait parameters are chosen by the caller

impl Trait in the parameter position expresses an anonymous generic parameter, such as impl AsRef<str> above. Two independent impl Trait parameters do not require the concrete types to be the same; writing two parameters as the same T requires them to unify to the same type.

impl Trait in the return position is chosen by the function implementation to hide the concrete type; it does not mean that any implementing type can be returned at every run. See Associated Types and GATs for more.

Two anonymous parameters do not express "the same type"

use std::fmt::Display;
fn show_pair(left: impl Display, right: impl Display) -> String {
    format!("{left}:{right}")
}
fn same_pair<T: Display>(left: T, right: T) -> String {
    format!("{left}:{right}")
}
fn main() {
    assert_eq!(show_pair(7u32, "ready"), "7:ready");
    assert_eq!(same_pair(7u32, 8u32), "7:8");
}

The two implementing types in show_pair can be different; same_pair requires them to unify to the same T in this call. If you need to describe type relationships between parameters or return values, explicitly naming T is often clearer. impl Trait in the parameter position is not dyn Trait, and it will not automatically become dynamic dispatch just because the type name is omitted. impl Trait

Specialization and coherence

Stable Rust does not allow arbitrarily overriding a general impl with a more specific impl where they overlap. specialization is still an experimental feature, and min_specialization should not be treated as a stable interface promise. Unstable Book

Stable solutions can use newtypes, explicit strategy parameters, or different entry points to ensure implementation ranges do not overlap. Choosing these solutions is to clarify the interface; it does not guarantee automatically getting all the effects of compiler specialization. When focusing on performance, observe compilation time, artifact size, and actual hot paths simultaneously, rather than just counting the number of generic parameters.

How to observe costs, rather than estimating machine code count from source code

CostObservation metricVariables easily mixed in
Compilation timeCompare clean builds and incremental builds with the same configurationCaches, parallelism, dependency versions
Artifact sizeRecord executable code section, debug info, and total file size separatelystrip, LTO, panic strategy, linker
RuntimeSame data, semantics, and workloadAllocation, I/O, cold/hot cache, branch ratio
Hot path dispatchAssembly/IR combined with actual performance samplingResults after inlining and devirtualization

You can use rustc --edition=2024 -O --emit=llvm-ir example.rs to view the optimized IR of a standalone example, but you should not write the number of symbol searches as a language guarantee. Instantiation count is part of the compiler's workload; final code may still be inlined, eliminated, or merged.

If splitting a large loop into a non-generic function builds faster but the hot path slows down, you should check if optimizations previously brought by type information were lost; conversely, adding dynamic calls may improve instruction cache due to smaller code size. Choosing static or dynamic boundaries requires looking at interface expression, compilation cost, and actual load simultaneously, rather than treating "monomorphization" as a winning label.