Rust Object-Oriented Patterns

OOP Concepts in a Systems Language

2026-02-01

Explanation

OOP in Rust

Rust doesn't have traditional classes, but achieves similar patterns through structs, traits, and enums. It favors composition over inheritance and uses traits for polymorphism.

Key Concepts

| Concept | Rust Equivalent | |---------|-----------------| | Classes | Structs + impl | | Inheritance | Traits + Default impl | | Interfaces | Traits | | Polymorphism | Trait objects | | Encapsulation | Module visibility |


Demonstration

Example 1: Structs and Methods

// Define a struct (like a class)
#[derive(Debug, Clone)]
pub struct User {
    pub name: String,
    pub email: String,
    age: u32,  // Private by default
}

impl User {
    // Associated function (like static method)
    pub fn new(name: String, email: String, age: u32) -> Self {
        Self { name, email, age }
    }

    // Builder pattern
    pub fn builder() -> UserBuilder {
        UserBuilder::default()
    }

    // Instance method (takes &self)
    pub fn greet(&self) -> String {
        format!("Hello, I'm {}!", self.name)
    }

    // Mutable method (takes &mut self)
    pub fn set_email(&mut self, email: String) {
        self.email = email;
    }

    // Getter for private field
    pub fn age(&self) -> u32 {
        self.age
    }

    // Method that consumes self
    pub fn into_parts(self) -> (String, String, u32) {
        (self.name, self.email, self.age)
    }
}

// Builder pattern
#[derive(Default)]
pub struct UserBuilder {
    name: Option<String>,
    email: Option<String>,
    age: Option<u32>,
}

impl UserBuilder {
    pub fn name(mut self, name: impl Into<String>) -> Self {
        self.name = Some(name.into());
        self
    }

    pub fn email(mut self, email: impl Into<String>) -> Self {
        self.email = Some(email.into());
        self
    }

    pub fn age(mut self, age: u32) -> Self {
        self.age = Some(age);
        self
    }

    pub fn build(self) -> Result<User, &'static str> {
        Ok(User {
            name: self.name.ok_or("name is required")?,
            email: self.email.ok_or("email is required")?,
            age: self.age.unwrap_or(0),
        })
    }
}

// Usage
fn main() {
    let user = User::new("Arthur".to_string(), "art@bpc.com".to_string(), 30);
    println!("{}", user.greet());

    // Builder
    let user2 = User::builder()
        .name("Sarah")
        .email("sarah@example.com")
        .age(25)
        .build()
        .unwrap();
}

Example 2: Traits (Interfaces)

// Define a trait (interface)
pub trait Drawable {
    fn draw(&self);

    // Default implementation
    fn description(&self) -> String {
        String::from("A drawable object")
    }
}

pub trait Resizable {
    fn resize(&mut self, factor: f64);
}

// Implement traits for structs
#[derive(Debug)]
pub struct Circle {
    pub x: f64,
    pub y: f64,
    pub radius: f64,
}

impl Drawable for Circle {
    fn draw(&self) {
        println!("Drawing circle at ({}, {}) with radius {}",
                 self.x, self.y, self.radius);
    }

    fn description(&self) -> String {
        format!("Circle with radius {}", self.radius)
    }
}

impl Resizable for Circle {
    fn resize(&mut self, factor: f64) {
        self.radius *= factor;
    }
}

#[derive(Debug)]
pub struct Rectangle {
    pub x: f64,
    pub y: f64,
    pub width: f64,
    pub height: f64,
}

impl Drawable for Rectangle {
    fn draw(&self) {
        println!("Drawing rectangle at ({}, {}) {}x{}",
                 self.x, self.y, self.width, self.height);
    }
}

impl Resizable for Rectangle {
    fn resize(&mut self, factor: f64) {
        self.width *= factor;
        self.height *= factor;
    }
}

// Trait bounds for generics
fn draw_shape<T: Drawable>(shape: &T) {
    shape.draw();
}

// Multiple trait bounds
fn draw_and_resize<T: Drawable + Resizable>(shape: &mut T) {
    shape.draw();
    shape.resize(1.5);
    shape.draw();
}

// Trait objects for runtime polymorphism
fn draw_all(shapes: &[&dyn Drawable]) {
    for shape in shapes {
        shape.draw();
    }
}

fn main() {
    let circle = Circle { x: 0.0, y: 0.0, radius: 5.0 };
    let rect = Rectangle { x: 10.0, y: 10.0, width: 20.0, height: 30.0 };

    // Static dispatch
    draw_shape(&circle);
    draw_shape(&rect);

    // Dynamic dispatch with trait objects
    let shapes: Vec<&dyn Drawable> = vec![&circle, &rect];
    draw_all(&shapes);
}

Example 3: Enums for State Machines

