Why Rust does not need OOP
Learning Rust? I co-run a 6-week Python to Rust cohort where you build a performant JSON parser with PyO3 bindings.
When I heard structs replace classes in Rust, I was a bit surprised. I thought, how can you do without classes? But as I started to learn Rust, I realized that structs, traits, ownership and composition help resist the temptation of OOP. In fact, Rust's approach to programming is more focused on data and behavior rather than objects.
Let's look at 5 reasons why Rust does not need OOP.
1. Composition
One major drawback of OOP is deep inheritance trees.
In classic OOP you would define class A, then class B inherits from A, then class C inherits from B, and so on. This can lead to a complex and fragile codebase where changes in one class can affect many others.
class Animal {}
class Dog extends Animal {}
class ServiceDog extends Dog {}
In What Rust Structs Taught Me About State Ownership I showed this example:
struct Tokenizer {
chars: Vec<char>,
position: usize,
}
impl Tokenizer {
fn advance(&mut self) -> Option<char> {
let ch = self.chars.get(self.position).copied();
self.position += 1;
ch
}
fn peek(&self) -> Option<char> {
self.chars.get(self.position).copied() // char is Copy, returns a value not a reference
}
}
We see a clear separation of data (the fields) and behavior (the methods). The Tokenizer struct holds the state, while the methods define how to interact with that state. This is a more flexible and modular approach than OOP's class-based design.
Rust also uses composition instead of inheritance. You can create complex types by combining simpler ones without the need for a class hierarchy.
struct Animal {}
struct Dog {
animal: Animal,
}
struct ServiceDog {
dog: Dog,
}
A great resource on this principle is Composition Over Inheritance, part of Brandon Rhodes' Python Patterns Guide.
For an example where I think OOP & inheritance went off the rails is Django's class-based views. The inheritance tree of those views is too deep making it an unpleasant API to work with, and the code so much harder to reason about. A better way is the more functional approach, see Luke Plant's Django Views — The Right Way.
2. Traits
In Python, think Protocols. In Java, think interfaces. In Rust, traits are a powerful way to define shared behavior without the need for a class hierarchy; polymorphism without inheritance.
Classic OOP:
Animal a = new Dog();
a.speak();
In Rust, you can achieve similar behavior using traits:
trait Speak {
fn speak(&self);
}
fn make_noise(x: &impl Speak) {
x.speak();
}
The advantage here is that you can implement the Speak trait for any type, and you don't need to have a common base class. This allows for more flexibility and code reuse.
Python has something similar with Protocols, which are part of the typing module. They allow you to define a set of methods that a class must implement, without requiring inheritance.
from typing import Protocol
class Speak(Protocol):
def speak(self) -> None: ...
def make_noise(x: Speak) -> None:
x.speak()
This is a more flexible alternative to ABCs (Abstract Base Classes) and allows for duck typing while still providing type safety. I wrote an article about this on Pybites.
Where OOP couples data and behavior, Rust's traits allow you to define behavior separately from data. This promotes code reuse and flexibility without the need for a rigid class structure.
Rust encourages:
struct User {}
trait Serialize {}
trait Validate {}
trait Persist {}
This is closer to the Single Responsibility Principle, the Unix philosophy of "do one thing and do it well", functional programming of data-oriented design with pure functions that operate on data, and composition over inheritance.
Hence Rust allows you to mix and match traits to create complex behavior without the need for a class hierarchy.
3. Ownership and borrowing
Many OOP patterns exist to control mutation and provide proper encapsulation. In Rust, ownership and borrowing rules ensure that data is accessed safely and efficiently.
fn process(data: Data) // takes ownership (moved in)
fn process(data: &Data) // borrows, read-only
fn process(data: &mut Data) // borrows, can mutate
Just by looking at the function signature in Rust, you can understand how data is being used and modified: who owns it, who mutates it, and when it goes out of scope. It eliminates the need for patterns like getters/setters, which are often used in OOP to control access to data. And these rules are enforced at compile time, not runtime.
4. Modularity
I came to the conclusion some time ago that Python's module scope is a great feature. It allows you to organize code in a way that is more flexible than OOP's class-based organization. In Rust, modules and crates provide a way to organize code without the need for classes.
Classic OOP:
public class Counter {
private int value;
public void increment() {
value += 1;
}
}
In Rust, you can use module-level functions and structs to achieve the same result:
mod internal {
pub struct Counter {
value: i32,
}
impl Counter {
pub fn increment(&mut self) {
self.value += 1;
}
}
}
Outside:
counter.value // inaccessible
counter.increment() // ok
Yes, increment is a mutating method, the same as a setter. The win isn't avoiding methods, it's two things.
First, the privacy boundary is the module, not the object: value is hidden from the whole module, and you can hide free functions and structs too, not just wrap fields in a class.
Second, you only write the method when you need to guard an invariant. If a field is just plain data, mark it pub and read it directly, no getter ceremony. Java's idiom nudges you to wrap every field in get/set whether it needs it or not.
So encapsulation lives at the module level, not the class level. It also leans toward a more functional style where pure functions operate on data without mutable state.
In Python you can also hide functions and variables at the module level:
def _private_function():
pass
But this does not prevent somebody importing it so you need fencing mechanisms like __all__ to control what gets imported. Python's _private is a suggestion, Rust's module privacy is enforced by the compiler.
5. Enums and pattern matching
In OOP, you often use class hierarchies to represent different types of objects. In Rust, you can use enums and pattern matching to achieve similar results without the need for a class hierarchy.
Classic OOP:
abstract class Shape {}
class Circle extends Shape {}
class Square extends Shape {}
In Rust, you can use enums to represent different shapes:
enum Shape {
Circle(f64),
Square(f64),
}
fn area(shape: Shape) -> f64 {
match shape {
Shape::Circle(r) => 3.14 * r * r,
Shape::Square(s) => s * s,
}
}
Not only is an enum more lightweight than a class hierarchy, it also pairs up really well with pattern matching handling different cases in a concise and (!) exhaustive manner. The compiler will not let you forget a case.
Conclusion
In short, OOP bundles data and behavior together which made a lot of sense to me for a long time. Learning Rust though, I am seeing new paradigms how the language designers have decoupled the two.
With Rust you get the good parts of OOP: encapsulation, abstraction and polymorphism, while dropping the less maintainable parts: inheritance trees, mutable state and coupling.
So to wrap up, here is a comparison of classic OOP -> Python -> Rust:
| Concern | Traditional OOP | Python | Rust |
|---|---|---|---|
| Encapsulation | Classes with private/public members | Modules + _private convention | mod + pub visibility |
| Polymorphism | Inheritance + virtual methods | Duck typing / Protocols | Traits |
| Reuse | Inheritance | Composition | Composition |
| State modeling | Class hierarchies | dict, dataclass, classes | struct + enum |
| Object lifecycle | GC / constructors | GC | Ownership + borrowing |
| Error handling | Exceptions | Exceptions | Result<T, E> + exhaustive matching |
Typical style evolution
Old-school OOP:
Object → Class → Inheritance → Framework
Modern Python:
Data → Functions → Composition → Protocols
Rust:
Data → Ownership → Traits → Composition
As you see Python gets you far and is versatile, but Rust's ownership and traits give you an even more reliable and maintainable way to structure your code without the need for OOP.
Different philosophy
I am not picking on Java or OOP, I've been a fan for a long time. Studying philosophy (e.g. Plato) gives you a deeper appreciation for this way of thinking. But it can also lead to overcomplication and unnecessary coupling.
It's good to learn the different paradigms and understand their strengths and weaknesses. In summary:
Traditional OOP:
"Model the world as interacting objects."
Python:
"We trust developers."
Which is awesome, but not without risk. It allows for great flexibility and rapid development, but it can also lead to bugs and maintenance issues if not used carefully.
Rust:
"Prove correctness to the compiler."
A newer paradigm for me, but the way Rust decouples data and behavior, enforces ownership and borrowing rules, and promotes composition over inheritance, all sit well with me.
Those things do lead to more reliable and maintainable code. It encourages you to think about your code in a way that is more focused on data and behavior rather than objects.
Want Rust to click beyond syntax? Build a JSON parser from scratch, wire it into Python with PyO3, and benchmark it against CPython and other JSON libraries. Six weeks of practical Python to Rust engineering with weekly PR reviews and support by experienced Rust and Python engineers, not lectures. Join the next Python to Rust cohort →