Simulating OOP in Rust: I Did It, and I Regret Everything
I came to Rust with baggage. Years of writing C++ and Java had wired me to think in objects, inheritance, and polymorphism. Encapsulation felt natural. Classes were home. Methods were my comfort zone. So when I first started writing Rust, my instinct screamed: “Okay, where’s my base class? My interface? My virtual function?” And that, dear reader, was the beginning of my descent into madness. Because I tried to simulate OOP in Rust. And even though it worked, I’ll never do it again. Why I Thought OOP Was a Good Idea (Spoiler: It Wasn’t) I was building a small simulation system — entities like Shape, Circle, Square, etc. In C++, this would be a 10-minute setup:
class Shape { public:
virtual double area() const = 0; virtual ~Shape() = default;
};
class Circle : public Shape {
public:
Circle(double r) : r(r) {}
double area() const override { return 3.14 * r * r; }
private:
double r;
};
You store them in a vector of pointers and call area() polymorphically. Easy. Then I tried the same in Rust.
Step 1 — Naive Port: Traits and Boxes I started simple:
trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
} impl Shape for Circle {
fn area(&self) -> f64 {
3.14 * self.radius * self.radius
}
}
So far, so good. But then I tried to make a vector of shapes:
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 3.0 }),
];
for shape in shapes {
println!("Area: {}", shape.area());
}
}
It compiled. It worked. I thought I’d cracked it — “OOP in Rust! Easy!” But what I didn’t realize was that I’d just invited dynamic dispatch, heap allocation, and trait object overhead into a system that was supposed to be zero-cost. Architecture Flow: What’s Actually Happening
Here’s what’s going on under the hood: [Shape Trait]
↓ (dyn Trait)
[Virtual Table (vtable)]
↓
[Box Pointer → Heap-Allocated Struct]
↓
[Concrete Struct (e.g., Circle)]
When you call shape.area(), Rust:
- Looks up the area function pointer in the vtable
- Follows the heap pointer to the actual object
- Invokes the function indirectly
In C++ this is normal. In Rust, it’s a step back from zero-cost abstractions — you lose compile-time dispatch and optimizations.
Step 2 — Adding More “Objects” Of course, I didn’t stop there. I added another type:
struct Rectangle {
width: f64, height: f64,
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
Now I had multiple shapes in my list. Everything was great… until I needed cloning.
fn clone_shape(shape: &Box<dyn Shape>) -> Box<dyn Shape> {
// ❌ Error: the trait `Clone` is not implemented for `dyn Shape`
}
You see, trait objects (dyn Shape) lose all compile-time type information. You can’t just clone or copy them — not without adding more trait indirection. Step 3 — Trait Objects Inside Trait Objects (a.k.a. Pain)
So I tried to fix it with a “cloneable shape” trait:
trait ShapeClone {
fn clone_box(&self) -> Box<dyn Shape>;
}
impl<T> ShapeClone for T
where
T: 'static + Shape + Clone,
{
fn clone_box(&self) -> Box<dyn Shape> {
Box::new(self.clone())
}
} trait Shape: ShapeClone {
fn area(&self) -> f64;
}
Then implement Clone manually:
impl Clone for Box<dyn Shape> {
fn clone(&self) -> Box<dyn Shape> {
self.clone_box()
}
}
At this point, I’d written more boilerplate than my original app. I was reinventing vtables, downcasting, and virtual methods — all by hand. Rust didn’t stop me. But it made me feel every ounce of pain for trying to bring old paradigms into a new world.
Step 4 — Compile-Time Polymorphism to the Rescue Eventually, I learned the Rust way:
fn print_area<T: Shape>(shape: &T) {
println!("Area: {}", shape.area());
}
fn main() {
let circle = Circle { radius: 3.0 };
let rect = Rectangle { width: 4.0, height: 5.0 };
print_area(&circle);
print_area(&rect);
}
No vtables. No heap allocation. Everything resolved at compile-time through monomorphization. The compiler generates separate specialized versions for each concrete type. Yes, it increases binary size slightly, but execution is blazing fast.
Code Flow Comparison
| Feature | OOP Simulation (`Box<dyn Trait>`) | Rust-Style Generic (`T: Trait`) | | --------------------- | --------------------------------- | ------------------------------- | | Dispatch | Dynamic (runtime lookup) | Static (compile-time inlined) | | Memory | Heap-allocated | Stack-allocated | | Type Info | Erased | Preserved | | Performance | Slower (indirect calls) | Faster (direct calls) | | Flexibility | Heterogeneous containers | Homogeneous only | | Compiler Optimization | Limited | Full inlining possible |
Benchmark Time
Let’s measure this madness.
OOP Version:
trait Shape {
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { 3.14 * self.radius * self.radius }
} fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
Generic Version:
fn total_area_generic<T: Shape>(shapes: &[T]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
Benchmark:
Dynamic dispatch (Box<dyn Trait>): ~130 ns Static dispatch (T: Trait): ~45 ns
The generic version is nearly 3x faster due to inlining and branch elimination. In real-world performance-critical systems (like physics engines, parsers, or game loops), that difference scales dramatically. The Emotional Damage At first, I thought Rust was “too strict” — that I was smarter than the compiler. But as I kept layering traits on traits, lifetimes, boxes, and clones, I realized something humbling: Rust doesn’t stop you from writing OOP-style code. It just makes sure you feel the cost of every decision. The moment you stop fighting the language and start embracing its model — traits, enums, and generics instead of class hierarchies — your designs suddenly become leaner, safer, and more explicit. It’s not about what Rust took away. It’s about what it forced me to unlearn.
Key Takeaways
- Yes, you can do OOP in Rust. But you’ll lose performance, clarity, and your sanity.
- Dynamic dispatch (dyn Trait) has a cost. You pay for heap allocation and indirect function calls.
- Static dispatch via generics is the Rust way. The compiler resolves everything at compile-time, often resulting in faster, smaller code.
- Trait objects erase type information. No Clone, no Eq, no automatic copying — you must re-implement logic.
- Rust rewards composition, not inheritance. You build behavior with traits, not hierarchies.
Final Thoughts
When people say “Rust isn’t OOP,” they’re wrong. It can be — but it really, really shouldn’t. Because once you stop trying to make Rust look like your old languages, you realize how beautifully anti-OOP it actually is. You start to love the type system for what it enforces, the compiler for what it refuses to guess, and the patterns for how they make your intent explicit. I tried to simulate OOP in Rust. It worked. But I regret it — because it made me realize I didn’t need OOP at all.
Final Line:
Rust doesn’t hate objects. It just refuses to lie to you about what they cost.
Read the full article here: https://medium.com/@syntaxSavage/simulating-oop-in-rust-i-did-it-and-i-regret-everything-b68483e76e3a