Skip to main content

Object-Oriented Programming in Rust

Published: October 28, 2025 Updated: August 28, 2026 Larry Qu 8 min read

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:

  1. Trait defaults — shared behavior through default implementations
  2. Composition — embed types inside structs
  3. 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.

Resources

Comments

👍 Was this article helpful?