Static Dispatch, Dynamic Dispatch & Monomorphization in Rust — Explained Like a Human

rust dev.to

When I first read about this topic, every article used words like "polymorphism," "vtable," and "compile-time resolution" in the very first line.

I understood none of it. I just nodded and moved on — and the confusion stayed with me for weeks.

So this time: no fancy words first. Let's start from real life.


📱 A Real Life Example First (No Code, Just Think About This)

Imagine you want to call someone for help.

Case 1: You know exactly who to call. You open your contacts, tap "Mom", and the phone dials her number directly. No confusion, no waiting, no middleman. You knew exactly who you were calling before you even picked up the phone.

Case 2: You call a company's customer care number. You don't know who will pick up. Could be Agent A, could be Agent B — whoever is free at that moment. The company decides while you're on the call, not before.

That's it. That's the whole idea — before we even touch Rust.

📱 Calling "Mom" directly = decision made beforehand = Static Dispatch

☎️ Calling customer care and getting routed = decision made at call time = Dynamic Dispatch

In Rust, your code does the exact same two things — it either knows exactly which function to run before the program starts, or it figures it out while the program is running.

Keep this phone analogy in your head. We'll come back to it throughout.


🔔 Let's Bring This Into Code — With a Simple Example

Forget shapes and circles. Let's use something everyone understands: sending a notification — like an app sending you either an Email or an SMS.

trait Notification {
    fn send(&self);
}

struct Email {
    address: String,
}

struct SMS {
    phone_number: String,
}

impl Notification for Email {
    fn send(&self) {
        println!("Sending Email to {}", self.address);
    }
}

impl Notification for SMS {
    fn send(&self) {
        println!("Sending SMS to {}", self.phone_number);
    }
}
Enter fullscreen mode Exit fullscreen mode

Simple enough — two different ways of notifying someone, both following the same basic contract: "you can send me."

Now let's write a function that sends a notification:

fn notify<T: Notification>(item: T) {
    item.send();
}
Enter fullscreen mode Exit fullscreen mode

This works perfectly fine if I call it like this:

notify(Email { address: String::from("ravi@example.com") });
notify(SMS { phone_number: String::from("9999999999") });
Enter fullscreen mode Exit fullscreen mode

Both lines work. No problem yet. 👍


😕 Here's Where It Gets Confusing (This Was My Exact Confusion)

Now I wanted one list, containing both an Email and an SMS — so I could loop through and send all notifications at once. Felt like a totally normal thing to want.

let notifications = vec![
    Email { address: String::from("ravi@example.com") },
    SMS { phone_number: String::from("9999999999") },
];
Enter fullscreen mode Exit fullscreen mode

This does not compile. 🚫

Rust says these are different types and refuses to put them in the same list.

This is the moment that actually matters. Not vocabulary — this exact confusion.

Why can't I put both in one list? They both know how to send(). Why does Rust care?


🔬 Monomorphization — The Compiler's Hidden Superpower

Here's the plain-language reason why.

Remember the "calling Mom directly" example? When you write a generic function like notify<T: Notification>, Rust treats it almost like writing a separate, hardcoded version of that function for every single type you actually use with it.

So behind the scenes, when you call notify with an Email and then with an SMS, Rust quietly creates two separate versions — something like this:

// Rust creates this secretly — you never write it yourself
fn notify_for_email(item: Email) {
    item.send(); // Rust already knows: call Email's send
}

fn notify_for_sms(item: SMS) {
    item.send(); // Rust already knows: call SMS's send
}
Enter fullscreen mode Exit fullscreen mode

You never write this yourself and you never even see it. Rust does it silently while compiling. This is called monomorphization.

Don't worry about the word. Just remember what it means in plain English:

🔬 Monomorphization = Rust takes your one generic function and quietly stamps out a separate, specific copy of it for every type you use it with — before your program even runs.

This is exactly why Email and SMS couldn't sit in the same Vec using a plain generic. Once Rust locks in T as Email in one copy of the function, that copy can only ever deal with Email. Like dialing "Mom" — once that copy exists, there's no ambiguity left.

This whole approach — Rust knowing exactly which send() to call because it generated a dedicated copy of the function — is static dispatch. It's "calling Mom directly." Fast, no confusion, decided way ahead of time.

