Object-Oriented Programming in Rust Programming Language: Structs, Traits, and Encapsulation Guide

Object-Oriented Programming in Rust

Object-Oriented Programming in Rust

When I first came to Rust after years of writing Java and C++, the first question I asked myself was: “Where are the classes?” There aren’t any. Rust doesn’t have classes, it doesn’t have inheritance in the classical sense, and it doesn’t have constructors in the way you’d expect. And yet, Rust is completely capable of object-oriented design. It just does it differently, and honestly, once it clicked for me, I found it more disciplined and less error-prone than the OOP I grew up with.

In this guide, I’m going to walk you through how Rust implements object-oriented concepts using structs, traits, and encapsulation. I’ll start from the fundamentals and work up to advanced patterns you’ll actually use in production code. By the end, you should understand not just the syntax, but the “why” behind Rust’s design choices, especially around ownership and memory safety.

Why Rust Doesn’t Have Classical OOP

Traditional OOP languages are built around three pillars: encapsulation, inheritance, and polymorphism. Rust supports encapsulation and polymorphism fully, but it deliberately leaves out inheritance. Instead, Rust favors composition over inheritance, and it achieves polymorphism through traits rather than base classes.

This isn’t a limitation — it’s a design philosophy. Inheritance hierarchies tend to become fragile as codebases grow (the classic “fragile base class” problem). Rust sidesteps this entirely by giving you structs for data and traits for shared behavior, and letting you compose them together.

Structs: The Foundation of Data Modeling

A struct in Rust is similar to a class without methods attached by default. It’s a way to group related data together.

struct User {
    username: String,
    email: String,
    active: bool,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        username: String::from("ali_dev"),
        email: String::from("ali@example.com"),
        active: true,
        sign_in_count: 1,
    };

    println!("Username: {}", user1.username);
}

Output:

Username: ali_dev

I like to think of a struct as the “noun” of your program — it represents a thing. The behavior attached to that thing comes later, through impl blocks.

Adding Behavior with impl Blocks

This is where Rust starts to feel object-oriented. You attach methods to a struct using an impl (implementation) block.

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };
    let rect2 = Rectangle { width: 10, height: 40 };

    println!("Area: {}", rect1.area());
    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
}

Output:

Area: 1500
Can rect1 hold rect2? true

Notice the &self parameter. This is Rust’s way of borrowing the instance without taking ownership of it. If I wrote self instead of &self, the method would take ownership of the struct and consume it — usually not what you want for a simple getter or calculation.

Associated Functions (Constructors)

Rust doesn’t have constructors, but it has a convention: associated functions that don’t take self are used to build new instances, typically named new.

impl Rectangle {
    fn new(width: u32, height: u32) -> Self {
        Rectangle { width, height }
    }
}

fn main() {
    let square = Rectangle::new(20, 20);
    println!("Square area: {}", square.area());
}

Output:

Square area: 400

Self here refers to the type the impl block is for — it saves you from repeating Rectangle everywhere and makes refactoring easier.

Encapsulation in Rust

Encapsulation means hiding internal implementation details and exposing only what’s necessary. Rust achieves this through its module system and visibility modifiers (pub), not through private/protected/public keywords attached to individual class members like in Java or C++.

mod bank_account {
    pub struct Account {
        owner: String,
        balance: f64,
    }

    impl Account {
        pub fn new(owner: &str, initial_balance: f64) -> Self {
            Account {
                owner: owner.to_string(),
                balance: initial_balance,
            }
        }

        pub fn deposit(&mut self, amount: f64) {
            self.balance += amount;
        }

        pub fn balance(&self) -> f64 {
            self.balance
        }
    }
}

fn main() {
    let mut acc = bank_account::Account::new("Ali", 100.0);
    acc.deposit(50.0);
    println!("Balance: {}", acc.balance());
}

Output:

Balance: 150

Here, balance is a private field — I can’t touch acc.balance directly from outside the module. I have to go through the public balance() method. This is genuine encapsulation, and Rust enforces it at compile time, not just by convention.

I’ve noticed that this makes API design much more intentional. You have to actively decide what’s pub, which forces you to think about your module’s public contract from day one.

Traits: Rust’s Answer to Interfaces and Shared Behavior

If structs are Rust’s nouns, traits are its verbs. A trait defines shared behavior that different types can implement, similar to an interface in Java or Go.

trait Shape {
    fn area(&self) -> f64;
    fn perimeter(&self) -> f64;
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }

    fn perimeter(&self) -> f64 {
        2.0 * std::f64::consts::PI * self.radius
    }
}

fn main() {
    let c = Circle { radius: 3.0 };
    println!("Area: {:.2}", c.area());
    println!("Perimeter: {:.2}", c.perimeter());
}

Output:

Area: 28.27
Perimeter: 18.85

Default Trait Implementations

One thing I really appreciate is that traits can provide default method bodies, which any implementing type can override if needed.

trait Greet {
    fn name(&self) -> String;

    fn greet(&self) -> String {
        format!("Hello, {}!", self.name())
    }
}

struct Person {
    name: String,
}

impl Greet for Person {
    fn name(&self) -> String {
        self.name.clone()
    }
}

fn main() {
    let p = Person { name: String::from("Sara") };
    println!("{}", p.greet());
}

Output:

Hello, Sara!

Polymorphism Through Trait Objects

This is where Rust’s OOP story really shines. Since there’s no inheritance, polymorphism is achieved through trait objects using dyn Trait and Box<dyn Trait>.

trait Shape {
    fn area(&self) -> f64;
}

struct Circle { radius: f64 }
struct Square { side: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}

