Generics

199. Static Dispatch — Generics Beat Box<dyn Trait> When You Can Afford the Code

Box<dyn Trait> is the reflex when a function “takes something that implements a trait.” But every call through it pays for a vtable hop the compiler can’t see past. Swap it for a generic and the optimizer inlines the whole thing.

This is the morning half of a pair with this afternoon’s #[inline] & Copy bite: both are about giving the optimizer a body it can actually fold into the caller.

The cost: Box<dyn Trait> hides the call from the optimizer

When you accept Box<dyn Fn(i32) -> i32> (or any dyn Trait), the concrete type is erased. At each call site the program loads a function pointer from a vtable and jumps through it. The compiler has no idea what’s on the other end, so it can’t inline the body, can’t constant-fold through it, can’t vectorize a loop around it:

1
2
3
fn apply_all(f: Box<dyn Fn(i32) -> i32>, xs: &[i32]) -> Vec<i32> {
    xs.iter().map(|&x| f(x)).collect() // indirect call every iteration
}

There’s also a heap allocation just to hold the closure, and the pointer-chase ruins instruction-cache locality in a hot loop.

The fix: a generic parameter monomorphizes to the real type

Take impl Fn (sugar for a generic) instead. The compiler stamps out a specialized copy of apply_all for each concrete f you pass — monomorphization. Inside that copy the closure’s body is fully visible, so it gets inlined and the map loop optimizes as if you’d written the arithmetic by hand:

1
2
3
fn apply_all<F: Fn(i32) -> i32>(f: F, xs: &[i32]) -> Vec<i32> {
    xs.iter().map(|&x| f(x)).collect() // direct call, inlinable
}

No box, no vtable, no allocation. impl Trait in argument position is the same thing with less typing:

1
2
3
fn apply_all(f: impl Fn(i32) -> i32, xs: &[i32]) -> Vec<i32> {
    xs.iter().map(|&x| f(x)).collect()
}
1
2
let doubled = apply_all(|x| x * 2, &[1, 2, 3]);
assert_eq!(doubled, vec![2, 4, 6]);

Both generic versions compile to a tight loop with the multiply spliced straight in.

The same trick for returns: impl Trait instead of Box<dyn>

Returning a dyn value forces a box too. If the function only ever returns one concrete type, impl Trait keeps it static — the caller gets the real type and can inline through it:

1
2
3
4
5
6
fn adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n          // one concrete closure type, no Box
}

let add5 = adder(5);
assert_eq!(add5(10), 15);

When dyn is still the right call

Static dispatch trades code size for speed: each instantiation is a fresh copy, so monomorphizing over many types bloats the binary. And generics can’t do heterogeneous collections — Vec<Box<dyn Draw>> holding circles and squares genuinely needs dynamic dispatch, because the element type varies at runtime. Reach for dyn when you need a uniform type for mixed values, want to shrink compile times and binary size, or the call isn’t hot enough to matter. Reach for generics / impl Trait when the call sits in a loop and you want the optimizer to see through it.

1
2
3
4
5
// heterogeneous → dyn is correct
let shapes: Vec<Box<dyn Fn() -> &'static str>> =
    vec![Box::new(|| "circle"), Box::new(|| "square")];
let names: Vec<&str> = shapes.iter().map(|s| s()).collect();
assert_eq!(names, vec!["circle", "square"]);

Rule of thumb: default to a generic, and downgrade to dyn only when you have a reason — mixed types, code-size pressure, or a cold path where the indirection is free.

192. impl Into<String> — Take Owned or Borrowed Without an Extra Allocation

Bite 191 said: if you only read the argument, take &str. But what if you need to store it? Taking &str and calling .to_owned() always allocates — even when the caller handed you a String it was about to throw away. impl Into<String> fixes that.

The hidden re-allocation

When a function keeps the value, the “take &str” rule turns into a trap:

1
2
3
4
5
struct Label { text: String }

fn make_label(text: &str) -> Label {
    Label { text: text.to_owned() } // always allocates
}

A literal caller has to allocate eventually — fair enough. But look what happens when the caller already owns a String:

1
2
3
4
let owned = String::from("Status: OK");
let label = make_label(&owned);
// `owned` is copied into a brand-new allocation, then `owned` is dropped.
// We threw away a perfectly good String and allocated a second one.

The caller had an owned buffer it no longer needed, and we ignored it.

Accept anything that becomes a String

Take impl Into<String>. A String moves in with zero copying; a &str allocates exactly once — never more:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct Label { text: String }

fn make_label(text: impl Into<String>) -> Label {
    Label { text: text.into() }
}

// Literal: one allocation, unavoidable since we store it.
let a = make_label("Status: OK");

// Owned String: MOVED in. No copy, no second allocation.
let owned = String::from("Status: OK");
let b = make_label(owned);

