The Rust Engineering Handbook / Chapter 18
Generics, Bounds, and Monomorphization
Place generic variation deliberately and account for bounds, generated instances, diagnostics, compile time, and code size.
Part IV: who gets to choose?
Ownership and algebraic data design gave us concrete boundaries: who owns a value, which states exist, and which transitions are legal. Part IV asks what happens when those decisions must work across a family of types. Rust offers type parameters, bounds, traits, and several forms of dispatch, but the design question comes first: which choices genuinely belong to the caller?
Consider a ledger export operation. Callers may reasonably choose the record view and the wire encoding. They probably should not choose the batch accounting type, the clock representation, the validation algorithm, the internal identifier, and the shape of every error. Yet an API can make all of those details variable:
fn export<R, I, E, S, C, M>(
records: I,
encoder: E,
sink: S,
clock: C,
map_error: M,
) -> Result<ExportReceipt, M::Error>
where
I: IntoIterator<Item = R>,
E: Encoder<R>,
S: Sink,
C: Clock,
M: ErrorMapper,
{
// validation, batching, accounting, and delivery
}
The signature looks accommodating. Its callers instead inherit a six-parameter proof obligation, errors mention adapter types rather than exports, and every concrete combination gives the compiler another instantiation to consider. Worse, the signature promises that each choice is independently meaningful even if the implementation immediately normalizes most of them.
A generic parameter is a promise of caller-controlled variation. The rest of this chapter repairs the export boundary by keeping the two honest choices generic and making the rest concrete. That repair gives us one question against which to test every mechanism: does this parameter preserve a real choice, or merely export implementation machinery?
The destination, clock, validation policy, and error translation can live behind a concrete ExportContext owned by the application:
fn export<R, E>(
records: &[R],
encoder: E,
context: &mut ExportContext,
) -> Result<ExportReceipt, ExportError>
where
R: RecordView,
E: Encoder<R>,
{
// normalize records, then validate, batch, account, and deliver
}
This is not automatically the final API. It is a more truthful starting claim: callers choose record representation and encoding, while the application owns its operating policy.
Three parameter kinds make three different promises
A type parameter such as R lets the caller select a type. A lifetime parameter such as 'a relates borrows; it is not a runtime duration. A const parameter such as const N: usize lets the caller select a compile-time value that becomes part of the type.
The export pipeline needs all three only at particular edges. A record view may be generic because several domain records can supply cents. An iterator returned over a borrowed batch needs a lifetime relationship. A fixed batch width may deserve a const parameter when that width changes static validity or layout:
fn sum_window<T, const N: usize>(items: &[T; N]) -> u64
where
T: Cents,
{
concrete_sum(items.iter().map(Cents::cents))
}
Here N distinguishes array types and T: Cents supplies the only record operation the adapter uses. No named lifetime appears because the function returns an owned total; elision expresses the borrow completely. Naming 'a would add notation without adding a relationship.
If the deployment reads its batch size from configuration, [T; N] is the wrong boundary. A slice plus a checked runtime limit admits the value the system actually has. Const generics earn their place when distinct values should produce distinct types, not merely because a number is available during compilation.
Bounds are the generic body’s available vocabulary
Rust checks a generic body against the capabilities in its declaration, not by inspecting every future caller. T: Cents permits Cents::cents; it does not permit cloning, formatting, or sending T between threads. A caller’s concrete type may support all three, but those undeclared capabilities are unavailable inside the body.
Some facts are implied by well-formed types, especially lifetime relationships required for a referenced type to exist. Behavioral capabilities such as Clone, Display, and a domain trait remain explicit. Prefer a where clause when bounds are long, relate several parameters, or carry the main contract. Inline bounds are readable for a single short capability.
This is why “just in case” bounds do damage. Adding Clone + Send + Sync + 'static to the record view excludes borrowed and thread-affine implementations before the export algorithm has demonstrated any need for cloning, threads, or owned data. Each bound should have a corresponding operation or storage requirement in the body. On a public API, tightening that vocabulary later can exclude types that callers already use.
Follow the parameter to code generation
For ordinary statically dispatched generic Rust, rustc collects reachable concrete uses and monomorphizes them for code generation. Calls such as sum_window::<u64, 32> and sum_window::<LocalCents, 64> therefore become distinct compiler work items. Static dispatch gives the optimizer concrete types and avoids a vtable call at this boundary. It can also increase compile work and, when machine code remains distinct, binary size.
Do not claim one source call always equals one separately retained machine-code body. The optimizer may inline, merge, or remove code. Symbol layout is a current implementation observation. Measure a release artifact on a named compiler, target, and profile before making a size claim.
The original export function makes validation, batching, accounting, and delivery generic even when only record conversion and encoding vary. Move conversion into a short adapter, then hand concrete values to a concrete core:
fn sum_window<T, const N: usize>(items: &[T; N]) -> u64
where
T: Cents,
{
let values: [u64; N] = std::array::from_fn(|index| items[index].cents());
concrete_sum(&values)
}
fn concrete_sum(values: &[u64]) -> u64 {
values.iter().sum()
}
The adapter is still instantiated for each T and N; the accounting operation accepts one concrete slice type and has no reason to multiply with every record representation. Normalization is not free: this version materializes an [u64; N] on the stack. For large or runtime-sized batches, a caller-owned buffer or a different concrete streaming boundary may be the better exchange. This shape does not guarantee a smaller executable either: inlining, deduplication, and dead-code elimination may erase the apparent boundary. It does make both the semantic choice and the normalization cost visible enough to inspect and measure.
Stop variation where the decision stops
A generic function is a good fit for a small operation whose variation is central: a caller chooses the record view, and the adapter asks only for the capability it uses. A generic type makes a larger promise. Its parameter propagates into constructors, stored values, return types, diagnostics, and often downstream structs. Use that reach when the chosen strategy shapes most of the object’s behavior, not merely to avoid writing one conversion.
The repaired export keeps generic variation at the input edge and normalizes records into a concrete internal form. That may require a conversion and may discard opportunities to optimize separately for every source type. In return, validation, batching, and accounting have stable types and stable errors. The core describes exporting rather than the machinery used to enter it.
Sometimes the choice really occurs at runtime. A trait object can put heterogeneous implementations behind one concrete container, at the cost of indirection and the trait’s dyn-compatibility constraints. When the alternatives are few and owned by the application, an enum keeps them explicit and closed. Chapters 20 and 21 develop those boundaries. Here they serve as a warning: replacing every generic parameter with Box<dyn Trait> changes when the choice is made; it does not decide whether the choice belongs in the API.
Opaque return types hide names, not behavior
Argument-position impl Trait is convenient generic input syntax; it still lets the caller choose the argument’s concrete type. Return-position impl Trait reverses that authority: the function chooses one concrete return type while exposing only its implemented traits:
fn above<'a, T>(items: &'a [T], floor: u64)
-> impl Iterator<Item = u64> + 'a
where
T: Cents + 'a,
The caller receives a statically dispatched concrete iterator whose name is hidden. All return paths must resolve to the same concrete type. This is not a trait object, and it does not allow runtime selection among unrelated iterator types without another unifying layer.
The returned iterator borrows items, so 'a remains visible in the promise. The caller may consume the iterator but cannot name its concrete chain of adapters or substitute another iterator type. In Rust 2024, return-position opaque types automatically capture in-scope generic parameters; precise use<...> capture can narrow that set when necessary. Capture and exposed bounds remain API concerns even though the concrete name is hidden.
Compile-time cost belongs in architecture review
The overgeneric signature feels expensive, but intuition is not a measurement. Compare cargo check, a clean release build, and a representative incremental edit before blaming generics. Procedural macros, build scripts, linking, and dependency graphs can dominate. When generic code is implicated, inspect how many downstream crates instantiate it, whether substantial bodies repeat, and whether feature combinations multiply the graph.
Compile time is an operational cost for CI and developer feedback. Binary size matters for cold start, instruction cache, distribution, firmware, and review—not as a universal reason to avoid static dispatch.
Failure modes
- Making every dependency a type parameter in the name of testability.
- Adding capability bounds the body does not use.
- Propagating a private generic parameter through an otherwise concrete public model.
- Treating const parameters as runtime configuration.
- Promising zero runtime or code-size cost without an artifact measurement.
- Returning
impl Traitwhile assuming callers can choose its hidden concrete type. - Boxing immediately to shorten an error without deciding whether runtime polymorphism is needed.
Generic design review
- What caller decision does each type, lifetime, or const parameter represent?
- Which bound authorizes each operation in the body?
- Are any bounds architectural guesses rather than requirements?
- How far does the generic parameter propagate through stored types and public errors?
- Can a small adapter normalize values into a concrete core?
- Is runtime heterogeneity actually required?
- What compiler, target, profile, and workload support compile-time or size claims?
- Would a concrete type or closed enum produce clearer evolution?
Refactoring exercise: make every parameter defend itself
Return to the six-parameter export function. Annotate each parameter with the caller decision it represents and the exact operation that earns each bound. Preserve caller choice of record view and encoding, but move validation, batching, and accounting into concrete helpers. Add one fixed-width batch only if width changes a static invariant, and return one borrowed record view as impl Iterator.
Then make the problem resist a mechanical answer. Add a deployment requirement that selects the sink at runtime and a second requirement that reads batch size from configuration. Decide separately whether the sink needs a trait object or closed enum and why batch size should cease to be const-generic. Keep a compile-fail fixture for a missing Cents bound. Finally, compare a clean check, a representative incremental edit, and the release binary before and after on a named toolchain, target, and profile. The measurements may justify the refactor, contradict it, or show that maintainability—not artifact size—is the honest reason to keep the concrete core.
Durable takeaways
- Type, lifetime, and const parameters model different forms of compile-time variation.
- Bounds are the operations a generic body may rely on and part of a public compatibility surface.
- Monomorphization enables static optimization while creating possible compile-time and code-size costs.
- Variation should stop where the caller’s meaningful decision stops; a generic edge and concrete core are one common result.
impl Traithides a concrete type’s name; it is not runtime polymorphism.
A bound can prove that an operation exists, but it says little by itself about what the operation means. The next chapter turns that vocabulary into a behavioral contract: what implementers must establish, which relationships associated items encode, and which laws callers may safely assume.
Sources and version notes
- Rust Reference: generic parameters and trait and lifetime bounds
- Rust Reference:
impl Trait - rustc book: monomorphization
- The lab’s source-to-instance evidence was reproduced with Rust 1.97.0 on
x86_64-unknown-linux-gnu; optimizer retention and symbol shape are current implementation observations. Core examples also pass Rust 1.85.0. The Rust 2024 opaque-type capture behavior follows the current Rust Reference.
Continue reading
Full table of contents