Skip to content

Rust Traits vs OOP Inheritance: A Complete Comparison for Developers

I spent years writing Java and C++ code, building deep class hierarchies, and thinking inheritance was the answer to every code reuse problem. Then I started learning Rust, and I hit a wall. Where were my base classes? How do I share code without inheritance? Why does everyone keep saying “composition over inheritance”?

If you’re coming from an OOP background and feeling the same confusion, this post is for you.

The Problem: Inheritance Feels Natural, Until It Doesn’t

In Java, I’d structure my code like this:

VehicleHierarchy.java
abstract class Vehicle {
protected String name;
protected int speed;
void move() {
System.out.println(name + " moving at " + speed);
}
}
class Car extends Vehicle {
private int wheels;
Car(String name, int speed, int wheels) {
this.name = name;
this.speed = speed;
this.wheels = wheels;
}
}
class ElectricCar extends Car {
private int batteryCapacity;
ElectricCar(String name, int speed, int wheels, int battery) {
super(name, speed, wheels);
this.batteryCapacity = battery;
}
}

This feels right. ElectricCar IS-A Car, which IS-A Vehicle. Clean hierarchy.

Then I needed a TeslaCybertruck. But wait, what if I also want an ElectricTruck? Do I create Truck extends Vehicle and somehow combine it with ElectricCar? In Java, I can’t extend multiple classes. I’m stuck refactoring my entire hierarchy.

Rust’s Answer: Traits, Not Inheritance

When I first saw Rust code, I was confused. There were no classes extending other classes. Instead, I saw this:

vehicle.rs
trait Vehicle {
fn name(&self) -> &str;
fn speed(&self) -> i32;
fn move(&self) {
println!("{} moving at {}", self.name(), self.speed());
}
}
struct Car {
name: String,
speed: i32,
wheels: i32,
}
impl Vehicle for Car {
fn name(&self) -> &str { &self.name }
fn speed(&self) -> i32 { self.speed }
}

At first, this seemed verbose. But then I realized something: my data (struct) and behavior (trait) were completely separate. I could change one without affecting the other.

Key Difference: “Is-A” vs “Can-Do”

In OOP, inheritance defines what something is. A Dog is an Animal. A TeslaModelS is an ElectricCar.

In Rust, traits define what something can do. A Dog can speak. A TeslaModelS can charge and self_drive.

This shift sounds subtle, but it changes everything.

Example: Building a Payment System

I tried building a payment system in Java first:

PaymentJava.java
abstract class PaymentMethod {
abstract void pay(double amount);
void log(double amount) {
System.out.println("Payment of $" + amount);
}
}
class CreditCard extends PaymentMethod {
private String number;
void pay(double amount) { /* credit card logic */ }
}
class PayPal extends PaymentMethod {
private String email;
void pay(double amount) { /* paypal logic */ }
}

Then I added Bitcoin:

CryptoPayment.java
class Bitcoin extends PaymentMethod {
private String walletAddress;
void pay(double amount) { /* bitcoin logic */ }
}

But what if I want to add logging differently for crypto? Override log()? What if some payment methods need verification and others don’t? I’d need more abstract methods or more inheritance levels.

In Rust:

payment.rs
trait PaymentMethod {
fn pay(&self, amount: f64);
fn log(&self, amount: f64) {
println!("Payment of ${}", amount);
}
}
trait Verifiable {
fn verify(&self) -> bool;
}
struct CreditCard { number: String }
struct PayPal { email: String }
struct Bitcoin { wallet_address: String }
impl PaymentMethod for CreditCard {
fn pay(&self, amount: f64) { /* logic */ }
}
impl PaymentMethod for Bitcoin {
fn pay(&self, amount: f64) { /* logic */ }
}
impl Verifiable for Bitcoin {
fn verify(&self) -> bool { /* crypto verification */ }
}

Now Bitcoin has both PaymentMethod and Verifiable behaviors. I didn’t need to pollute the PaymentMethod trait with verification logic, and I didn’t need to create a VerifiablePayment base class.

The Diamond Problem: Gone in Rust

In C++, multiple inheritance can cause the diamond problem:

Diamond Problem Diagram
A
/ \
B C
\ /
D

If B and C both inherit from A, and D inherits from both, which A does D use?

diamond.cpp
class A { public: void foo() { cout << "A"; } };
class B : public A { public: void foo() { cout << "B"; } };
class C : public A { public: void foo() { cout << "C"; } };
// class D : public B, public C { }; // Which foo()?

I’ve spent hours debugging these issues in C++. In Rust, this simply doesn’t happen:

diamond_rust.rs
trait A {
fn foo(&self) { println!("A"); }
}
trait B: A {
fn foo(&self) { println!("B"); }
}
trait C: A {
fn foo(&self) { println!("C"); }
}
struct D;
impl B for D {
fn foo(&self) { println!("D implements B"); }
}
// No conflict - D explicitly implements what it needs