assert_eq!(a.text, "Status: OK");
assert_eq!(b.text, "Status: OK");

Same call site for both, and the owned case is now free. The conversion happens lazily at the boundary, exactly once, and only when it must.

When you only read: impl AsRef

If you don’t store the value but still want to accept more than deref coercion allows (String, &str, Box<str>, Cow<str>, …), reach for impl AsRef<str>:

1
2
3
4
5
6
7
fn shout(text: impl AsRef<str>) -> String {
    text.as_ref().to_uppercase()
}

assert_eq!(shout("hi"), "HI");                       // &str
assert_eq!(shout(String::from("hi")), "HI");          // String
assert_eq!(shout(Box::<str>::from("hi")), "HI");      // Box<str>

as_ref() is a cheap borrow — no allocation — and the generic accepts every string-like type without forcing the caller to convert first.

The rule of thumb

If the function stores the string, take impl Into<String> so an owned argument moves in for free. If it only reads but you want maximum flexibility, take impl AsRef<str>. Plain &str (bite 191) is still the right default for simple read-only functions — these two just cover the cases it can’t.

165. PhantomData<T> — The Zero-Sized Marker That Pretends to Own a T

You write a generic struct, never actually store a T in any field, and the compiler stops you with “parameter T is never used”. PhantomData<T> is the zero-cost lie that fixes it — a marker that occupies no bytes but tells the compiler “act as if I own a T.”

The problem shows up the moment you build a typed handle around something that isn’t a T:

1
2
3
4
// Won't compile: T isn't actually stored anywhere.
struct TypedId<T> {
    raw: u64,
}

rustc rejects this because an unused type parameter is almost always a bug — variance, drop checking, and Send/Sync all depend on what a struct claims to own. std::marker::PhantomData<T> is the escape hatch: a zero-sized struct that pretends the type parameter is used:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::marker::PhantomData;

struct TypedId<T> {
    raw: u64,
    _marker: PhantomData<T>,
}

impl<T> TypedId<T> {
    fn new(raw: u64) -> Self {
        Self { raw, _marker: PhantomData }
    }
}

struct User;
struct Order;

let u: TypedId<User>  = TypedId::new(1);
let o: TypedId<Order> = TypedId::new(1);

// Same raw value, different types — the compiler refuses to mix them.
// let _: TypedId<User> = o; // error: expected TypedId<User>, found TypedId<Order>

assert_eq!(std::mem::size_of::<TypedId<User>>(), 8); // still just the u64

The _marker field disappears at runtime — size_of::<TypedId<User>>() is exactly size_of::<u64>(). But at compile time, TypedId<User> and TypedId<Order> are distinct types you can’t accidentally swap.

The same pattern fixes lifetimes too. FFI wrappers borrow from a buffer they don’t physically point into:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::marker::PhantomData;

struct CursorHandle<'a> {
    raw_ptr: *const u8,
    _borrow: PhantomData<&'a [u8]>,
}

impl<'a> CursorHandle<'a> {
    fn new(buf: &'a [u8]) -> Self {
        Self { raw_ptr: buf.as_ptr(), _borrow: PhantomData }
    }
}

let buf = vec![1u8, 2, 3];
let cursor = CursorHandle::new(&buf);
// drop(buf); // compile error — cursor still borrows it, thanks to PhantomData
let _ = cursor;

Without the PhantomData<&'a [u8]>, the 'a would be unused and the compiler wouldn’t enforce that buf outlives cursor. With it, the borrow checker treats CursorHandle<'a> as if it held a real &'a [u8].

Three flavors of PhantomData you’ll see in the wild — pick by what you want the compiler to believe:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::marker::PhantomData;

// 1. Owns a T (covariant, drops a T): PhantomData<T>
struct Owns<T>(PhantomData<T>);

// 2. Borrows a T (no drop): PhantomData<&'a T>
struct Borrows<'a, T>(PhantomData<&'a T>);

// 3. Neither Send nor Sync: PhantomData<*const ()>
struct NotThreadSafe(PhantomData<*const ()>);

fn assert_send<T: Send>() {}
// assert_send::<NotThreadSafe>(); // would fail — raw ptr makes it !Send

assert_eq!(std::mem::size_of::<Owns<u64>>(), 0);
assert_eq!(std::mem::size_of::<Borrows<'_, u64>>(), 0);
assert_eq!(std::mem::size_of::<NotThreadSafe>(), 0);

That last one is the cheap way to opt a type out of Send/Sync without unsafeRc<T> uses exactly this trick internally to stay single-threaded.

PhantomData is the bookkeeping behind almost every wrapper type you’ve used. Cell, Cow, Pin, Rc, and NonNull all carry one — it’s how they tell the compiler what they conceptually own without paying for it at runtime.