FABRKNT
Reading the Stack — Bridge to Intermediate
Rust for source-reading
Lesson 6 of 10·CONTENT15 min30 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Reading the Stack — Bridge to Intermediate
Lesson role
CONTENT
Sequence
6 / 10

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 default Sized bound. Required to accept dyn 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 require T: Send + 'static. The foundation of Rust concurrency.
  • 'static lifetime. 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]) — same
  • dyn 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 Traitdyn Trait
DispatchStatic (compile-time)Dynamic (runtime vtable)
SpeedFaster (inlinable)Slightly slower (one indirect call)
Heterogeneous collections❌ no✅ yes (Vec<Box<dyn Trait>>)
Object safetyDoesn't matterRequired (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 Self returns in methods other than Self: Sized ones
  • 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 named add
  • <IT: ITy, H: ?Sized> — two type parameters:
    • IT must implement the ITy trait (interpreter-types marker — concrete vs traced vs sandboxed)
    • H is allowed to be unsized (so it can be dyn 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

  1. Rust Book chapters 10 (Generics, Traits, Lifetimes), 17 (Trait Objects) — open them, even if just to skim the section headings. Free reference.
  2. Find any function in reth/crates with 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."
  3. Try this thought experiment: if you removed ?Sized from H in the add signature, what concrete error would the compiler give a caller passing &mut dyn Host? (Answer: "the trait Sized is not implemented for dyn 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."
  • Sized is implicit on every type parameter; ?Sized opts out so the parameter can be a trait object.
  • dyn Trait is a runtime-dispatched pointer-plus-vtable; impl Trait is 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 bounds T: Bound (constraints) + ?Sized (allows trait objects) + dyn / impl Trait (runtime vs static).
  • Send + Sync + 'static enable cross-thread move/share/lifetime → the substrate of Tokio tasks.
  • Next lesson: Arc / Mutex / RwLock — the shared-ownership primitives for concurrent code.