Academy
From Static to Dynamic: Implementing Traits and Mastering Trait Objects in Rust
Master Rust polymorphism with static (impl Trait) and dynamic (dyn Trait) dispatch. Learn when to choose zero-cost abstractions or runtime flexibility.
10 menit baca
Di halaman ini
- Implementing Traits: The Basics That Aren't Basic
- Static Dispatch: When Performance Is Non-Negotiable
- Dynamic Dispatch: When You Need Runtime Flexibility
- Box<dyn Trait>: Owned Trait Objects
- Static vs. Dynamic: A Decision Framework
- Broader Implications: Explicit Trade-offs as Design Philosophy
- Practical Applications & Strategic Takeaways
- Our Commitment to Open Knowledge
- Support Our Mission & Get Your Handbook
There's an old joke in programming that goes: "We wanted polymorphism, so we got inheritance. Now we have two problems." It's funny because it's true—and because every developer who's spent an afternoon untangling a virtual method resolution order knows exactly what the second problem feels like. (The first problem, for the record, was that your code was boring. The second problem is that your code is now incomprehensible.)
But here's the thing: polymorphism itself isn't the problem. The ability to write code that works with many types is genuinely, undeniably useful. You want a function that can process any shape, any logger, any database connection—without knowing the concrete type at the call site. That's not an anti-pattern; that's good engineering. The problem was always about how polymorphism was implemented: virtual dispatch everywhere, vtable overhead you didn't ask for, and a one-size-fits-all approach that gave you runtime flexibility when you wanted compile-time performance, and compile-time rigidity when you wanted runtime flexibility.
Rust does something that, in retrospect, seems obvious but that no mainstream language had done before: it gives you two kinds of polymorphism, makes both explicit, and lets you choose based on what your code actually needs. Static dispatch gives you zero-cost abstraction—the compiler generates specialized code for each type, and the result is as fast as if you'd written it by hand. Dynamic dispatch gives you runtime flexibility—different types behind a single interface, resolved at runtime through a vtable. And the critical design insight: you choose which one, per call site, with clear syntax that makes the trade-off visible.
At RantAI, where we build systems that need both the raw performance of statically dispatched computation kernels and the flexibility of dynamically dispatched plugin architectures, understanding the difference between impl Trait and dyn Trait isn't academic—it's the foundation of our architecture. This article, drawn from Chapter 3, Sections 3.2.3 and 3.2.4 of our guide "The Rust Programming Language," shows you how to implement traits for your types, when to use static dispatch, when to reach for dynamic dispatch, and why Rust's explicit approach to this choice is better than what any other language offers.
Implementing Traits: The Basics That Aren't Basic#
Implementing a trait for a type is straightforward in syntax but profound in implication. You're declaring a contract: "this type can do this thing, and here's how."
trait Printable {
fn print(&self);
fn print_twice(&self) {
self.print();
self.print();
}
}
struct Point {
x: i32,
y: i32,
}
impl Printable for Point {
fn print(&self) {
println!("Point({}, {})", self.x, self.y);
}
// print_twice is inherited from the default implementation — free!
}
struct Circle {
radius: f64,
}
impl Printable for Circle {
fn print(&self) {
println!("Circle(radius={:.2})", self.radius);
}
// Override the default if you want custom behavior:
fn print_twice(&self) {
println!("⭕ Circle(r={:.2}) — printed with style, twice!", self.radius);
println!("⭕ Circle(r={:.2}) — because once wasn't enough.", self.radius);
}
}
Both Point and Circle implement Printable. Point uses the default print_twice (which calls print twice). Circle overrides it with custom behavior. A type can implement as many traits as it needs—no single-inheritance limitation, no diamond problem, no "which base class does this method come from?" ambiguity. Each impl block is self-contained. Each contract is independent.
Here's what makes this genuinely different from interfaces in Java or C#: in those languages, if you want shared behavior, you need a base class. If you want a contract, you need an interface. If you want both, you need to choose between them or use some awkward combination. In Rust, a single trait gives you both the contract and the shared behavior through default methods, without any of the coupling that makes inheritance problematic. It's elegant in a way that becomes increasingly apparent the longer you use it.
Static Dispatch: When Performance Is Non-Negotiable#
When you use generics with trait bounds, Rust uses monomorphization—a fancy word for "the compiler generates specialized code for each concrete type at compile time":
fn print_item(item: &impl Printable) {
item.print();
}
// Or equivalently, with explicit generic syntax:
fn print_item_generic<T: Printable>(item: &T) {
item.print();
}
fn main() {
let p = Point { x: 10, y: 20 };
let c = Circle { radius: 5.0 };
print_item(&p); // Compiler generates: print_item_Point(&p)
print_item(&c); // Compiler generates: print_item_Circle(&c)
}
When you call print_item(&p), the compiler doesn't generate a generic function that dispatches through a vtable at runtime. It generates a specialized version of print_item specifically for Point, with the print() call inlined directly. It generates another version for Circle. Each version is as fast as if you'd written a dedicated function by hand—because that's exactly what the compiler did for you.
This is zero-cost abstraction in its purest form. You write generic, reusable code. The compiler transforms it into specialized, maximally efficient code. The abstraction exists in your source code for readability and maintainability; it doesn't exist in the compiled binary, where only raw performance matters.
The downside? Each concrete type gets its own copy of the function, which can increase binary size if you use many types with the same generic function. This is rarely a problem in practice, but it's worth knowing about. (And if it ever becomes a problem, that's when you reach for dynamic dispatch.)
Dynamic Dispatch: When You Need Runtime Flexibility#
Static dispatch is great when you know all the types at compile time. But sometimes you don't. Sometimes you have a collection of different types that all implement the same trait, and you need to process them uniformly. Sometimes you're building a plugin system where new types are added without recompiling the core. Sometimes you just need a Vec of "things that can print themselves."
Enter trait objects:
fn print_anything(item: &dyn Printable) {
item.print();
}
fn print_all(items: &[&dyn Printable]) {
for item in items {
item.print();
}
}
fn main() {
let p = Point { x: 10, y: 20 };
let c = Circle { radius: 5.0 };
// Different types in the same slice!
let items: Vec<&dyn Printable> = vec![&p, &c];
print_all(&items);
}
The dyn Printable syntax creates a trait object—a fat pointer consisting of a pointer to the data and a pointer to a vtable containing the trait's method implementations. Method calls go through the vtable at runtime, which adds a small overhead (one pointer indirection per call) but enables patterns that are impossible with static dispatch alone.
That Vec<&dyn Printable> is the key insight: you can store different concrete types in the same collection, as long as they all implement the same trait. Point and Circle are different types with different sizes and different layouts—you can't put them in a Vec<Point> together. But you can put references to them (or Boxed trait objects) in a Vec<&dyn Printable> because the trait object abstraction normalizes them to a common interface.
Box<dyn Trait>: Owned Trait Objects#
When you need to own the data (not just borrow it), use Box<dyn Trait>:
fn create_shape(kind: &str) -> Box<dyn Printable> {
match kind {
"point" => Box::new(Point { x: 0, y: 0 }),
"circle" => Box::new(Circle { radius: 1.0 }),
_ => panic!("Unknown shape: {}", kind),
}
}
fn main() {
let shapes: Vec<Box<dyn Printable>> = vec![
create_shape("point"),
create_shape("circle"),
create_shape("circle"),
];
for shape in &shapes {
shape.print();
}
}
Box<dyn Printable> is a heap-allocated trait object that owns its data. The Vec owns the boxes, which own the data. When the Vec is dropped, everything is cleaned up automatically. No manual memory management, no reference counting (unless you want it), just ownership doing its thing.
This pattern is the foundation of plugin architectures, strategy patterns, and any system where you need to decide at runtime which implementation to use. Factory functions return Box<dyn Trait>, and the calling code works with the trait interface without knowing or caring about the concrete type.
Static vs. Dynamic: A Decision Framework#
Here's the practical guide that will save you from overthinking this:
Use static dispatch (impl Trait / generics) when you know the types at compile time, performance is critical, you want the compiler to inline and optimize aggressively, or you're writing library code where each user will use the function with their own types. This is the default choice. When in doubt, start here.
Use dynamic dispatch (dyn Trait) when you need heterogeneous collections (different types in the same container), you're building plugin or extension systems, you want to reduce binary size (one function body instead of N monomorphized copies), or the concrete type is determined at runtime (factory patterns, configuration-driven behavior).
The performance difference: Static dispatch has zero overhead—the function call is either direct or inlined. Dynamic dispatch adds one pointer indirection per method call (through the vtable). In practice, this overhead is negligible for most applications. If you're calling a trait method in a tight inner loop processing millions of elements per second, static dispatch is measurably faster. If you're calling it once per HTTP request, the difference is lost in the noise. Profile before you optimize.
Broader Implications: Explicit Trade-offs as Design Philosophy#
Most languages make this choice for you. Java uses virtual dispatch everywhere (every method call is potentially overridden). C++ defaults to non-virtual but lets you opt in with virtual. Go uses interface dispatch (similar to dynamic) for all interface calls. None of them let you see at the call site whether you're paying for dynamic dispatch or not.
Rust makes both options equally accessible and equally explicit. When you write impl Trait, you know you're getting static dispatch. When you write dyn Trait, you know you're getting dynamic dispatch. There's no hidden cost, no surprise overhead, no "wait, is this method virtual or not?" ambiguity. The trade-off is visible in the code, documented in the type, and chosen consciously by the developer.
At RantAI, we use this distinction architecturally. Our hot computational paths—simulation loops, data processing pipelines, mathematical kernels—use static dispatch exclusively. Every method call is inlined, every generic is monomorphized, every abstraction is zero-cost. Our plugin systems, configuration handlers, and extension points use dynamic dispatch, where the flexibility of runtime type selection matters more than the nanoseconds saved by static dispatch. The ability to choose per-call-site rather than per-class-hierarchy is genuinely a superpower for systems design.
Practical Applications & Strategic Takeaways#
For newcomers: Start with static dispatch (impl Trait). It's simpler, faster, and handles the vast majority of use cases. Reach for dyn Trait only when the compiler tells you it's needed (usually because you're trying to put different types in the same collection) or when you explicitly need runtime flexibility.
For C++ veterans: Think of impl Trait as C++ templates (but with better error messages and checked at definition). Think of dyn Trait as C++ virtual functions (but without requiring a base class or modifying the type's definition). The key difference: in Rust, you choose per-use-site, not per-class-definition. A method isn't inherently virtual or non-virtual—the caller decides how to dispatch it.
For architects: Design your systems with a clear boundary between "things that are known at compile time" (static dispatch) and "things that are decided at runtime" (dynamic dispatch). Hot paths should be generic. Extension points should be trait objects. This separation gives you both performance and flexibility without compromising either.
Our Commitment to Open Knowledge#
RantAI is committed to open education. Trait implementation, static dispatch, and dynamic dispatch are covered comprehensively in Chapter 3, Sections 3.2.3 and 3.2.4 of our guide, "The Rust Programming Language," freely available online.
Explore these concepts further: https://trpl.rantai.dev
Support Our Mission & Get Your Handbook#
If this article has clarified the difference between static and dynamic dispatch—or at least saved you from the trap of using dyn Trait everywhere when impl Trait would have been both simpler and faster—consider supporting RantAI's educational mission.
Get the Handbook on Amazon KDP: https://www.amazon.com/dp/B0DHCMD3F2
Get the Handbook on Google Play Books: https://play.google.com/store/books/details?id=INwfEQAAQBAJ
Have you ever switched from dynamic dispatch to static dispatch (or vice versa) and been surprised by the result? Do you have a rule of thumb for when to use which? Share your experience below—these are the kinds of architectural decisions that benefit from collective wisdom.
#RustLang #Traits #DynamicDispatch #StaticDispatch #Polymorphism #RantAI #LearnRust #Performance #SystemsProgramming #SoftwareArchitecture
Artikel berikutnya
Traits: Rust's Answer to Interfaces, Inheritance, and Everything In Between