The inheritance wall
You are building a game engine. In Python, you create a GameObject class with position and velocity. You subclass Player and Enemy. You override update() and draw(). You inherit everything. You copy this pattern into Rust, define a struct, try to make another struct extend it, and the compiler rejects you. Rust has no inheritance. No base classes. No virtual method tables by default.
This feels like a step backward until you realize Rust is trading the flexibility of inheritance for something sharper. You stop asking "what is this a kind of?" and start asking "what can this do?" and "what data does this hold?" The shift from class hierarchies to composition and traits eliminates the diamond problem, prevents accidental inheritance of bugs, and lets you mix behaviors freely. You gain flexibility by losing the family tree.
Composition and traits
Object-oriented programming treats code like a lineage. A Car is a Vehicle. A Vehicle has wheels. A Car inherits wheels and adds doors. This hierarchy is intuitive but brittle. Add a flying car, and you have to restructure the whole tree. You end up with deep inheritance chains where changing a base class breaks dozens of subclasses.
Rust treats code like a modular toolkit. You have a box of data. You snap capabilities onto that box using traits. A Car struct does not inherit from Vehicle. It implements the Drive trait and the HasWheels trait. A Bicycle also implements Drive and HasWheels. They share behavior without pretending to be the same thing. This is composition over inheritance. You build behavior by combining small, focused pieces rather than drilling down a deep class chain.
Traits define shared behavior. Structs define data. You implement traits for structs to attach behavior. This separation keeps your types flat and your responsibilities clear. Inheritance couples types tightly. Traits decouple behavior from data.
Minimal example
A trait is a contract. It lists methods that implementors must provide. A struct is a data container. You implement the trait for the struct to fulfill the contract.
/// A trait defines a shared capability, not a base class.
/// Any type can implement this trait if it can provide the required methods.
trait Render {
fn draw(&self);
}
/// A struct defines data.
/// It has no behavior until you implement traits for it.
struct Button {
label: String,
}
/// Implement the trait for the struct.
/// This attaches the `draw` behavior to the `Button` data.
impl Render for Button {
fn draw(&self) {
// Print the button label.
// The method borrows self immutably, so it can be called on shared references.
println!("Drawing button: {}", self.label);
}
}
fn main() {
let btn = Button { label: "Click".to_string() };
btn.draw();
}
The Render trait does not contain data. It only specifies that implementors must have a draw method. Button owns its label. The impl block connects the two. You can implement Render for any type that makes sense: Window, Text, Image. Each type provides its own implementation of draw. There is no shared base implementation to inherit. You implement everything explicitly. This sounds like more work, but it means you never inherit bugs from a base class you did not write.
How traits replace classes
In OOP, a class bundles data and methods. In Rust, data lives in structs and methods live in impl blocks. You can have multiple impl blocks for the same struct. This lets you organize code by feature rather than dumping everything into one giant class.
struct Player {
health: u32,
name: String,
}
/// Core data methods.
impl Player {
fn new(name: &str) -> Self {
Self { health: 100, name: name.to_string() }
}
}
/// Combat behavior.
/// Separated into its own impl block for clarity.
impl Player {
fn take_damage(&mut self, amount: u32) {
self.health = self.health.saturating_sub(amount);
}
}
/// Display behavior.
impl std::fmt::Display for Player {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} (HP: {})", self.name, self.health)
}
}
The Player struct has three impl blocks. One for construction, one for combat, one for formatting. In a class-based language, all these methods would live inside the Player class. Rust lets you split them. This keeps related methods grouped and reduces scrolling. You can even implement traits for types you do not own, as long as you own the trait. This is the key to extension traits.
Realistic example: UI components
Consider a UI system. You have widgets. Some are clickable. Some are draggable. Some render. In OOP, you might create ClickableWidget extending Widget, then DraggableWidget extending Widget. A Window needs to be both clickable and draggable. You hit the single inheritance limit. You have to use interfaces or mixins, which adds complexity.
Rust handles this naturally with traits.
/// A trait for widgets that can be clicked.
trait Clickable {
fn on_click(&self);
}
/// A trait for widgets that can be dragged.
trait Draggable {
fn on_drag(&self, dx: f64, dy: f64);
}
/// A button is just data.
struct Button {
label: String,
}
/// A window is just data.
struct Window {
title: String,
}
/// Buttons are clickable.
impl Clickable for Button {
fn on_click(&self) {
println!("Button '{}' clicked", self.label);
}
}
/// Windows are clickable and draggable.
/// No inheritance hierarchy needed.
/// You just implement the traits that apply.
impl Clickable for Window {
fn on_click(&self) {
println!("Window '{}' clicked", self.title);
}
}
impl Draggable for Window {
fn on_drag(&self, dx: f64, dy: f64) {
println!("Window '{}' dragged by ({}, {})", self.title, dx, dy);
}
}
/// A function that works with any clickable widget.
/// Uses generics with a trait bound for zero-cost abstraction.
fn handle_click<W: Clickable>(widget: &W) {
widget.on_click();
}
fn main() {
let btn = Button { label: "Submit".to_string() };
let win = Window { title: "Settings".to_string() };
handle_click(&btn);
handle_click(&win);
}
Button implements Clickable. Window implements both Clickable and Draggable. There is no common base class. The handle_click function accepts any type that implements Clickable. The compiler monomorphizes the function for each type, inlining the calls for maximum performance. You get polymorphism without the runtime overhead of virtual dispatch.
Extension traits and the orphan rule
One of Rust's most powerful idioms is the extension trait. You can define a trait and implement it for a type from another crate. This lets you add methods to String, Vec, or standard library types without modifying their source code.
/// An extension trait to add palindrome checking to strings.
trait StringExt {
fn is_palindrome(&self) -> bool;
}
/// Implement the extension trait for String.
/// This is allowed because StringExt is defined in this crate.
impl StringExt for String {
fn is_palindrome(&self) -> bool {
let reversed: String = self.chars().rev().collect();
self == reversed
}
}
fn main() {
let word = "racecar".to_string();
// Call the extension method as if it were native.
if word.is_palindrome() {
println!("It's a palindrome!");
}
}
The community calls this pattern "extension traits." It mimics monkey-patching but stays safe. The compiler checks the implementation. You cannot break the type. There is a restriction called the orphan rule. You can only implement a trait for a type if either the trait or the type is defined in your crate. You cannot implement a foreign trait for a foreign type. This prevents conflicts between crates. If two crates tried to implement Display for String, the compiler would not know which one to use. The orphan rule ensures coherence.
Convention aside: When writing extension traits, name the trait with the type it extends as a prefix, like StringExt or VecExt. This signals to readers that the trait is meant to extend that specific type.
The mutation model shift
OOP often relies on mutable state inside objects. You call player.take_damage(10) and the object updates its internal health. The method signature does not reveal that mutation happens. In Rust, mutation is explicit. Methods that modify state must take &mut self. Methods that only read take &self.
This changes how you design APIs. You cannot have a method that mutates state if you only have a shared reference. The borrow checker enforces exclusive access for mutation. This prevents data races at compile time. It also forces you to think about ownership. Who owns the data? Who is allowed to mutate it?
If you try to call a &mut self method on an immutable reference, the compiler rejects you with E0596 (cannot borrow as mutable). The fix is to restructure the code so that mutation happens where you have exclusive access. You might need to pass mutable references through functions, or use interior mutability types like RefCell if you need to mutate through shared references. Do not fight the borrow checker by hiding mutability behind getters. Expose the mutation clearly. The borrow checker is not blocking your design. It is exposing where your mutation model is vague. Fix the model, and the code writes itself.
Pitfalls and compiler errors
Newcomers often try to simulate inheritance by nesting structs. You define struct Parent { x: i32 } and struct Child { parent: Parent }. This works for data, but you lose polymorphism. You cannot pass a Child where a Parent is expected without a trait. If you try to call a method on Child that expects &mut self but only have &self, the compiler stops you.
Another common mistake is implementing a trait for a type without realizing the orphan rule applies. If you try to implement std::fmt::Display for Vec<i32>, the compiler rejects you with E0117 (cannot implement foreign trait for foreign type). Both Display and Vec are from the standard library. You must define your own trait or use a newtype wrapper.
The newtype pattern solves this. You wrap the foreign type in a local struct, then implement the foreign trait for the wrapper.
/// A newtype wrapper around Vec<i32>.
/// This allows implementing foreign traits.
struct MyVec(Vec<i32>);
impl std::fmt::Display for MyVec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "MyVec({:?})", self.0)
}
}
The newtype adds zero runtime cost. The compiler optimizes it away. It gives you a distinct type that you can extend freely. Use this pattern whenever you need to implement a trait for a type you do not control.
Decision matrix
Use traits to define shared behavior when you want different types to support the same operation without sharing a common base type. Use composition when one type logically contains another, like a Car containing an Engine, and you need to access the inner data directly. Use generics with trait bounds when you want a function to work with any type that implements a specific capability, keeping monomorphization for zero-cost abstraction. Use trait objects (dyn Trait) when you need to store heterogeneous types in a collection, like a Vec<Box<dyn Render>>, accepting the runtime dispatch cost for flexibility. Use &self methods when the operation only reads data and allows concurrent access. Use &mut self methods when the operation modifies state and requires exclusive access. Use extension traits when you want to add methods to a type from another crate, ensuring you own the trait to satisfy the orphan rule. Use newtype wrappers when you need to implement a foreign trait for a foreign type, creating a local type to bridge the gap.
Pick the tool that matches the data relationship. Traits for behavior, composition for structure, generics for flexibility. The compiler will guide you if you follow the rules.