Polymorphism#
- Source:
Rust achieves polymorphism without inheritance. Where C++ uses virtual functions and class hierarchies, Rust provides two main approaches: trait objects (dynamic dispatch) and enums (closed-set dispatch). Both avoid the fragile base class problem.
Trait Objects (Dynamic Dispatch)#
Trait objects (&dyn Trait or Box<dyn Trait>) are Rust’s equivalent of C++
virtual function calls. They use a vtable for runtime dispatch.
C++ (virtual):
#include <iostream>
#include <memory>
#include <vector>
class Shape {
public:
virtual double area() const = 0;
virtual const char* name() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
double radius_;
public:
Circle(double r) : radius_(r) {}
double area() const override { return 3.14159265 * radius_ * radius_; }
const char* name() const override { return "Circle"; }
};
class Rectangle : public Shape {
double w_, h_;
public:
Rectangle(double w, double h) : w_(w), h_(h) {}
double area() const override { return w_ * h_; }
const char* name() const override { return "Rectangle"; }
};
void print_area(const Shape& s) {
std::cout << s.name() << ": " << s.area() << "\n";
}
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(3.0));
shapes.push_back(std::make_unique<Rectangle>(4.0, 5.0));
for (auto& s : shapes) print_area(*s);
}
Rust:
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
fn name(&self) -> &str { "Circle" }
}
impl Shape for Rectangle {
fn area(&self) -> f64 { self.width * self.height }
fn name(&self) -> &str { "Rectangle" }
}
fn print_area(shape: &dyn Shape) {
println!("{}: area = {:.2}", shape.name(), shape.area());
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 3.0 }),
Box::new(Rectangle { width: 4.0, height: 5.0 }),
];
for s in &shapes {
print_area(s.as_ref());
}
}
Key differences from C++:
No inheritance hierarchy — any type can implement any trait
No virtual destructor needed —
Box<dyn Trait>handles cleanup viaDropdynkeyword makes dynamic dispatch explicit (C++ hides it behindvirtual)
Static vs Dynamic Dispatch#
Rust lets you choose between static dispatch (monomorphization, like C++ templates) and dynamic dispatch (vtable, like C++ virtual) per call site.
// Static dispatch — compiler generates specialized code per type
// Equivalent to C++ templates: zero overhead, but larger binary
fn print_area_static<T: Shape>(shape: &T) {
println!("{}: area = {:.2}", shape.name(), shape.area());
}
// Dynamic dispatch — single function, vtable lookup at runtime
// Equivalent to C++ virtual: smaller binary, slight runtime cost
fn print_area_dynamic(shape: &dyn Shape) {
println!("{}: area = {:.2}", shape.name(), shape.area());
}
Aspect |
Static ( |
Dynamic ( |
|---|---|---|
C++ equivalent |
Templates |
Virtual functions |
Dispatch |
Compile-time |
Runtime (vtable) |
Performance |
Zero-cost, inlinable |
Indirect call overhead |
Binary size |
Larger (monomorphized copies) |
Smaller (single function) |
Heterogeneous collections |
No |
Yes |
Enum-based Dispatch#
When the set of variants is known at compile time, enums provide a closed-set alternative to trait objects. This avoids heap allocation and vtable overhead.
C++ (variant):
#include <iostream>
#include <variant>
#include <string>
struct Dog { std::string name; };
struct Cat { std::string name; };
using Animal = std::variant<Dog, Cat>;
const char* speak(const Animal& a) {
return std::visit([](auto& v) -> const char* {
if constexpr (std::is_same_v<std::decay_t<decltype(v)>, Dog>) return "Woof!";
else return "Meow!";
}, a);
}
Rust:
enum Animal {
Dog(String),
Cat(String),
}
impl Animal {
fn speak(&self) -> &str {
match self {
Animal::Dog(_) => "Woof!",
Animal::Cat(_) => "Meow!",
}
}
}
Advantages over trait objects:
Stack-allocated, no
BoxneededExhaustive
match— compiler warns if you miss a variantBetter cache locality
Returning Trait Objects#
Functions can return different concrete types via Box<dyn Trait>, similar to
returning std::unique_ptr<Base> in C++.
fn make_shape(kind: &str) -> Box<dyn Shape> {
match kind {
"circle" => Box::new(Circle { radius: 5.0 }),
_ => Box::new(Rectangle { width: 4.0, height: 3.0 }),
}
}
Trait Object References (&dyn Trait)#
A &dyn Trait is a fat pointer — two machine words (16 bytes on 64-bit):
one pointer to the data, one pointer to the vtable. No heap allocation is involved;
it simply borrows an existing value.
This is the lightest way to do dynamic dispatch:
fn total_area(shapes: &[&dyn Shape]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
let c = Circle { radius: 1.0 };
let r = Rectangle { width: 2.0, height: 3.0 };
let refs: Vec<&dyn Shape> = vec![&c, &r]; // no Box, no heap
println!("{}", total_area(&refs));
In C++, the equivalent is passing const Shape& — but C++ references are thin
pointers (the vtable pointer lives inside the object). Rust’s fat pointer keeps the
vtable external, which is why dyn is needed to opt in.
Object Safety#
Not all traits can be used as trait objects. A trait is object-safe if:
No methods return
SelfNo methods have generic type parameters
All methods have a receiver (
&self,&mut self,self, etc.)
// Object-safe — can use as `dyn Drawable`
trait Drawable {
fn draw(&self);
}
// NOT object-safe — returns Self
trait Clonable {
fn clone(&self) -> Self;
}
// NOT object-safe — generic method
trait Converter {
fn convert<T>(&self) -> T;
}
The Clone trait in std is not object-safe, which is why you cannot write
Box<dyn Clone>. Use workarounds like a helper trait when needed.
See Also#
Traits and Generics - Trait definitions, bounds, and deriving
Smart Pointers - Box, Rc, Arc for trait object storage
Casting - Type conversions and
Anytrait