Lesson 5 — Generics, trait bounds, ?Sized, dyn vs impl
Question
Reading Reth / Revm / Alloy source means constantly encountering generics + trait bounds. <T: Bound> / ?Sized / dyn Trait / impl Trait — what does each mean and when to use which? Four concepts that are mandatory for reading the source.
Principle (minimum model)
- Generics
<T>. Type parameters; the compiler monomorphises them to concrete types at compile time → no runtime overhead but one binary copy per concrete type. - Trait bounds
T: Trait. Constraints on a type parameter.fn print<T: Display>(x: T)= "anything that implements Display". Multiple bounds:T: A + B + 'static. ?Sized. Removes the defaultSizedbound. Required to acceptdyn Trait(whose size is only known at runtime).<T: ?Sized>lets you pass trait objects.dyn Trait. Runtime dispatch via vtable.Box<dyn Trait>/&dyn Trait. Multiple concrete types treated uniformly. Size unknown at compile time.impl Trait. As an argument = "something that implements Trait" (a shorthand for generics). As a return type = "some concrete type that's hidden". Argument-position is sugar; return-position is single concrete type.- Generics vs
dyn. Generics = static dispatch + monomorphisation (fast, binary bloat);dyn= dynamic dispatch + vtable (flexible, slightly slower). Send + Sync. Marker traits for thread-safe movability/shareability. Tokio tasks requireT: Send + 'static. The foundation of Rust concurrency.'staticlifetime. Lives for the whole program OR no borrowing at all. Global constants /String::from("...")/Vec<T>are'static.Send + 'static= movable across tasks.
Worked example + steps
Generics, trait bounds, ?Sized, dyn vs impl
This is the lesson that lets you read pub fn add<IT: ITy, H: ?Sized>(...) without flinching. Reth and Revm source code is dense with generics — function signatures with three type parameters and trait bounds aren't unusual. This lesson goes through every piece of that machinery.
Generics 101 — the basic shape
A generic function takes a type parameter in angle brackets, then uses it like an ordinary type:
fn first<T>(items: &[T]) -> Option<&T> {
items.first()
}
let nums = [1, 2, 3];
let first_num = first(&nums); // T inferred as i32
let words = ["hello", "world"];
let first_word = first(&words); // T inferred as &str
The compiler monomorphizes — it generates a specialized copy of first for each concrete T you call it with. first::<i32> and first::<&str> are two separate functions in the compiled binary, both with zero runtime cost vs hand-written non-generic code.
Trait bounds — "T must support these operations"
Plain <T> says "T can be anything." Often you need to do something with T — call a method, compare, format. That's where trait bounds come in:
use std::fmt::Display;
fn print_first<T: Display>(items: &[T]) {
if let Some(first) = items.first() {
println!("{}", first); // requires T: Display
}
}
<T: Display> reads as "T must implement the Display trait." Without it, the compiler refuses — it doesn't know that T has a {} formatter.
Multiple bounds with +:
fn process<T: Display + Clone>(item: T) {
let copy = item.clone();
println!("{} (cloned: {})", item, copy);
}
where clauses — same thing, different syntax
When bounds get long, they move below the signature:
fn process<T>(item: T)
where
T: Display + Clone + Send + 'static,
{
// body
}
This is purely cosmetic. <T: Bound> and where T: Bound mean the exact same thing. Reth code uses both styles depending on length.
Sized — the implicit bound you didn't know about
Every type parameter T in Rust has an implicit Sized bound. That is, <T> is silently <T: Sized>. Sized means "the compiler knows the type's size at compile time."
Most types are Sized: i32 is 4 bytes, String is 24 bytes (pointer + length + capacity), your custom struct has a known size.
Some types are not Sized:
str(the bare string type, not&str) — its length depends on what's in it[i32](a bare slice type, not&[i32]) — samedyn Trait(we'll get to this) — the underlying concrete type can be anything
When you say <T: ?Sized>, you're opting out of the implicit Sized bound. T is now allowed to be unsized. The ? is "maybe Sized, maybe not."
Why would you do this? Because &T and Box<T> can hold unsized types if T is ?Sized. Without ?Sized, you can never write fn foo<T>(x: &T) and pass a &dyn Trait to it — the compiler enforces T: Sized and dyn Trait doesn't satisfy it.
When Revm writes:
pub fn add<IT: ITy, H: ?Sized>(context: Ictx<'_, H, IT>) -> Result {
The H: ?Sized is exactly so that H can be dyn Host. The function works for both &MyConcreteHost (Sized) and &mut dyn Host (unsized, ?Sized).
This single character (?) decides whether the function accepts trait objects.
dyn Trait — the trait object
dyn Trait is a trait object. It's a "pointer + vtable" pair:
- The pointer points to the concrete value
- The vtable is a table of function pointers — one for each method of the trait
When you call obj.method() on a dyn Trait, the compiler emits a vtable lookup: load the method pointer from the vtable, call it indirectly. This is dynamic dispatch — resolved at runtime.
dyn Trait itself is unsized (the concrete type behind the pointer can be any size). So you always see it behind some pointer:
&dyn Trait // shared reference
&mut dyn Trait // exclusive reference
Box<dyn Trait> // owned, heap-allocated
Rc<dyn Trait> // shared ownership, single-threaded
Arc<dyn Trait> // shared ownership, thread-safe
A vector of mixed implementations:
trait Greet { fn greet(&self); }
struct En; struct Ja;
impl Greet for En { fn greet(&self) { println!("Hi"); } }
impl Greet for Ja { fn greet(&self) { println!("Hello"); } }
let mixed: Vec<Box<dyn Greet>> = vec![Box::new(En), Box::new(Ja)];
for g in &mixed { g.greet(); }
Without dyn, this is impossible — Vec<T> requires all elements be the same concrete type.
impl Trait — the static counterpart
impl Trait looks similar but is fundamentally different:
fn make_greeter(lang: &str) -> impl Greet {
if lang == "ja" { Ja } else { En }
// ❌ won't compile — return type must be a single concrete type
}
impl Trait in return position means "I return some specific type that implements Trait, but I'm hiding it from the caller." It's static dispatch — the compiler picks one type, monomorphizes, no vtable.
The trade-off:
impl Trait | dyn Trait | |
|---|---|---|
| Dispatch | Static (compile-time) | Dynamic (runtime vtable) |
| Speed | Faster (inlinable) | Slightly slower (one indirect call) |
| Heterogeneous collections | ❌ no | ✅ yes (Vec<Box<dyn Trait>>) |
| Object safety | Doesn't matter | Required (some traits aren't object-safe) |
Reth and Revm use both, with impl as the default and dyn reserved for cases where heterogeneity matters (lists of stages, plug-in points).
Object safety — when dyn Trait doesn't compile
A trait must be object safe to be used as dyn Trait. The rules are subtle, but the most common gotcha:
- No generic methods in the trait (other than over lifetimes)
- No
Selfreturns in methods other thanSelf: Sizedones - No associated constants
If you try Box<dyn MyTrait> and the trait isn't object-safe, you'll get a compile error like "cannot be made into an object." The fix is usually adding where Self: Sized to the offending method, or splitting the trait.
You won't hit this often as a consumer of Reth/Revm code, but it explains why some traits in the source aren't dyn-able.
Putting it together — reading a real signature
Back to:
pub fn add<IT: ITy, H: ?Sized>(context: Ictx<'_, H, IT>) -> Result {
Now you can read it word by word:
pub fn add— public function namedadd<IT: ITy, H: ?Sized>— two type parameters:ITmust implement theITytrait (interpreter-types marker — concrete vs traced vs sandboxed)His allowed to be unsized (so it can bedyn Host)
context: Ictx<'_, H, IT>— takes a context parameterized by both-> Result— returns a Result
Same function, two specialization paths: one for (ConcreteIT, ConcreteHost) (fully monomorphized, fastest), one for (ConcreteIT, dyn Host) (vtable for Host calls). The ?Sized is what enables the second path.
This is why Revm is "modular": the same opcode function compiles into multiple binaries for different execution modes, with the dispatch decided at the type-system level instead of via runtime branches.
Reading list
- Rust Book chapters 10 (Generics, Traits, Lifetimes), 17 (Trait Objects) — open them, even if just to skim the section headings. Free reference.
- Find any function in
reth/crateswith three or more type parameters. Read the signature. You should now be able to translate every piece into "what concrete shapes is this function valid for." - Try this thought experiment: if you removed
?SizedfromHin theaddsignature, what concrete error would the compiler give a caller passing&mut dyn Host? (Answer: "the traitSizedis not implemented fordyn Host.")
What you should walk away with
- Generics + bounds are the Rust analog of "interface" or "concept": "T is whatever, but it must support these operations."
Sizedis implicit on every type parameter;?Sizedopts out so the parameter can be a trait object.dyn Traitis a runtime-dispatched pointer-plus-vtable;impl Traitis compile-time monomorphized.- Revm's heavy use of generics is modularity at the type level — same code, multiple specializations.
When Intermediate lesson 1 hits you with three type parameters and ?Sized in the very first line, you'll read it as one connected sentence, not as five intimidating tokens.
Summary (3 lines)
- Four concepts: generics
<T>(compile-time concretisation) + trait boundsT: Bound(constraints) +?Sized(allows trait objects) +dyn/impl Trait(runtime vs static). Send + Sync + 'staticenable cross-thread move/share/lifetime → the substrate of Tokio tasks.- Next lesson: Arc / Mutex / RwLock — the shared-ownership primitives for concurrent code.