Rust is not an object-oriented language, but it supports OOP patterns — often more safely and more explicitly than traditional OOP languages. Understanding how Rust approaches encapsulation, polymorphism, and composition helps you write better Rust and helps OOP developers understand what to reach for instead of inheritance.
Encapsulation
In Rust, encapsulation is enforced at the module level. Everything is private by default; you explicitly expose what you want with pub:
// bank_account.rs
pub struct BankAccount {
balance: f64, // private — only methods in this module can access
owner: String, // private
pub currency: String, // public
}
impl BankAccount {
pub fn new(owner: &str, currency: &str) -> Self {
BankAccount {
balance: 0.0,
owner: owner.to_string(),
currency: currency.to_string(),
}
}
pub fn deposit(&mut self, amount: f64) -> Result<(), String> {
if amount <= 0.0 {
return Err(format!("Invalid deposit amount: {}", amount));
}
self.balance += amount;
Ok(())
}
pub fn withdraw(&mut self, amount: f64) -> Result<(), String> {
if amount <= 0.0 {
return Err("Invalid withdrawal amount".to_string());
}
if self.balance < amount {
return Err(format!("Insufficient funds: have {:.2}, need {:.2}", self.balance, amount));
}
self.balance -= amount;
Ok(())
}
pub fn balance(&self) -> f64 { self.balance }
pub fn owner(&self) -> &str { &self.owner }
// Transfer between accounts — only accessible within this module/file
fn internal_transfer(from: &mut BankAccount, to: &mut BankAccount, amount: f64) -> Result<(), String> {
from.withdraw(amount)?;
to.deposit(amount)
}
}
fn main() {
let mut alice = BankAccount::new("Alice", "USD");
let mut bob = BankAccount::new("Bob", "USD");
alice.deposit(1000.0).unwrap();
alice.withdraw(250.0).unwrap();
// alice.balance = 5000.0; // ERROR: balance is private
println!("{}: ${:.2}", alice.owner(), alice.balance()); // Alice: $750.00
}
Module-Level Encapsulation
Privacy is per-module, not per-class. Methods in the same module can access private fields of each other’s types — useful for tightly coupled types:
mod geometry {
pub struct Circle { radius: f64 }
pub struct Square { side: f64 }
impl Circle {
pub fn new(r: f64) -> Self { Circle { radius: r } }
pub fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}
impl Square {
pub fn new(s: f64) -> Self { Square { side: s } }
pub fn area(&self) -> f64 { self.side * self.side }
}
// Can access private fields of both within the same module
pub fn compare_areas(c: &Circle, s: &Square) -> std::cmp::Ordering {
c.radius.partial_cmp(&(s.side / 2.0)).unwrap()
}
}
Inheritance vs Composition
Rust has no inheritance. This is deliberate. Inheritance creates tight coupling and the diamond problem. Rust instead uses:
- Trait defaults — shared behavior through default implementations
- Composition — embed types inside structs
- Delegation — forward method calls to inner types
Trait Default Methods (Shared Behavior)
trait Animal {
fn name(&self) -> &str;
fn sound(&self) -> &str;
// Default method — shared behavior without inheritance
fn introduce(&self) {
println!("I am {} and I say '{}'", self.name(), self.sound());
}
fn is_loud(&self) -> bool {
self.sound().len() > 3
}
}
struct Dog { name: String }
struct Cat { name: String }
struct Mouse { name: String }
impl Animal for Dog {
fn name(&self) -> &str { &self.name }
fn sound(&self) -> &str { "woof" }
// Uses default `introduce` and `is_loud`
}
impl Animal for Cat {
fn name(&self) -> &str { &self.name }
fn sound(&self) -> &str { "meow" }
// Override introduce
fn introduce(&self) {
println!("I am {}, and I ignore you 😸", self.name());
}
}
impl Animal for Mouse {
fn name(&self) -> &str { &self.name }
fn sound(&self) -> &str { "squeak" }
}
fn main() {
let animals: Vec<Box<dyn Animal>> = vec![
Box::new(Dog { name: "Rex".to_string() }),
Box::new(Cat { name: "Whiskers".to_string() }),
Box::new(Mouse { name: "Jerry".to_string() }),
];
for animal in &animals {
animal.introduce();
println!(" Loud: {}", animal.is_loud());
}
}
Composition
Instead of a Vehicle base class with Car extending it, compose:
#[derive(Debug)]
struct Engine {
horsepower: u32,
cylinders: u32,
}
impl Engine {
fn start(&self) { println!("Engine started ({} HP)", self.horsepower); }
fn stop(&self) { println!("Engine stopped"); }
}
#[derive(Debug)]
struct Transmission {
gear: u8,
automatic: bool,
}
impl Transmission {
fn shift_up(&mut self) { self.gear += 1; }
fn current_gear(&self) -> u8 { self.gear }
}
#[derive(Debug)]
pub struct Car {
engine: Engine,
transmission: Transmission,
make: String,
model: String,
}
impl Car {
pub fn new(make: &str, model: &str, hp: u32) -> Self {
Car {
engine: Engine { horsepower: hp, cylinders: 4 },
transmission: Transmission { gear: 1, automatic: true },
make: make.to_string(),
model: model.to_string(),
}
}
// Delegate to inner types
pub fn start(&self) { self.engine.start(); }
pub fn stop(&self) { self.engine.stop(); }
pub fn accelerate(&mut self) {
self.transmission.shift_up();
println!("Now in gear {}", self.transmission.current_gear());
}
}
fn main() {
let mut car = Car::new("Toyota", "Corolla", 132);
car.start();
car.accelerate();
car.accelerate();
car.stop();
}
Polymorphism
Rust supports two kinds of polymorphism:
Static Dispatch (Generics + impl Trait)
Resolved at compile time. Zero overhead — the compiler generates separate code for each type:
trait Drawable {
fn draw(&self) -> String;
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }
struct Triangle { base: f64, height: f64 }
impl Drawable for Circle {
fn draw(&self) -> String { format!("○ (r={})", self.radius) }
fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}
impl Drawable for Rectangle {
fn draw(&self) -> String { format!("□ ({}x{})", self.width, self.height) }
fn area(&self) -> f64 { self.width * self.height }
}
impl Drawable for Triangle {
fn draw(&self) -> String { format!("△ (b={},h={})", self.base, self.height) }
fn area(&self) -> f64 { 0.5 * self.base * self.height }
}
// Static dispatch — T is resolved at compile time
fn print_shape(shape: &impl Drawable) {
println!("{} area={:.2}", shape.draw(), shape.area());
}
fn main() {
print_shape(&Circle { radius: 3.0 });
print_shape(&Rectangle { width: 4.0, height: 5.0 });
}
Dynamic Dispatch (Trait Objects)
Resolved at runtime via a vtable. Allows heterogeneous collections:
fn total_area(shapes: &[Box<dyn Drawable>]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
fn main() {
let shapes: Vec<Box<dyn Drawable>> = vec![
Box::new(Circle { radius: 3.0 }),
Box::new(Rectangle { width: 4.0, height: 5.0 }),
Box::new(Triangle { base: 6.0, height: 4.0 }),
Box::new(Circle { radius: 1.5 }),
];
for s in &shapes {
println!("{} area={:.2}", s.draw(), s.area());
}
println!("Total area: {:.2}", total_area(&shapes));
}
Common OOP Patterns in Rust
Strategy Pattern
Replace a family of algorithms with trait implementations:
trait SortStrategy {
fn sort(&self, data: &mut Vec<i32>);
fn name(&self) -> &str;
}
struct BubbleSort;
struct QuickSortStrategy;
impl SortStrategy for BubbleSort {
fn sort(&self, data: &mut Vec<i32>) {
let n = data.len();
for i in 0..n {
for j in 0..n-i-1 {
if data[j] > data[j+1] { data.swap(j, j+1); }
}
}
}
fn name(&self) -> &str { "BubbleSort" }
}
impl SortStrategy for QuickSortStrategy {
fn sort(&self, data: &mut Vec<i32>) { data.sort(); } // stdlib quicksort
fn name(&self) -> &str { "QuickSort" }
}
struct Sorter {
strategy: Box<dyn SortStrategy>,
}
impl Sorter {
fn new(strategy: impl SortStrategy + 'static) -> Self {
Sorter { strategy: Box::new(strategy) }
}
fn sort(&self, data: &mut Vec<i32>) {
println!("Sorting with {}", self.strategy.name());
self.strategy.sort(data);
}
}
fn main() {
let mut data = vec![5, 2, 8, 1, 9, 3];
let sorter = Sorter::new(QuickSortStrategy);
sorter.sort(&mut data);
println!("{:?}", data);
}
Observer Pattern
use std::rc::Rc;
use std::cell::RefCell;
trait Observer {
fn update(&self, event: &str, value: f64);
}
struct StockPrice {
symbol: String,
price: f64,
observers: Vec<Rc<dyn Observer>>,
}
impl StockPrice {
fn new(symbol: &str, price: f64) -> Self {
StockPrice { symbol: symbol.to_string(), price, observers: Vec::new() }
}
fn subscribe(&mut self, obs: Rc<dyn Observer>) {
self.observers.push(obs);
}
fn set_price(&mut self, new_price: f64) {
let event = if new_price > self.price { "RISE" } else { "FALL" };
self.price = new_price;
for obs in &self.observers {
obs.update(event, new_price);
}
}
}
struct PriceLogger { name: String }
struct AlertSystem { threshold: f64 }
impl Observer for PriceLogger {
fn update(&self, event: &str, value: f64) {
println!("[{}] Price {} to {:.2}", self.name, event, value);
}
}
impl Observer for AlertSystem {
fn update(&self, event: &str, value: f64) {
if value > self.threshold {
println!("🚨 ALERT: Price {:.2} exceeds threshold {:.2}", value, self.threshold);
}
}
}
Template Method Pattern
Define an algorithm skeleton, let subclasses fill in the steps:
trait DataProcessor {
// Template method — fixed algorithm
fn process(&self, data: &str) -> String {
let validated = self.validate(data);
let cleaned = self.clean(&validated);
let result = self.transform(&cleaned);
self.format(&result)
}
// Steps — override these
fn validate(&self, data: &str) -> String { data.to_string() }
fn clean(&self, data: &str) -> String { data.trim().to_string() }
fn transform(&self, data: &str) -> String;
fn format(&self, data: &str) -> String { data.to_string() }
}
struct UpperCaseProcessor;
struct CsvProcessor;
impl DataProcessor for UpperCaseProcessor {
fn transform(&self, data: &str) -> String { data.to_uppercase() }
fn format(&self, data: &str) -> String { format!("[{}]", data) }
}
impl DataProcessor for CsvProcessor {
fn validate(&self, data: &str) -> String {
if data.contains(',') { data.to_string() }
else { format!("{},N/A", data) }
}
fn transform(&self, data: &str) -> String {
data.split(',').map(|s| s.trim().to_string()).collect::<Vec<_>>().join("|")
}
}
fn main() {
let processors: Vec<Box<dyn DataProcessor>> = vec![
Box::new(UpperCaseProcessor),
Box::new(CsvProcessor),
];
let input = " hello, world ";
for p in &processors {
println!("{}", p.process(input));
}
// [HELLO, WORLD]
// hello|world
}
Summary: OOP Concepts in Rust
| OOP Concept | Rust Equivalent |
|---|---|
| Class | struct + impl block |
| Private fields | Default (no pub) |
| Getter/setter | fn field(&self) / fn set_field(&mut self) |
| Inheritance | Traits with default methods + composition |
| Interface | Trait |
| Abstract class | Trait with some default + some required methods |
| Virtual methods | dyn Trait (dynamic dispatch) |
| Overloading | Not supported — use different method names |
| Constructor | Type::new() convention |
| Destructor | impl Drop for Type |
The key mindset shift: instead of “what does this object inherit?”, ask “what traits does this type implement?” — composition and shared behavior via traits is more flexible and avoids the fragile base class problem.
Comments