Traits don’t carry hidden state or ambiguous inheritance paths. Each implementation is explicit.

Static vs Dynamic Dispatch: A Choice, Not a Default

In Java, every method call on an object might be virtual (dynamic dispatch). You don’t choose.

In Rust, you choose:

dispatch.rs
// Static dispatch - resolved at compile time, zero runtime cost
fn process_static<T: Drawable>(item: T) {
item.draw();
}
// Dynamic dispatch - resolved at runtime, small overhead
fn process_dynamic(item: &dyn Drawable) {
item.draw();
}

I learned to prefer static dispatch (generics) by default and use dynamic dispatch only when I actually needed runtime flexibility, like storing heterogeneous collections.

Composition in Practice: Building Complex Types

Instead of inheritance chains, I now compose types from smaller parts:

composition.rs
struct User {
profile: Profile,
permissions: Permissions,
settings: Settings,
}
struct Profile { name: String, email: String }
struct Permissions { roles: Vec<String> }
struct Settings { theme: String, language: String }
trait HasProfile {
fn profile(&self) -> &Profile;
}
trait HasPermissions {
fn permissions(&self) -> &Permissions;
}
impl HasProfile for User {
fn profile(&self) -> &Profile { &self.profile }
}
impl HasPermissions for User {
fn permissions(&self) -> &Permissions { &self.permissions }
}

Each component is independent and testable. I can reuse Permissions in an Admin struct without any inheritance tricks.

Extending Third-Party Types: Orphan Rules

In Java, if I wanted to add behavior to a class I didn’t own, I’d need wrapper classes or inheritance (if the class isn’t final).

In Rust, I can implement my own traits for third-party types:

extend.rs
trait PrettyPrint {
fn pretty_print(&self);
}
impl PrettyPrint for Vec<String> {
fn pretty_print(&self) {
println!("[{}]", self.join(", "));
}
}
let items = vec!["a".to_string(), "b".to_string()];
items.pretty_print(); // Prints: [a, b]

There are some rules (orphan rules: either the trait or the type must be local), but this opened up possibilities I didn’t have in Java.

Transition Tips: What I Wish I Knew Earlier

1. Stop Asking “What Is This?”

In OOP, I’d ask: “Is a Tesla a Car or an ElectricVehicle?”

In Rust, I ask: “What can a Tesla do?“

mindset.rs
struct Tesla { /* ... */ }
impl Electric for Tesla { /* can charge */ }
impl Autonomous for Tesla { /* can self-drive */ }
impl Connectable for Tesla { /* can connect to app */ }

2. Use Derive Macros for Boilerplate

Rust has procedural macros that automatically implement common traits:

derive.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
name: String,
email: String,
}

This gives me Debug, Clone, Serialize, and Deserialize implementations for free. In Java, I’d write all that manually or use Lombok.

3. Trait Objects Are Tools, Not Defaults

When I first learned about trait objects (dyn Trait), I overused them. Then I realized generics give me the same flexibility with zero runtime cost:

trait_objects.rs
// Prefer this for most cases
fn process<T: Drawable>(items: Vec<T>) { }
// Use this only when you need runtime heterogeneity
fn process(items: Vec<Box<dyn Drawable>>) { }

4. Default Methods Exist

Traits can have default implementations:

default_impl.rs
trait Animal {
fn name(&self) -> &str;
fn speak(&self) {
println!("{} makes a sound", self.name());
}
}
struct Dog { name: String }
impl Animal for Dog {
fn name(&self) -> &str { &self.name }
// speak() uses the default!
}

This gives you some inheritance-like convenience without the coupling.

When Inheritance Might Still Win

I won’t pretend traits solve everything. Inheritance still has uses:

  • UI frameworks often rely on deep hierarchies for component sharing
  • ORMs sometimes need base classes for entity mapping
  • Rapid prototyping can benefit from inheritance’s quick sharing of state

But in my experience, most code I wrote with inheritance became cleaner and more flexible when I rewrote it with traits and composition.

Summary

The mental shift from OOP inheritance to Rust traits:

Paradigm Shift
OOP: Rust:
-------- --------
Is-A Can-Do
Inherits Implements
Class chains Flat traits
Shared state Separate data/behavior
Virtual Static or Dynamic (your choice)

Traits aren’t “better” or “worse” than inheritance. They’re a different tool for a different philosophy. Instead of building taxonomies, you define capabilities. Instead of sharing implementation through hierarchies, you compose behavior.

After the initial adjustment, I found myself writing code that was easier to test, easier to change, and easier to reason about. The compiler caught more errors, and I spent less time untangling inheritance messes.

If you’re transitioning from OOP to Rust, give traits time. The first few weeks will feel verbose. But once the mindset clicks, you’ll wonder how you ever lived without explicit composition.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments