Rust Notes: Object-Oriented Programming
A review of OOP in Rust.
Object-Oriented Programming in Rust
Rust is not traditionally considered an object-oriented language in the same way as Java or C++.
It does not have:
- classes
- traditional class inheritance
- constructors in the traditional OOP sense
private/protected/publicfields and methods in the same class-based model- virtual methods as a built-in class feature
However, Rust supports many of the important ideas associated with object-oriented programming.
Rust provides:
- encapsulation
- abstraction
- polymorphism
- interfaces through traits
- composition
- dynamic dispatch
- static dispatch
The important difference is that Rust provides these features through its type system, modules, structs, enums, and traits, rather than through classes and inheritance.
A useful way to think about Rust is:
Rust supports many OOP concepts, but it does not implement OOP through classes and inheritance.
1. What Is Object-Oriented Programming?
Object-oriented programming is usually associated with a few major ideas:
- Encapsulation
- Abstraction
- Inheritance
- Polymorphism
Different languages implement these concepts differently.
For example, in Java you might have:
1
2
3
4
5
6
7
8
9
10
11
class Animal {
public void speak() {
}
}
class Dog extends Animal {
@Override
public void speak() {
System.out.println("Woof");
}
}
The class contains:
- data
- methods
- visibility
- inheritance relationships
Rust separates these concepts.
For example:
1
2
3
4
5
6
7
8
9
struct Dog {
name: String,
}
impl Dog {
fn speak(&self) {
println!("Woof!");
}
}
The data is defined by the struct:
1
2
3
struct Dog {
name: String,
}
The methods are defined by an impl block:
1
2
3
4
5
impl Dog {
fn speak(&self) {
println!("Woof!");
}
}
This separation is fundamental to Rust.
2. Structs Are Similar to Classes, But Not the Same
A Rust struct can contain data:
1
2
3
4
struct User {
name: String,
age: u32,
}
You can define methods using impl:
1
2
3
4
5
impl User {
fn greet(&self) {
println!("Hello {}", self.name);
}
}
Then:
1
2
3
4
5
6
let user = User {
name: String::from("Amir"),
age: 29,
};
user.greet();
This looks similar to a class:
1
2
3
4
5
6
7
8
9
10
11
Java/C++
Class
├── fields
└── methods
Rust
Struct
└── fields
impl
└── methods
But Rust intentionally separates the data definition from the implementation.
3. Encapsulation in Rust
Rust absolutely supports encapsulation.
The main tools are:
- modules
- visibility modifiers
- private fields
- public methods
- traits
By default, items are private.
For example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
mod bank {
pub struct Account {
balance: u64,
}
impl Account {
pub fn new(balance: u64) -> Self {
Self { balance }
}
pub fn balance(&self) -> u64 {
self.balance
}
pub fn withdraw(&mut self, amount: u64) {
if amount <= self.balance {
self.balance -= amount;
}
}
}
}
Outside the module:
1
2
3
let mut account = bank::Account::new(100);
println!("{}", account.balance());
But this is not allowed:
1
println!("{}", account.balance);
because balance is private.
This is encapsulation.
The user of Account does not directly manipulate its internal state.
Instead, they use:
1
account.withdraw(20);
4. Rust Visibility
Rust provides several visibility mechanisms.
The most common are:
1
pub
and private visibility.
For example:
1
2
3
4
pub struct User {
pub name: String,
age: u32,
}
Here:
1
2
name → public
age → private
You can also expose something only within a parent module:
1
pub(crate)
This means:
Public within the current crate.
Example:
1
2
pub(crate) fn internal_function() {
}
There are also more advanced visibility forms such as:
1
2
pub(super)
pub(in crate::some_module)
These allow more precise control over visibility.
5. Encapsulation Is Not Only About Classes
In Java, encapsulation is often associated with:
1
private int balance;
and:
1
public void withdraw(int amount)
Rust achieves the same general idea through modules and visibility:
1
2
3
4
5
6
7
8
9
pub struct Account {
balance: u64,
}
impl Account {
pub fn withdraw(&mut self, amount: u64) {
// ...
}
}
The important principle is:
Expose behavior, hide implementation details.
This is a very useful way to design Rust APIs.
6. Rust Does Not Have Constructors
Rust does not have a special constructor keyword such as:
1
2
3
4
class User {
public:
User(int age);
};
Instead, Rust commonly uses associated functions.
1
2
3
4
5
6
7
8
9
10
struct User {
name: String,
age: u32,
}
impl User {
fn new(name: String, age: u32) -> Self {
Self { name, age }
}
}
Then:
1
2
3
4
let user = User::new(
String::from("Amir"),
29,
);
The conventional name is:
1
new
but it is not a special language feature.
You could write:
1
2
3
4
5
impl User {
fn create(name: String, age: u32) -> Self {
Self { name, age }
}
}
and:
1
2
3
4
let user = User::create(
String::from("Amir"),
29,
);
7. Methods and Associated Functions
Rust distinguishes between methods and associated functions.
A method has self:
1
2
3
4
5
impl User {
fn greet(&self) {
println!("Hello");
}
}
Called with:
1
user.greet();
An associated function does not have self:
1
2
3
4
5
impl User {
fn new(name: String) -> Self {
Self { name }
}
}
Called with:
1
User::new(String::from("Amir"));
This distinction is useful when thinking about constructors.
8. Inheritance in Rust
Rust does not support traditional class inheritance.
You cannot write:
1
2
struct Dog extends Animal {
}
Instead, Rust uses traits.
1
2
3
4
5
6
7
8
9
10
11
trait Animal {
fn speak(&self);
}
struct Dog;
impl Animal for Dog {
fn speak(&self) {
println!("Woof!");
}
}
The trait defines behavior:
1
2
3
4
5
Animal
│
│ implemented by
▼
Dog
This is different from:
1
2
3
4
5
Animal
│
│ inherited by
▼
Dog
Rust intentionally avoids traditional inheritance.
9. Why Does Rust Avoid Inheritance?
Inheritance creates a strong relationship between types.
For example:
1
2
3
4
5
6
7
Animal
│
▼
Dog
│
▼
SpecialDog
Over time, inheritance hierarchies can become difficult to maintain.
Rust generally favors:
Composition over inheritance.
Instead of saying:
A
Doginherits everything fromAnimal.
we say:
A
Dogimplements the behavior described byAnimal.
This makes the relationship more explicit.
10. Composition in Rust
Suppose we have an engine:
1
2
3
struct Engine {
horsepower: u32,
}
and a car:
1
2
3
struct Car {
engine: Engine,
}
The car contains an engine.
1
2
Car
└── Engine
This is composition.
You can then implement behavior:
1
2
3
4
5
6
7
8
impl Car {
fn start(&self) {
println!(
"Starting {} HP engine",
self.engine.horsepower
);
}
}
This is often preferred over inheritance.
11. Traits as Interfaces
Traits are Rust’s main mechanism for defining shared behavior.
1
2
3
trait Animal {
fn speak(&self);
}
A type implements the interface:
1
2
3
4
5
6
7
struct Dog;
impl Animal for Dog {
fn speak(&self) {
println!("Woof!");
}
}
This is similar to a Java interface:
1
2
3
interface Animal {
void speak();
}
But Rust traits are more powerful than simple interfaces.
They can contain:
- methods
- default implementations
- associated types
- associated constants
- associated functions
- trait bounds
12. Polymorphism in Rust
Rust supports polymorphism.
There are two major forms you should understand:
- Static polymorphism
- Dynamic polymorphism
13. Static Polymorphism
Using generics:
1
2
3
4
5
6
7
trait Animal {
fn speak(&self);
}
fn speak<T: Animal>(animal: &T) {
animal.speak();
}
Then:
1
2
3
4
5
let dog = Dog;
let cat = Cat;
speak(&dog);
speak(&cat);
The compiler knows the concrete type of T.
Conceptually:
1
2
3
4
5
6
7
speak::<Dog>()
↓
Dog::speak()
speak::<Cat>()
↓
Cat::speak()
This is static dispatch.
The compiler determines the implementation at compile time.
14. Dynamic Polymorphism
Rust can also perform runtime polymorphism using trait objects.
1
2
3
fn speak(animal: &dyn Animal) {
animal.speak();
}
Now:
1
2
3
4
5
let dog = Dog;
let cat = Cat;
speak(&dog);
speak(&cat);
The concrete type is hidden behind:
1
dyn Animal
Rust uses dynamic dispatch to select the implementation.
Conceptually:
1
2
3
4
5
&dyn Animal
│
├── Dog → Dog::speak()
│
└── Cat → Cat::speak()
15. Trait Objects
Common trait-object forms include:
1
2
3
4
&dyn Trait
Box<dyn Trait>
Rc<dyn Trait>
Arc<dyn Trait>
For example:
1
let animal: Box<dyn Animal> = Box::new(Dog);
This is useful when the concrete type should be hidden.
16. Different Types in One Collection
This is one of the most important reasons to use dynamic polymorphism.
This does not work:
1
2
3
4
let animals = vec![
Dog,
Cat,
];
because a Vec<T> normally contains one concrete type.
Instead:
1
2
3
4
let animals: Vec<Box<dyn Animal>> = vec![
Box::new(Dog),
Box::new(Cat),
];
Now:
1
2
3
4
Vec<Box<dyn Animal>>
│
├── Dog
└── Cat
The collection has one type:
1
Box<dyn Animal>
while the underlying objects have different concrete types.
17. Static vs Dynamic Dispatch
The distinction is important.
Static dispatch:
1
2
3
fn process<T: Animal>(animal: &T) {
animal.speak();
}
Dynamic dispatch:
1
2
3
fn process(animal: &dyn Animal) {
animal.speak();
}
Think of it as:
1
2
3
4
5
6
7
8
9
10
T: Animal
│
└── compiler knows concrete type
└── static dispatch
dyn Animal
│
└── concrete type hidden
└── runtime dispatch
Static dispatch generally allows more compile-time optimization.
Dynamic dispatch gives runtime flexibility.
18. Traits and Abstraction
Traits allow you to depend on behavior instead of implementation.
Suppose you have:
1
2
struct PostgresDatabase;
struct InMemoryDatabase;
Instead of:
1
2
fn save(db: &PostgresDatabase) {
}
define:
1
2
3
trait Database {
fn save(&self);
}
Then:
1
2
3
fn save<D: Database>(db: &D) {
db.save();
}
Now the function doesn’t care whether the database is PostgreSQL, memory-based, or something else.
It only cares:
Does this type implement
Database?
This is abstraction.
19. Dependency Injection
Traits are useful for dependency injection.
For example:
1
2
3
trait Storage {
fn get(&self, key: &str) -> String;
}
Production implementation:
1
2
3
4
5
6
7
struct DatabaseStorage;
impl Storage for DatabaseStorage {
fn get(&self, key: &str) -> String {
String::from("real value")
}
}
Test implementation:
1
2
3
4
5
6
7
struct MockStorage;
impl Storage for MockStorage {
fn get(&self, key: &str) -> String {
String::from("fake value")
}
}
Application code:
1
2
3
4
fn process<S: Storage>(storage: &S) {
let value = storage.get("key");
println!("{value}");
}
Production:
1
process(&DatabaseStorage);
Testing:
1
process(&MockStorage);
The application depends on the abstraction:
1
2
3
4
Storage trait
/ \
/ \
DatabaseStorage MockStorage
rather than a concrete implementation.
20. Traits and Encapsulation Together
Traits become particularly powerful when combined with private implementation details.
For example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mod storage {
pub trait Storage {
fn get(&self, key: &str) -> String;
}
struct DatabaseStorage {
connection: String,
}
impl Storage for DatabaseStorage {
fn get(&self, key: &str) -> String {
// implementation
String::from("value")
}
}
pub fn create_storage() -> impl Storage {
DatabaseStorage {
connection: String::from("database"),
}
}
}
The caller can use:
1
let storage = storage::create_storage();
without knowing the concrete type.
This gives us:
1
2
3
4
5
6
7
8
9
Public API
│
▼
Storage trait
│
▼
Hidden implementation
│
└── DatabaseStorage
This is a powerful form of abstraction.
21. Opaque Types with impl Trait
Rust can hide the concrete type:
1
2
3
fn create_storage() -> impl Storage {
DatabaseStorage
}
The caller knows:
The returned value implements
Storage.
But does not need to know the exact concrete type.
This is different from:
1
Box<dyn Storage>
With:
1
impl Storage
there is one hidden concrete type.
With:
1
dyn Storage
the concrete type can vary at runtime.
22. impl Trait vs dyn Trait
Compare:
1
2
3
fn create() -> impl Animal {
Dog
}
and:
1
2
3
4
5
6
7
fn create(condition: bool) -> Box<dyn Animal> {
if condition {
Box::new(Dog)
} else {
Box::new(Cat)
}
}
The first returns one hidden concrete type.
The second can return different concrete types.
1
2
3
4
5
6
7
8
9
impl Animal
│
└── one concrete type hidden
dyn Animal
│
├── Dog
└── Cat
23. Rust’s Version of an Abstract Class
Rust does not have abstract classes.
Instead, you usually define a trait:
1
2
3
trait Animal {
fn speak(&self);
}
Then concrete types implement it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Dog;
struct Cat;
impl Animal for Dog {
fn speak(&self) {
println!("Woof");
}
}
impl Animal for Cat {
fn speak(&self) {
println!("Meow");
}
}
The trait provides the abstraction.
24. Default Trait Methods
Traits can provide default behavior:
1
2
3
4
5
trait Animal {
fn speak(&self) {
println!("Some animal sound");
}
}
A type can use the default:
1
2
3
struct Dog;
impl Animal for Dog {}
or override it:
1
2
3
4
5
impl Animal for Dog {
fn speak(&self) {
println!("Woof");
}
}
This gives traits some functionality that resembles abstract base classes, without requiring inheritance.
25. Trait Composition
Traits can require other traits.
1
2
3
trait Animal: Display {
fn speak(&self);
}
This means:
1
2
3
Animal
│
└── requires Display
An implementation must satisfy both:
1
2
3
4
5
6
7
impl Display for Dog {
// ...
}
impl Animal for Dog {
// ...
}
Multiple requirements are possible:
1
2
3
trait Animal: Display + Debug + Send {
fn speak(&self);
}
This is sometimes called using supertraits.
26. Operator Overloading Through Traits
Rust’s operators are heavily trait-based.
For example:
1
a + b
uses the Add trait.
Other examples include:
1
2
3
4
5
6
7
+ Add
- Sub
* Mul
/ Div
== PartialEq
< PartialOrd
[] Index
This demonstrates an important Rust principle:
Language behavior is often expressed through traits.
27. Trait-Based Polymorphism vs Inheritance-Based Polymorphism
In a traditional OOP language:
1
2
3
4
Animal
│
├── Dog
└── Cat
The relationship is often inheritance.
In Rust:
1
2
3
4
Animal trait
│
├── Dog implements Animal
└── Cat implements Animal
This is not inheritance.
It is a contract:
1
2
Animal:
must provide speak()
Dog:
1
implements speak()
Cat:
1
implements speak()
The difference may seem small at first, but it leads to a different style of program design.
28. Composition Over Inheritance
Suppose we want a Car.
Instead of:
1
2
3
4
Vehicle
│
▼
Car
we might use:
1
2
3
4
5
6
7
8
9
10
11
12
struct Engine {
// ...
}
struct Wheels {
// ...
}
struct Car {
engine: Engine,
wheels: Wheels,
}
Now:
1
2
3
Car
├── Engine
└── Wheels
The car is composed of components.
This is often easier to modify than a deep inheritance hierarchy.
29. Traits for Capabilities
A useful Rust mental model is to treat traits as capabilities.
For example:
1
Clone
means:
This type can be cloned.
1
Debug
means:
This type has a debugging representation.
1
Display
means:
This type can be formatted for users.
1
Iterator
means:
This type can produce a sequence of values.
1
Send
means:
This type can safely be transferred to another thread.
1
Sync
means:
References to this type can safely be shared between threads.
This capability-based design is one of the most important ideas in Rust.
30. Traits Are Also Used by the Type System
Traits aren’t only an OOP-like feature.
Rust itself uses traits extensively.
For example:
1
2
fn spawn<T: Send + 'static>(value: T) {
}
Here Send is a trait used to express a concurrency property.
Similarly:
1
T: Clone
is a requirement that the type supports cloning.
And:
1
T: Iterator
means that the type provides iterator behavior.
So traits are much broader than traditional interfaces.
31. Enums and OOP
Rust enums can also be used to model polymorphic data.
For example:
1
2
3
4
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}
Then:
1
2
3
4
5
6
7
8
9
10
11
12
13
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => {
std::f64::consts::PI * radius * radius
}
Shape::Rectangle { width, height } => {
width * height
}
}
}
}
This is another Rust alternative to some traditional OOP designs.
Instead of:
1
2
3
Shape
├── Circle
└── Rectangle
through inheritance, you can represent the alternatives explicitly:
1
2
3
enum Shape
├── Circle
└── Rectangle
This is especially useful when the set of variants is known.
32. Enum vs Trait
This distinction is very important.
Use an enum when:
I know all possible variants.
Example:
1
2
3
4
5
enum Message {
Text(String),
Number(i32),
Quit,
}
Use a trait when:
I want different types to provide the same behavior.
Example:
1
2
3
trait Renderer {
fn render(&self);
}
Then:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct HtmlRenderer;
struct JsonRenderer;
impl Renderer for HtmlRenderer {
fn render(&self) {
// ...
}
}
impl Renderer for JsonRenderer {
fn render(&self) {
// ...
}
}
A useful rule:
1
2
3
4
5
6
7
8
Known alternatives
↓
enum
Open-ended behavior
↓
trait
33. Rust Does Not Force an OOP Style
You can write Rust in several styles.
Procedural:
1
2
3
fn calculate(a: i32, b: i32) -> i32 {
a + b
}
Data-oriented:
1
2
3
struct User {
name: String,
}
Trait-oriented:
1
2
3
trait Serialize {
fn serialize(&self);
}
Functional-style:
1
2
3
4
let result = values
.iter()
.map(|x| x * 2)
.collect::<Vec<_>>();
Rust is therefore often described as a multi-paradigm language.
34. A Better Mental Model for Rust OOP
Instead of thinking:
“Where is the class?”
think:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
What data do I have?
↓
struct
What behavior does it have?
↓
impl
What behavior should multiple types share?
↓
trait
Do I know the concrete type at compile time?
↓
generic / impl Trait
Do I need runtime polymorphism?
↓
dyn Trait
Do I need to hide implementation details?
↓
modules + visibility + traits
This mental model is much more useful than trying to translate Java classes directly into Rust.
35. Example: Designing a Storage System
Suppose we are designing a storage abstraction.
Instead of:
1
2
3
4
5
abstract class Storage
│
├── S3Storage
├── CephStorage
└── LocalStorage
Rust can use:
1
2
3
4
trait Storage {
fn put(&self, key: &str, data: &[u8]);
fn get(&self, key: &str) -> Vec<u8>;
}
Implementations:
1
2
3
4
5
struct S3Storage;
struct CephStorage;
struct LocalStorage;
Then:
1
2
3
4
5
6
7
8
9
10
impl Storage for S3Storage {
fn put(&self, key: &str, data: &[u8]) {
// S3 implementation
}
fn get(&self, key: &str) -> Vec<u8> {
// S3 implementation
Vec::new()
}
}
and:
1
2
3
4
5
6
7
8
9
10
impl Storage for CephStorage {
fn put(&self, key: &str, data: &[u8]) {
// Ceph implementation
}
fn get(&self, key: &str) -> Vec<u8> {
// Ceph implementation
Vec::new()
}
}
The application can depend only on:
1
Storage
rather than a particular backend.
36. Why This Style Is Useful for Systems Programming
This approach fits systems programming particularly well.
For example, you might have:
1
2
3
4
5
6
Storage
Network
Device
Backend
Transport
Scheduler
as traits describing capabilities.
Concrete implementations can then be:
1
2
3
4
5
6
CephStorage
S3Storage
VirtioDevice
VhostBackend
TcpTransport
UnixTransport
The abstraction is separated from the implementation.
This is especially useful when:
- testing
- supporting multiple backends
- building libraries
- writing virtualization software
- implementing operating-system components
- designing extensible infrastructure software
37. Rust’s Approach to OOP
Rust does not reject object-oriented ideas.
Instead, it separates the concepts.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Traditional OOP
────────────────────────────
Class
├── state
├── methods
├── visibility
├── inheritance
└── polymorphism
Rust
────────────────────────────
struct
└── state
impl
└── methods
module + pub/private
└── encapsulation
trait
└── shared behavior
generic
└── static polymorphism
dyn Trait
└── dynamic polymorphism
composition
└── reuse of implementation
This separation gives you more control over how each concept is used.
38. The Most Important Differences from Java/C++
| Concept | Java/C++ | Rust |
|---|---|---|
| Class | Yes | No |
| Struct | Yes, but different semantics | Yes |
| Methods | Inside classes | impl blocks |
| Encapsulation | Class visibility | Modules + visibility |
| Interfaces | Interfaces / abstract classes | Traits |
| Inheritance | Yes | No traditional inheritance |
| Composition | Yes | Yes |
| Polymorphism | Virtual methods / generics | dyn Trait / generics |
| Static dispatch | Templates/generics | Generics |
| Dynamic dispatch | Virtual methods | Trait objects |
| Constructors | Special language feature | Associated functions |
| Abstract classes | Yes | Traits |
| Operator overloading | Language-specific | Traits |
| Multiple inheritance | Limited / supported differently | Not traditional; traits provide composition of behavior |
39. What Rust Gives You Instead of Inheritance
Instead of inheritance, Rust gives you several tools:
1
2
3
4
5
6
7
8
9
Composition
+
Traits
+
Generics
+
Trait objects
+
Enums
Together, these cover many designs that would traditionally be implemented with inheritance.
For example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Traditional OOP:
Animal
│
├── Dog
└── Cat
Rust:
trait Animal
│
├── Dog implements Animal
└── Cat implements Animal
and/or:
enum Animal
├── Dog
└── Cat
The best choice depends on whether the problem is about shared behavior or a closed set of variants.
40. Key Takeaways
Rust supports many object-oriented programming concepts, but it does not use traditional class-based OOP.
The most important ideas are:
structdefines data.impldefines methods and associated functions.- Modules and visibility provide encapsulation.
pubexposes items outside their normal visibility boundary.- Private fields can protect internal state.
- Associated functions such as
new()are commonly used as constructors. - Traits define shared behavior and act similarly to interfaces.
- Rust does not have traditional class inheritance.
- Rust favors composition over inheritance.
- Traits provide polymorphism.
- Generic traits normally use static dispatch.
dyn Traitprovides dynamic dispatch.Box<dyn Trait>allows different concrete types to be stored together.impl Traithides a concrete type while preserving static dispatch.- Enums are useful when the set of possible variants is known.
- Traits are useful when you want open-ended shared behavior.
- Traits can provide default implementations.
- Traits can require other traits through supertraits.
- Traits can define associated types and associated constants.
- Traits are also used to express capabilities such as
Send,Sync,Clone, andIterator. - Rust’s OOP-like features work together with ownership and borrowing rather than replacing them.
The most useful mental model is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Data
↓
struct
Behavior for one type
↓
impl
Shared behavior
↓
trait
Compile-time polymorphism
↓
generics / impl Trait
Runtime polymorphism
↓
dyn Trait
Encapsulation
↓
modules + pub/private
Reuse
↓
composition
The key idea is:
Rust does not try to make everything an object. It gives you separate tools for data, behavior, abstraction, encapsulation, and polymorphism, and lets you combine them.
That is why Rust can express many object-oriented designs while still being fundamentally different from class-based languages such as Java and C++.