// Enums can hold data (like union types)
#[derive(Debug)]
pub enum ConnectionState {
    Disconnected,
    Connecting { attempt: u32 },
    Connected { session_id: String },
    Error { message: String },
}

pub struct Connection {
    state: ConnectionState,
    url: String,
}

impl Connection {
    pub fn new(url: String) -> Self {
        Self {
            state: ConnectionState::Disconnected,
            url,
        }
    }

    pub fn connect(&mut self) -> Result<(), String> {
        match &self.state {
            ConnectionState::Disconnected => {
                self.state = ConnectionState::Connecting { attempt: 1 };
                // Simulate connection
                self.state = ConnectionState::Connected {
                    session_id: "abc123".to_string(),
                };
                Ok(())
            }
            ConnectionState::Connected { .. } => {
                Err("Already connected".to_string())
            }
            ConnectionState::Connecting { .. } => {
                Err("Connection in progress".to_string())
            }
            ConnectionState::Error { message } => {
                Err(format!("Previous error: {}", message))
            }
        }
    }

    pub fn disconnect(&mut self) {
        self.state = ConnectionState::Disconnected;
    }

    pub fn is_connected(&self) -> bool {
        matches!(self.state, ConnectionState::Connected { .. })
    }

    pub fn session_id(&self) -> Option<&str> {
        match &self.state {
            ConnectionState::Connected { session_id } => Some(session_id),
            _ => None,
        }
    }
}

// Result and Option for error handling
pub fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("Division by zero".to_string())
    } else {
        Ok(a / b)
    }
}

pub fn find_user(id: u64) -> Option<User> {
    // Returns None if not found
    if id == 1 {
        Some(User::new("Arthur".to_string(), "art@bpc.com".to_string(), 30))
    } else {
        None
    }
}

Example 4: Composition over Inheritance

// Rust uses composition instead of inheritance
pub trait Logger {
    fn log(&self, message: &str);
}

pub struct ConsoleLogger;

impl Logger for ConsoleLogger {
    fn log(&self, message: &str) {
        println!("[LOG] {}", message);
    }
}

pub struct FileLogger {
    path: String,
}

impl Logger for FileLogger {
    fn log(&self, message: &str) {
        // Write to file
        println!("[FILE:{}] {}", self.path, message);
    }
}

// Compose with trait objects
pub struct Service {
    logger: Box<dyn Logger>,
    name: String,
}

impl Service {
    pub fn new(name: String, logger: Box<dyn Logger>) -> Self {
        Self { logger, name }
    }

    pub fn do_work(&self) {
        self.logger.log(&format!("{} doing work", self.name));
    }
}

// Or use generics for static dispatch
pub struct GenericService<L: Logger> {
    logger: L,
    name: String,
}

impl<L: Logger> GenericService<L> {
    pub fn new(name: String, logger: L) -> Self {
        Self { logger, name }
    }

    pub fn do_work(&self) {
        self.logger.log(&format!("{} doing work", self.name));
    }
}

// Newtype pattern for extending functionality
pub struct EmailAddress(String);

impl EmailAddress {
    pub fn new(email: &str) -> Result<Self, &'static str> {
        if email.contains('@') {
            Ok(Self(email.to_string()))
        } else {
            Err("Invalid email format")
        }
    }

    pub fn domain(&self) -> &str {
        self.0.split('@').nth(1).unwrap_or("")
    }
}

impl std::fmt::Display for EmailAddress {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

Example 5: Smart Pointers

use std::rc::Rc;
use std::cell::RefCell;

// Reference counting for shared ownership
#[derive(Debug)]
struct Node {
    value: i32,
    children: Vec<Rc<Node>>,
}

impl Node {
    fn new(value: i32) -> Rc<Self> {
        Rc::new(Self {
            value,
            children: Vec::new(),
        })
    }
}

// Interior mutability with RefCell
#[derive(Debug)]
struct Counter {
    count: RefCell<u32>,
}

impl Counter {
    fn new() -> Self {
        Self {
            count: RefCell::new(0),
        }
    }

    fn increment(&self) {
        *self.count.borrow_mut() += 1;
    }

    fn get(&self) -> u32 {
        *self.count.borrow()
    }
}

// Rc<RefCell<T>> for shared mutable state
type SharedState = Rc<RefCell<Vec<String>>>;

fn create_shared_state() -> SharedState {
    Rc::new(RefCell::new(Vec::new()))
}

fn add_item(state: &SharedState, item: String) {
    state.borrow_mut().push(item);
}

// Box for heap allocation
trait Animal {
    fn speak(&self) -> String;
}

struct Dog;
struct Cat;

impl Animal for Dog {
    fn speak(&self) -> String { "Woof!".to_string() }
}

impl Animal for Cat {
    fn speak(&self) -> String { "Meow!".to_string() }
}

fn create_animal(is_dog: bool) -> Box<dyn Animal> {
    if is_dog {
        Box::new(Dog)
    } else {
        Box::new(Cat)
    }
}

Example 6: Error Handling Patterns

use std::error::Error;
use std::fmt;

// Custom error type
#[derive(Debug)]
pub enum AppError {
    NotFound(String),
    Validation(String),
    Database(String),
    Unauthorized,
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::NotFound(msg) => write!(f, "Not found: {}", msg),
            AppError::Validation(msg) => write!(f, "Validation error: {}", msg),
            AppError::Database(msg) => write!(f, "Database error: {}", msg),
            AppError::Unauthorized => write!(f, "Unauthorized"),
        }
    }
}

