Generics, traits, lifetimes
Generic types, trait bounds, trait objects, lifetimes, and when each tool earns its keep.
Three Rust features that look like abstraction tools and are also abstraction taxes if you misuse them: generics, traits, lifetimes. Each is the right answer to a specific problem. Each is the wrong answer to several adjacent problems.
For an agent orchestrator, this is where review intuition pays off most. Agents reach for Box<dyn Trait> when a generic would work. They reach for 'static when a real lifetime would work. They reach for .clone() when a borrow would work. Each substitution compiles. Each substitution leaks performance, type information, or both.
Generic types
A generic function or struct takes a type parameter that the caller fills in. The compiler stamps out a specialized version for each concrete type (monomorphization). Zero runtime cost.
fn largest<T: PartialOrd>(items: &[T]) -> &T {
let mut best = &items[0];
for item in &items[1..] {
if item > best {
best = item;
}
}
best
}
struct Pair<T, U> {
first: T,
second: U,
}<T: PartialOrd> is a trait bound. It says: T must implement PartialOrd. Otherwise the item > best line cannot type-check.
Traits
A trait is a contract. Types that implement it agree to provide certain methods.
pub trait Summary {
fn summarize(&self) -> String;
// Default method. Implementors may override.
fn preview(&self) -> String {
let s = self.summarize();
s.chars().take(80).collect()
}
}
impl Summary for Article {
fn summarize(&self) -> String {
format!("{} by {}", self.title, self.author)
}
}Default methods let you add behavior without breaking existing implementors. They are how the standard library evolves.
Trait bounds, where clauses, impl Trait
Three syntactic forms for the same idea. Use the clearest one.
// Inline. Fine for one or two bounds.
fn notify<T: Summary + Clone>(item: &T) { /* ... */ }
// Where clause. Better when bounds get long.
fn notify<T, U>(item: &T, other: &U) -> bool
where
T: Summary + Clone,
U: PartialEq + Display,
{
// ...
}
// impl Trait. Anonymous type. The caller cannot name it.
fn make_summary() -> impl Summary {
Article { title: "x".into(), author: "y".into() }
}impl Trait in return position means "some concrete type that implements Summary, I'm not telling you which." It is monomorphized like a generic. It cannot return different concrete types from different branches of an if. For that, you need a trait object.
Trait objects: &dyn Trait, Box<dyn Trait>
When the concrete type must be chosen at runtime, use a trait object. The cost: a vtable lookup per method call. The benefit: heterogeneous collections.
fn summaries(items: &[Box<dyn Summary>]) {
for item in items {
println!("{}", item.summarize());
}
}Not every trait can be a trait object. The trait must be object-safe: no generic methods, no Self in return position (except behind a pointer), and the trait must not require Sized. Sized methods cannot be dispatched through a vtable.
Rule of thumb: prefer generics. Reach for dyn Trait only when you genuinely need runtime dispatch (plugin registries, heterogeneous lists, dynamic configuration).
Lifetimes
Every reference in Rust has a lifetime. Most of the time the compiler infers them. When a function takes more than one reference and returns one, the compiler needs you to be explicit about which input the output borrows from.
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}'a is a name. It says: the returned reference lives at least as long as both inputs. Without the annotation, the compiler does not know which input the output came from.
Three elision rules let the compiler infer lifetimes in common cases:
- Each input reference gets its own lifetime.
- If there is exactly one input lifetime, it is assigned to all outputs.
- If one input is
&selfor&mut self, its lifetime is assigned to all outputs.
If elision succeeds, you write no lifetimes. If it fails, the compiler says so and you annotate.
'static: the program-long lifetime
'static means "lives for the entire program." String literals are &'static str. Constants are 'static. Box::leak returns 'static.
let s: &'static str = "hello, world";Most uses of 'static in agent-written code are wrong. The agent could not figure out the right lifetime, so it reached for the one that always satisfies the compiler. That works locally and creates a virus of 'static bounds across the call graph. The right answer is almost always to thread an explicit lifetime through.
'static is also a trait bound (T: 'static) meaning "any references inside T are 'static." This is what tokio::spawn requires. It is correct there because spawned tasks may outlive their parent.
Bad vs good
A common hot-path mistake: dyn-dispatching when monomorphization is free.
pub fn pipeline(
sources: Vec<Box<dyn Iterator<Item = Row>>>,
) -> Vec<Row> {
let mut out = Vec::new();
for src in sources {
for row in src {
out.push(row);
}
}
out
}pub fn pipeline<I, S>(sources: S) -> Vec<Row>
where
I: Iterator<Item = Row>,
S: IntoIterator<Item = I>,
{
let mut out = Vec::new();
for src in sources {
for row in src {
out.push(row);
}
}
out
}The bad version dispatches every .next() call through a vtable. The good version inlines and vectorizes. For a query engine inner loop, that is a 5x to 50x difference. The same code shape, the same readability, totally different machine code.