The cost: if you use notify with five different types, Rust creates five separate copies in your compiled program. More code gets generated. That's the price for that speed and certainty.


✅ So How Do I Actually Mix Email and SMS in One List?

This is where we go back to the "customer care line" idea — the version where the decision happens while the call is happening, not before.

In Rust, this is written using dyn Trait:

fn notify_all(items: Vec<Box<dyn Notification>>) {
    for item in items {
        item.send();
    }
}

let notifications: Vec<Box<dyn Notification>> = vec![
    Box::new(Email { address: String::from("ravi@example.com") }),
    Box::new(SMS { phone_number: String::from("9999999999") }),
];

notify_all(notifications);
Enter fullscreen mode Exit fullscreen mode

✅ This compiles. One list, two different types, both treated as "something that can send a notification."


🧠 What Is Box<dyn Notification> Really? (The Part That Finally Clicked For Me)

Think of Box<dyn Notification> as a sticky note with two pieces of information on it:

  1. Where the actual data lives — a pointer to the real Email or SMS value sitting in memory
  2. A small instruction sheet for that specific type — a tiny lookup table that says "if someone asks for send, here's exactly which function to run for this type"

That second part is what people call a vtable (virtual table). You don't need the word — just remember:

☎️ vtable = a lookup sheet, attached to each value, telling the program which actual function to run at runtime.

So when you call item.send() on a Box<dyn Notification>, the program doesn't already know at compile time whether this item is an Email or SMS. So it looks at the attached lookup sheet while running, finds the right function, and calls it.

That lookup — happening while your program runs — is exactly the "customer care line" situation. This is dynamic dispatch. It's marginally slower than calling Mom directly — not because of some mysterious penalty, but for the most boring reason possible: there's literally one extra step (checking the lookup sheet) before the actual function runs.

Why do we need Box?

An Email and an SMS aren't the same size in memory. Rust always needs to know the exact size of a value upfront. By using Box, you're holding a pointer — and a pointer is always the same fixed size, no matter what it points to. That's it.


⚖️ Quick Side-by-Side, In Plain Words

Static Dispatch (generics) Dynamic Dispatch (dyn Trait)
Real life version Calling "Mom" directly Calling customer care
When Rust decides Before the program runs While the program is running
What Rust does Stamps out a copy per type (monomorphization) Attaches a lookup sheet (vtable) to each value
Speed ⚡ Slightly faster 🐢 Tiny overhead
Binary size 📦 Larger (more copies) ✅ Smaller (one version)
Mix different types? ❌ No — locked to one type ✅ Yes — that's the whole point

🤔 So When Do You Pick Which One?

Ask yourself one simple question:

"Do I need to keep genuinely different types together — in the same place — and decide what to do with them only while the program is running?"

If yes — like our notification list, a list of payment methods, or plugins added at runtime — use dyn Trait.

If no — if you're fine handling one type at a time and just want one piece of code that works across many types — use generics. You get the "calling Mom directly" speed, automatically, for free.


🦀 Why This Is Genuinely "The Heart of Rust"

This is the actual reason people say this topic matters so much in Rust.

Rust makes a big promise: writing flexible, reusable code shouldn't cost you anything at runtime.

The static dispatch + monomorphization mechanism is how that promise is kept — your generic code gets turned into the exact same machine code you would've written by hand for each specific type, with zero extra runtime cost.

dyn Trait exists for the cases where that promise genuinely can't apply — where you truly don't know the type until the program is running. And in Rust, you have to ask for that flexibility on purpose, by writing dyn. It's never sneaked in on you.

You always know, just by reading the code, whether you're "calling Mom directly" or "calling customer care."

And now, so do you. 📱☎️


🔑 The One-Line Summary

  • Static dispatch = compiler decides at build time → fast, bigger binary, generics
  • Dynamic dispatch = program decides at runtime → flexible, smaller binary, dyn Trait
  • Monomorphization = how static dispatch actually works under the hood → one generic becomes many type-specific copies

If this helped you, drop a ❤️ — it took me months to piece this together, and I hope this saves you that time.

Have a question or something still unclear? Leave a comment below — I read every one. 👇

Source: dev.to

arrow_back Back to Tutorials