impl Error for AppError {}

// Result type alias
pub type AppResult<T> = Result<T, AppError>;

// Service with error handling
pub struct UserService;

impl UserService {
    pub fn find_by_id(&self, id: u64) -> AppResult<User> {
        if id == 0 {
            return Err(AppError::Validation("Invalid ID".to_string()));
        }

        // Simulate database lookup
        if id == 999 {
            return Err(AppError::NotFound(format!("User {}", id)));
        }

        Ok(User::new(
            "Arthur".to_string(),
            "art@bpc.com".to_string(),
            30,
        ))
    }

    pub fn create(&self, name: &str, email: &str) -> AppResult<User> {
        if name.is_empty() {
            return Err(AppError::Validation("Name required".to_string()));
        }

        if !email.contains('@') {
            return Err(AppError::Validation("Invalid email".to_string()));
        }

        Ok(User::new(name.to_string(), email.to_string(), 0))
    }
}

// Using the ? operator
pub fn get_user_email(service: &UserService, id: u64) -> AppResult<String> {
    let user = service.find_by_id(id)?;
    Ok(user.email)
}

// Using thiserror crate (recommended)
// use thiserror::Error;
//
// #[derive(Error, Debug)]
// pub enum AppError {
//     #[error("not found: {0}")]
//     NotFound(String),
//     #[error("validation error: {0}")]
//     Validation(String),
// }

Key Takeaways:

  • Structs + impl for encapsulation
  • Traits for polymorphism and interfaces
  • Enums for state machines and variants
  • Composition over inheritance
  • Smart pointers for complex ownership

Imitation

Challenge 1: Implement a Repository Pattern

Task: Create a generic repository trait with in-memory implementation.

Solution

use std::collections::HashMap;
use std::hash::Hash;

pub trait Entity {
    type Id: Eq + Hash + Clone;
    fn id(&self) -> Self::Id;
}

pub trait Repository<T: Entity> {
    fn find(&self, id: &T::Id) -> Option<&T>;
    fn find_all(&self) -> Vec<&T>;
    fn save(&mut self, entity: T) -> &T;
    fn delete(&mut self, id: &T::Id) -> Option<T>;
}

pub struct InMemoryRepository<T: Entity> {
    data: HashMap<T::Id, T>,
}

impl<T: Entity> InMemoryRepository<T> {
    pub fn new() -> Self {
        Self {
            data: HashMap::new(),
        }
    }
}

impl<T: Entity> Repository<T> for InMemoryRepository<T> {
    fn find(&self, id: &T::Id) -> Option<&T> {
        self.data.get(id)
    }

    fn find_all(&self) -> Vec<&T> {
        self.data.values().collect()
    }

    fn save(&mut self, entity: T) -> &T {
        let id = entity.id();
        self.data.insert(id.clone(), entity);
        self.data.get(&id).unwrap()
    }

    fn delete(&mut self, id: &T::Id) -> Option<T> {
        self.data.remove(id)
    }
}

// Usage
#[derive(Clone)]
struct User {
    id: u64,
    name: String,
}

impl Entity for User {
    type Id = u64;
    fn id(&self) -> Self::Id {
        self.id
    }
}

fn main() {
    let mut repo: InMemoryRepository<User> = InMemoryRepository::new();

    repo.save(User { id: 1, name: "Arthur".to_string() });
    repo.save(User { id: 2, name: "Sarah".to_string() });

    if let Some(user) = repo.find(&1) {
        println!("Found: {}", user.name);
    }
}


Practice

Exercise 1: Observer Pattern

Difficulty: Intermediate

Implement an event system:

  • Subject trait with subscribe/notify
  • Observer trait
  • Type-safe events

Exercise 2: Plugin System

Difficulty: Advanced

Create a plugin architecture:

  • Plugin trait with lifecycle
  • Dynamic loading support
  • Dependency resolution

Summary

What you learned:

  • Structs and impl blocks
  • Traits for polymorphism
  • Enums for state machines
  • Composition patterns
  • Error handling

Next Steps:

  • Read: Rust API
  • Practice: Build a CLI tool
  • Explore: Async Rust

Resources