impl Shape for Square {
    fn area(&self) -> f64 { self.side * self.side }
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 2.0 }),
        Box::new(Square { side: 4.0 }),
    ];

    for shape in shapes.iter() {
        println!("Area: {:.2}", shape.area());
    }
}

Output:

Area: 12.57
Area: 16.00

Here Box<dyn Shape> stores a heap-allocated value along with a vtable pointer used for dynamic dispatch, similar to how virtual functions work under the hood in C++. This is genuinely powerful for cases like GUI toolkits or plugin systems, where you need a collection of heterogeneous types that share behavior.

Ownership, Borrowing, and Memory Safety in OOP Design

This is the part that separates Rust from every other OOP language I’ve used. When you design structs and their methods, you’re constantly thinking about who owns the data.

struct Counter {
    count: u32,
}

impl Counter {
    fn increment(&mut self) {
        self.count += 1;
    }
}

fn main() {
    let mut counter = Counter { count: 0 };
    counter.increment();
    counter.increment();
    println!("Count: {}", counter.count);
}

Output:

Count: 2

The borrow checker enforces at compile time that you can’t have a mutable and immutable reference to the same struct at the same time. This eliminates an entire category of bugs — data races and use-after-free errors — that plague OOP code in C++ when object lifetimes aren’t carefully managed.

Lifetimes in Struct Definitions

If a struct holds a reference instead of owned data, you need to annotate its lifetime.

struct Highlight<'a> {
    text: &'a str,
}

impl<'a> Highlight<'a> {
    fn show(&self) {
        println!("Highlighted: {}", self.text);
    }
}

fn main() {
    let sentence = String::from("Rust is memory safe");
    let h = Highlight { text: &sentence };
    h.show();
}

Output:

Highlighted: Rust is memory safe

The 'a lifetime tells the compiler that Highlight cannot outlive the string slice it’s borrowing. This prevents dangling references entirely — something garbage-collected languages avoid at runtime cost, and something C++ often gets wrong silently.

Real-World Application: Building a Simple Task Manager

Let me tie this together with something practical — a small task manager that uses structs, traits, and encapsulation together.

trait Task {
    fn describe(&self) -> String;
    fn is_done(&self) -> bool;
}

struct TodoItem {
    title: String,
    done: bool,
}

impl Task for TodoItem {
    fn describe(&self) -> String {
        format!("{} [{}]", self.title, if self.done { "x" } else { " " })
    }

    fn is_done(&self) -> bool {
        self.done
    }
}

struct TaskList {
    tasks: Vec<Box<dyn Task>>,
}

impl TaskList {
    fn new() -> Self {
        TaskList { tasks: Vec::new() }
    }

    fn add(&mut self, task: Box<dyn Task>) {
        self.tasks.push(task);
    }

    fn print_all(&self) {
        for task in &self.tasks {
            println!("{}", task.describe());
        }
    }
}

fn main() {
    let mut list = TaskList::new();
    list.add(Box::new(TodoItem { title: String::from("Learn Rust"), done: true }));
    list.add(Box::new(TodoItem { title: String::from("Write blog post"), done: false }));
    list.print_all();
}

Output:

Learn Rust [x]
Write blog post [ ]

This pattern — a trait for behavior, a struct for data, and Box<dyn Trait> for a heterogeneous collection — is something I use constantly in real Rust projects, from CLI tools to backend services.

Cargo Workflow for OOP-Style Rust Projects

When I start a new project, I always go through the same steps:

cargo new task_manager
cd task_manager
cargo build
cargo run
cargo test

cargo build compiles the project and catches ownership/borrowing errors early, which is one of Rust’s biggest advantages — most of what would be a runtime crash in other languages becomes a compile-time error here.

Best Practices I’ve Learned

  1. Favor composition over trying to simulate inheritance. If you find yourself wanting a base struct with shared fields, consider embedding a struct instead of forcing an inheritance-like pattern.
  2. Keep fields private and expose behavior through methods. This is true encapsulation and makes your code much easier to refactor later.
  3. Use traits for shared behavior, not shared data. Traits describe what a type can do, not what it contains.
  4. Prefer impl Trait over dyn Trait when you don’t need runtime polymorphism. Static dispatch is faster since the compiler can inline calls.
  5. Only reach for Box<dyn Trait> when you genuinely need a heterogeneous collection or runtime flexibility.

Common Mistakes to Avoid

Debugging Tips

When the compiler throws ownership or borrowing errors, read the message carefully — Rust’s compiler diagnostics are unusually good at explaining exactly what went wrong and often suggest a fix. Running cargo check frequently while developing (instead of a full cargo build) speeds up this feedback loop significantly.

FAQs

Does Rust support inheritance? No, not in the classical sense. Rust uses composition and trait implementation instead of class hierarchies.

Can a struct implement multiple traits? Yes. A struct can implement as many traits as needed, and each impl block is separate.

What’s the difference between impl Trait and dyn Trait? impl Trait is resolved at compile time (static dispatch, faster). dyn Trait is resolved at runtime through a vtable (dynamic dispatch, more flexible).

Is encapsulation really enforced, or just convention? It’s enforced by the compiler through the module and visibility system — private fields genuinely cannot be accessed outside their module.

Do I need lifetimes every time I use references in a struct? Only when the struct stores a reference instead of an owned value. Structs holding owned data like String or Vec<T> don’t need lifetime annotations.

Summary

Rust reimagines object-oriented programming without inheritance, replacing it with structs for data, traits for behavior, and a strict ownership model for memory safety. Encapsulation is enforced by the compiler, not just convention, and polymorphism is achieved cleanly through trait objects when you need it. Once you get comfortable with this model, you’ll likely find it produces more maintainable and safer code than traditional class hierarchies.

References

Exit mobile version