A generic function can work with several concrete types. Rust normally generates specialized code for the concrete type and const arguments needed by a program. This process is called monomorphisation.
Specialization gives the optimizer information about the operations and data involved. It can also increase compilation work and generated code. Neither effect is a fixed cost you can read directly from the number of generic parameters in the source.
Type checking happens before specialization
Rust checks a generic definition using its declared bounds, even if the function is never called. This definition is invalid:
fn combine<T: Copy>(a: T, b: T) -> T {
a + b // Copy does not provide addition.
}
Adding std::ops::Add<Output = T> to the bounds supplies the missing
operation. Monomorphisation is a later compilation step; it does not defer
ordinary type checking until the compiler sees a concrete call.
Bounds establish which operations are available, not every behavioral property you might want. A valid generic function can still panic or have a logic bug for particular values or implementations.
Concrete instances are not a final binary count
Calls using i32 and f64 can require different instances of a generic
function. The final executable need not contain two separate, recognizable
function bodies: inlining, constant folding, dead-code elimination and other
optimizations can change what remains.
Const parameters can also create distinct instances. That does not imply
that Matrix<3> and Matrix<4> must share no generated code. Likewise,
nested generic calls create the instances actually needed by reachable code,
not automatically every combination of types at every level.
Public generic library code can require code generation in downstream crates. How much work is repeated depends on instantiation reuse, incremental builds, code-generation settings and linking. Avoid assuming that every use creates another complete copy or that nothing can be reused.
Static and dynamic dispatch answer different needs
Compare these function signatures:
trait Draw {
fn draw(&self) -> usize;
}
fn draw_generic<T: Draw>(value: &T) -> usize {
value.draw()
}
fn draw_impl(value: &impl Draw) -> usize {
value.draw()
}
fn draw_dynamic(value: &dyn Draw) -> usize {
value.draw()
}
The first two use generic parameters. Argument-position impl Trait gives
the parameter an implicit name; it does not request dynamic dispatch.
The third accepts a trait-object reference. Calls through that interface
normally use a vtable, though optimization may resolve a particular call
statically. The reference itself does not require a heap allocation:
&dyn Draw can borrow a value on the stack. Box<dyn Draw> adds owned,
boxed storage, which is a separate choice.
Static dispatch can expose opportunities for inlining and specialization. Dynamic dispatch can let different concrete types share calling code and supports heterogeneous collections. Neither guarantees a faster build, smaller binary or faster execution for every program. Reduced code size can help instruction-cache behavior, while indirect calls can inhibit inlining.
Keep a generic wrapper small
Sometimes the generic parameter only makes the input convenient. Once an input is converted to a common representation, the remaining work can live in a non-generic helper:
pub fn word_count<S: AsRef<str>>(input: S) -> usize {
word_count_inner(input.as_ref())
}
fn word_count_inner(input: &str) -> usize {
input.split_whitespace().count()
}
fn main() {
assert_eq!(word_count("one two"), 2);
assert_eq!(word_count(String::from("three four five")), 3);
assert_eq!(word_count(""), 0);
}
This is outlining: the conversion stays generic, while the shared work has one non-generic definition. It is particularly worth investigating when the shared body is large and many input types call it. The short body above illustrates the structure; it is not a performance benchmark.
Outlining can reduce duplicated compiler work, but the optimizer may still inline or duplicate the helper. There is no guaranteed instruction count for the wrapper or guaranteed reduction in binary size.
Check your understanding
A public function takes T: Into<Config>, converts its input once, then processes Config for many lines. What would you change, and what would you check?
Show answer
Hide answer
Keep the public generic signature. Let it call process_inner(cfg.into()),
and move the shared work into a helper taking Config directly.
Preserve the conversion count, error handling, ownership and drop behavior. Run the same behavioral tests before and after the refactor. Then measure build time, relevant code size and representative runtime performance with matching compiler settings.
Conversion to a shared type does not erase every optimization opportunity: an inlined caller can still expose known values for constant propagation. A new call boundary could therefore affect runtime performance. The result needs measurement, even when the API and outputs stay the same.
Measure the cost you are trying to change
A useful comparison records the source revision, compiler version, target, enabled features and build profile. Keep optimization, LTO, codegen-unit, debug-information and stripping settings consistent between versions. Compare clean builds separately from incremental rebuilds, and repeat runs to distinguish a change from timing noise.
| Tool | Evidence it provides | What it does not establish alone |
|---|---|---|
cargo build --timings |
Compilation-unit durations, concurrency and dependency scheduling | That generics caused a slow build |
cargo llvm-lines |
LLVM IR volume and instantiation counts grouped by function | Final machine-code size or runtime cost |
cargo bloat |
Attribution of executable code size to functions or crates | Whether the largest function is a runtime bottleneck |
The last two are separately installed Cargo tools. Their output can identify candidates for investigation; it does not prescribe a refactor automatically. Overall file size can also include symbols, debug data and other sections, so distinguish it from the code-size metric you intend to compare.
Build time has other causes, including dependency scheduling, procedural macros, build scripts and linking. Runtime performance has its own evidence: use a representative workload and a profiler or benchmark. Choose generics, trait objects or outlining to address a measured need while keeping the API clear.