Object-Oriented Programming in Rust

rustnotesrust-book

What This Chapter Is Really About

Before we write any code, let's understand the question this chapter answers:

"Rust isn't Java or Python. So how do I do the OOP stuff I've heard about?"

The chapter explores three things:

  1. What does "object-oriented" even mean?
  2. Which OOP ideas does Rust support?
  3. Which OOP ideas does Rust intentionally reject (and why)?

Part 1: What Is Object-Oriented Programming?

The Confusion

Here's the thing: there's no single agreed-upon definition of OOP. Ask five programmers, get five answers.

But most people agree on three core ideas:

ConceptPlain English
EncapsulationHiding the messy details, showing only what's needed
InheritanceCreating new types based on existing types
PolymorphismOne piece of code working with multiple different types

Let's explore each one individually, slowly.


Part 2: Encapsulation

What Is It?

Encapsulation means two things bundled together:

  1. Bundling data with behavior: A struct contains data, and methods operate on that data
  2. Controlling access: Deciding what outsiders can see and touch

A Real-World Analogy

Think of a coffee machine:

What you see (public interface):

  • A button that says "Make Coffee"
  • A place to pour water
  • A cup holder

What you don't see (private internals):

  • The heating element
  • The pump mechanism
  • The temperature sensor

You don't need to understand the pump to make coffee. And the manufacturer can change the pump without you knowing, as long as the button still works.

How Rust Does Encapsulation

Rust uses one keyword: pub

The rule is simple:

  • By default, everything is private
  • Add pub to make something public

Let me show you with tiny examples first.

Example: A Private Field

struct Wallet {
    money: u32,  // No pub = private
}

What does "private" mean here?

fn main() {
    let wallet = Wallet { money: 100 };  // ❌ ERROR!
}

This won't compile. Why? Because money is private. Code outside the Wallet's module cannot directly access or set money.

Example: A Public Field

struct Wallet {
    pub money: u32,  // pub = public
}

Now this works:

fn main() {
    let mut wallet = Wallet { money: 100 };  // ✅ Works
    wallet.money = 999999;  // ✅ Can modify directly
}

Why Would You Want Private Fields?

Here's the key insight: private fields let you control how data changes.

With a public field, anyone can do anything:

wallet.money = 999999;  // Cheating!
wallet.money = -50;     // Invalid state! (if it were i32)

With private fields, you force people to go through your methods:

struct Wallet {
    money: u32,  // Private
}
 
impl Wallet {
    // The ONLY way to create a Wallet
    pub fn new(starting_amount: u32) -> Wallet {
        Wallet { money: starting_amount }
    }
    
    // The ONLY way to see the money
    pub fn balance(&self) -> u32 {
        self.money
    }
    
    // The ONLY way to add money
    pub fn deposit(&mut self, amount: u32) {
        self.money += amount;
    }
    
    // The ONLY way to remove money (with validation!)
    pub fn withdraw(&mut self, amount: u32) -> Result<(), String> {
        if amount > self.money {
            Err(String::from("Not enough money"))
        } else {
            self.money -= amount;
            Ok(())
        }
    }
}

Now look at what this enables:

fn main() {
    let mut wallet = Wallet::new(100);
    
    // wallet.money = 999999;  // ❌ Can't cheat anymore!
    
    wallet.deposit(50);  // ✅ Must use the method
    
    let result = wallet.withdraw(200);  // Tries to withdraw too much
    // result is Err("Not enough money") - we prevented invalid state!
}

The Big Benefit of Encapsulation

Because money is private, you can change how it works internally without breaking any code that uses Wallet.

Maybe later you decide to track money in cents instead of dollars:

struct Wallet {
    cents: u64,  // Changed! But it's private, so no one outside notices
}
 
impl Wallet {
    pub fn balance(&self) -> u32 {
        (self.cents / 100) as u32  // Convert internally
    }
    
    // ... other methods adapt internally too
}

Everyone using Wallet keeps calling balance() and it still works. They never knew about the change.

When To Use Encapsulation

Use private fields when:

  • You need to validate data before it changes
  • You want to be able to change internals later
  • The data has rules (balance can't be negative, age can't be 500, etc.)

Use public fields when:

  • The struct is just simple data with no rules
  • You're prototyping and don't care yet

Part 3: Inheritance (Or Why Rust Says "No Thanks")

What Is Inheritance?

In traditional OOP (Java, C++, Python), inheritance means:

"I want to create a new type that's based on an existing type, automatically getting all its stuff."

Conceptually:

Animal (parent)
  - has name
  - can eat()
  - can sleep()
  
Dog (child, inherits from Animal)
  - automatically has name, eat(), sleep()
  - adds bark()

Rust's Position

Rust does not have struct inheritance.

You cannot write "struct Dog extends Animal" in Rust. It's simply not a feature.

Why Not?

The Rust designers looked at decades of OOP experience and found that inheritance often causes problems:

Problem 1: Tight Coupling

When Dog inherits from Animal, Dog becomes deeply dependent on Animal's internal structure. If Animal changes, Dog might break.

Problem 2: The Fragile Base Class Problem

Imagine Animal has 50 types inheriting from it. Now you need to change Animal. You might accidentally break 20 of those child types without realizing it.

Problem 3: Inheritance Hierarchies Get Messy

What if something is both a FlyingThing and a SwimmingThing? You end up with diamond inheritance, multiple inheritance, and complex hierarchies that are hard to understand.

What Rust Offers Instead

Rust gives you two alternatives that cover most use cases:

  1. Default trait implementations: for sharing code
  2. Trait objects: for polymorphism

Let's look at default implementations now. We'll cover trait objects in the next section.

Default Trait Implementations

You already learned traits can have default method implementations. This is Rust's way of sharing code.

trait Animal {
    // Required: each type must implement this
    fn name(&self) -> &str;
    
    // Default: types get this for free (but can override)
    fn eat(&self) {
        println!("{} is eating", self.name());
    }
    
    // Default
    fn sleep(&self) {
        println!("{} is sleeping", self.name());
    }
}

Now any type implementing Animal:

  • Must provide name()
  • Gets eat() and sleep() automatically
  • Can override them if needed
struct Dog {
    name: String,
}
 
impl Animal for Dog {
    fn name(&self) -> &str {
        &self.name
    }
    // Gets eat() and sleep() automatically!
}
 
struct Cat {
    name: String,
}
 
impl Animal for Cat {
    fn name(&self) -> &str {
        &self.name
    }
    
    // Override the default
    fn sleep(&self) {
        println!("{} sleeps with one eye open", self.name());
    }
}
fn main() {
    let dog = Dog { name: String::from("Buddy") };
    let cat = Cat { name: String::from("Whiskers") };
    
    dog.eat();   // "Buddy is eating" (default)
    dog.sleep(); // "Buddy is sleeping" (default)
    
    cat.eat();   // "Whiskers is eating" (default)
    cat.sleep(); // "Whiskers sleeps with one eye open" (overridden)
}

Inheritance vs Default Implementations

Inheritance (other languages)Default Implementations (Rust)
Child gets parent's data and methodsTypes get default methods only
Hierarchy: Dog IS-A AnimalRelationship: Dog IMPLEMENTS Animal
Changing parent can break childrenTraits are more loosely coupled
Complex multiple inheritanceCan implement multiple traits easily

Part 4: Polymorphism

What Is Polymorphism?

The word comes from Greek: "poly" (many) + "morph" (form) = many forms.

In programming, it means:

"Writing code that can work with multiple different types."

Why Do We Need It?

Let me show you the problem polymorphism solves.

Imagine you're building a simple drawing app. You have shapes:

struct Circle {
    radius: f64,
}
 
struct Rectangle {
    width: f64,
    height: f64,
}

You want each shape to draw itself:

impl Circle {
    fn draw(&self) {
        println!("Drawing a circle");
    }
}
 
impl Rectangle {
    fn draw(&self) {
        println!("Drawing a rectangle");
    }
}

This works fine for individual shapes:

fn main() {
    let c = Circle { radius: 5.0 };
    let r = Rectangle { width: 10.0, height: 20.0 };
    
    c.draw();  // ✅ Works
    r.draw();  // ✅ Works
}

The Problem

But what if you want to store multiple shapes together?

fn main() {
    // ❌ This doesn't work!
    let shapes = vec![
        Circle { radius: 5.0 },
        Rectangle { width: 10.0, height: 20.0 },
    ];
}

This fails because Vec can only hold one type. Circle and Rectangle are different types.

And what if you want a function that draws any shape?

// What type goes here???
fn draw_shape(shape: ???) {
    shape.draw();
}

This is the problem polymorphism solves: How do we write code that works with multiple different types?

Rust's Two Approaches

Rust gives you two ways to achieve polymorphism:

ApproachNameWhen Type Is Known
GenericsStatic dispatchCompile time
Trait objectsDynamic dispatchRuntime

Let's understand each one.


Part 5: Generics (Static Dispatch): Quick Review

You already know generics from earlier chapters. Let me briefly review why they're a form of polymorphism.

The Setup

First, we define a trait:

trait Drawable {
    fn draw(&self);
}

Then we implement it for our types:

impl Drawable for Circle {
    fn draw(&self) {
        println!("Drawing circle with radius {}", self.radius);
    }
}
 
impl Drawable for Rectangle {
    fn draw(&self) {
        println!("Drawing rectangle {}x{}", self.width, self.height);
    }
}

Generic Function

Now we can write a function that works with any Drawable type:

fn draw_shape<T: Drawable>(shape: &T) {
    shape.draw();
}

Let's break this down:

  • <T: Drawable>: "T is some type that implements Drawable"
  • shape: &T: "shape is a reference to that type"

Using It

fn main() {
    let circle = Circle { radius: 5.0 };
    let rect = Rectangle { width: 10.0, height: 20.0 };
    
    draw_shape(&circle);  // T = Circle
    draw_shape(&rect);    // T = Rectangle
}

What "Static Dispatch" Means

When you compile this code, Rust generates two separate functions:

// The compiler creates these behind the scenes:
fn draw_shape_circle(shape: &Circle) { ... }
fn draw_shape_rectangle(shape: &Rectangle) { ... }

The word "static" means the decision happens at compile time. The compiler knows exactly which function to call.

The Limitation of Generics

Generics are powerful, but they have one limitation:

The concrete type must be known at compile time.

This means you still can't do this:

fn main() {
    let circle = Circle { radius: 5.0 };
    let rect = Rectangle { width: 10.0, height: 20.0 };
    
    // ❌ Still doesn't work!
    let shapes: Vec<???> = vec![circle, rect];
}

Why? Because Vec<T> needs one specific type for T. Even with generics, Circle and Rectangle are still different types.

This is where trait objects come in.


Part 6: Trait Objects (Dynamic Dispatch): The New Concept

The Core Idea

A trait object lets you say:

"I don't care what the exact type is, as long as it implements this trait."

The Syntax

Here's how you write a trait object:

&dyn Drawable

Let's break this into pieces:

PartMeaning
&A reference (borrowing)
dyn"Dynamic": the type is determined at runtime
DrawableThe trait it must implement

Using a Trait Object in a Function

fn draw_shape(shape: &dyn Drawable) {
    shape.draw();
}

Compare to the generic version:

// Generic (static dispatch)
fn draw_shape<T: Drawable>(shape: &T)
 
// Trait object (dynamic dispatch)  
fn draw_shape(shape: &dyn Drawable)

Both work! Both let you pass in any type that implements Drawable. But they work differently under the hood.

What "Dynamic Dispatch" Means

With trait objects, there's only one function, not multiple generated copies.

But how does Rust know which draw() to call: Circle's or Rectangle's?

At runtime, Rust looks up the correct method in something called a vtable (virtual table). It's like a lookup table:

"Hey, this thing is a Circle. Let me check the vtable... 
 ah, Circle's draw() is at this memory address. Call it!"

The word "dynamic" means the decision happens at runtime, not compile time.

Solving Our Original Problem

Now we can put different types in the same Vec!

fn main() {
    let shapes: Vec<&dyn Drawable> = vec![
        &Circle { radius: 5.0 },
        &Rectangle { width: 10.0, height: 20.0 },
    ];
    
    for shape in shapes {
        shape.draw();
    }
}

It works! The Vec holds &dyn Drawable: references to anything that implements Drawable.


Part 7: The Box<dyn Trait> Pattern

The Problem with References

The previous example used references:

let shapes: Vec<&dyn Drawable> = vec![
    &Circle { radius: 5.0 },
    // ...
];

But references have lifetimes. What if you want the Vec to own the shapes?

// ❌ This doesn't work
let shapes: Vec<dyn Drawable> = vec![...];

Why not? Because dyn Drawable has no known size. A Circle might be 8 bytes. A Rectangle might be 16 bytes. Rust needs to know sizes at compile time to allocate stack space.

The Solution: Box

Box puts data on the heap and gives you a fixed-size pointer.

let shapes: Vec<Box<dyn Drawable>> = vec![
    Box::new(Circle { radius: 5.0 }),
    Box::new(Rectangle { width: 10.0, height: 20.0 }),
];

Let's break down Box<dyn Drawable>:

PartMeaning
Box<...>A heap-allocated smart pointer (fixed size: just a pointer)
dyn DrawableCould be any type implementing Drawable

Full Example

trait Drawable {
    fn draw(&self);
}
 
struct Circle {
    radius: f64,
}
 
struct Rectangle {
    width: f64,
    height: f64,
}
 
impl Drawable for Circle {
    fn draw(&self) {
        println!("⭕ Circle, radius: {}", self.radius);
    }
}
 
impl Drawable for Rectangle {
    fn draw(&self) {
        println!("🟦 Rectangle: {} x {}", self.width, self.height);
    }
}
 
fn main() {
    // A Vec that OWNS different shapes
    let shapes: Vec<Box<dyn Drawable>> = vec![
        Box::new(Circle { radius: 3.0 }),
        Box::new(Rectangle { width: 4.0, height: 5.0 }),
        Box::new(Circle { radius: 1.5 }),
    ];
    
    for shape in shapes.iter() {
        shape.draw();
    }
}

Output:

⭕ Circle, radius: 3
🟦 Rectangle: 4 x 5
⭕ Circle, radius: 1.5

Part 8: When To Use Which?

This is the key practical question.

Use Generics (Static Dispatch) When:

You know all types at compile time.

fn print_twice<T: Display>(item: T) {
    println!("{}", item);
    println!("{}", item);
}

Performance matters a lot.

Static dispatch has zero runtime overhead. The compiler generates optimized code for each type.

You're writing library code that should be maximally fast.

Use Trait Objects (Dynamic Dispatch) When:

You need different types in one collection.

let shapes: Vec<Box<dyn Drawable>> = vec![...];

Types are determined at runtime.

For example, a user clicks "add circle" or "add rectangle"; you don't know which at compile time.

You're building plugin systems or extensible architectures.

New types can be added without recompiling existing code.

The Trade-Off

AspectGenericsTrait Objects
PerformanceFaster (no lookup)Slight overhead (vtable lookup)
Binary sizeLarger (code duplicated per type)Smaller (one copy of code)
FlexibilityTypes fixed at compile timeTypes can vary at runtime
CollectionsOne type per collectionMixed types in one collection

Part 9: Object Safety

The Catch

Not every trait can be used as a trait object. A trait must be "object-safe" to be used with dyn.

Why?

Think about what a trait object does: it says "I'm some type that implements this trait, but you don't know which one."

Some trait features require knowing the concrete type. Those features break the abstraction.

Rule 1: No Self in Return Types

❌ Not object-safe:

trait Cloneable {
    fn clone(&self) -> Self;
}

Why? If you have a &dyn Cloneable, what type does clone() return? The compiler doesn't know the concrete type, so it can't determine the return type's size.

✅ Object-safe alternatives:

trait Cloneable {
    fn clone(&self) -> Box<dyn Cloneable>;  // Returns a trait object
}
 
// Or just don't return Self
trait Drawable {
    fn draw(&self);  // Returns nothing - fine!
}

Rule 2: No Generic Type Parameters on Methods

❌ Not object-safe:

trait Processor {
    fn process<T>(&self, input: T);
}

Why? For each different T, you'd need a different entry in the vtable. But the vtable is fixed at compile time.

✅ Object-safe:

trait Processor {
    fn process_string(&self, input: String);
    fn process_number(&self, input: i32);
}

Simple Checklist

Before using dyn Trait, ask:

  1. Do any methods return Self? → Not object-safe
  2. Do any methods have generic parameters <T>? → Not object-safe
  3. Neither of those? → Object-safe ✅

Common Object-Safe Patterns

trait Drawable {
    fn draw(&self);                    // ✅ No return
    fn area(&self) -> f64;             // ✅ Returns concrete type
    fn name(&self) -> &str;            // ✅ Returns reference
    fn set_color(&mut self, c: Color); // ✅ Takes concrete parameter
}

Quick Summary So Far

ConceptWhat It IsRust's Approach
EncapsulationHiding internalsPrivate by default, pub for public
InheritanceTypes based on other typesNo struct inheritance; use default trait methods
PolymorphismCode working with multiple typesGenerics (static) or trait objects (dynamic)
SyntaxMeaning
&dyn TraitBorrowed trait object
Box<dyn Trait>Owned trait object
fn foo<T: Trait>(x: T)Generic with trait bound
fn foo(x: &dyn Trait)Function taking trait object

Part 10: The State Pattern

What Problem Does It Solve?

Sometimes an object needs to behave differently depending on what state it's in.

Think about a traffic light:

  • When it's red → cars must stop
  • When it's yellow → cars should slow down
  • When it's green → cars can go

The traffic light is one object, but its behavior changes based on its internal state.

A Real-World Example: Blog Posts

Let's use something more relatable for programming: a blog post that goes through a workflow.

A blog post might have these states:

Draft → PendingReview → Published

And the rules are:

StateWhat You Can Do
DraftAdd/edit text, request review
PendingReviewApprove or reject
PublishedRead the content

Here's the key insight: the same action has different results depending on the state.

  • Calling content() on a Draft? Returns nothing (not ready yet)
  • Calling content() on a Published post? Returns the actual text

The Naive Approach (And Why It's Messy)

You might think: "I'll just use an enum and match on it!"

enum PostState {
    Draft,
    PendingReview,
    Published,
}
 
struct Post {
    state: PostState,
    content: String,
}
 
impl Post {
    fn content(&self) -> &str {
        match self.state {
            PostState::Draft => "",
            PostState::PendingReview => "",
            PostState::Published => &self.content,
        }
    }
    
    fn request_review(&mut self) {
        match self.state {
            PostState::Draft => self.state = PostState::PendingReview,
            PostState::PendingReview => {},  // Already pending
            PostState::Published => {},       // Can't un-publish
        }
    }
    
    fn approve(&mut self) {
        match self.state {
            PostState::Draft => {},           // Can't approve draft
            PostState::PendingReview => self.state = PostState::Published,
            PostState::Published => {},       // Already published
        }
    }
}

The Problem With This Approach

This works, but imagine:

  • You have 10 states instead of 3
  • You have 15 methods instead of 3
  • Each method has a big match with 10 arms

The code becomes a tangled mess. Every method knows about every state. Adding a new state means editing every method.

The State Pattern Solution

The State Pattern says:

"Instead of one object that changes behavior, have separate state objects that each know their own behavior."

Each state becomes its own type:

  • Draft struct: knows how Draft behaves
  • PendingReview struct: knows how PendingReview behaves
  • Published struct: knows how Published behaves

They all implement a common trait, so the main Post can hold any of them.


Part 11: Building the State Pattern Step by Step

Let's build this slowly.

Step 1: Define What All States Can Do

First, we need a trait that defines what actions exist:

trait PostState {
    fn request_review(self: Box<Self>) -> Box<dyn PostState>;
    fn approve(self: Box<Self>) -> Box<dyn PostState>;
    fn content<'a>(&self, post: &'a Post) -> &'a str;
}

Wait: there's new syntax here. Let me explain each method signature.

Understanding self: Box<Self>

fn request_review(self: Box<Self>) -> Box<dyn PostState>;

This is unusual. Let's break it down:

PartMeaning
self: Box<Self>This method takes ownership of a boxed self
-> Box<dyn PostState>Returns a (possibly different) boxed state

Why this pattern?

When you transition from Draft to PendingReview, you're consuming the old state and creating a new one. The old Draft is destroyed, and a new PendingReview takes its place.

self: Box<Self> lets us take ownership (and destroy) the current state.

Think of it like this:

Before: Post holds Box<Draft>
        ↓ request_review() consumes the Draft
After:  Post holds Box<PendingReview>

Understanding the content Signature

fn content<'a>(&self, post: &'a Post) -> &'a str;

This method:

  • Borrows self (doesn't consume)
  • Takes a reference to the Post
  • Returns a string slice with the same lifetime as the Post

Why pass post in?

The state objects don't store the content; the Post does. So when Published wants to return the content, it needs access to the Post.

Step 2: Define the Draft State

struct Draft {}
 
impl PostState for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn PostState> {
        // Draft → PendingReview
        Box::new(PendingReview {})
    }
    
    fn approve(self: Box<Self>) -> Box<dyn PostState> {
        // Can't approve a draft - stay as Draft
        self
    }
    
    fn content<'a>(&self, _post: &'a Post) -> &'a str {
        // Drafts don't show content
        ""
    }
}

Notice how Draft knows:

  • "If someone calls request_review, I become PendingReview"
  • "If someone calls approve, nothing happens (I stay Draft)"
  • "If someone asks for content, they get nothing"

Step 3: Define the PendingReview State

struct PendingReview {}
 
impl PostState for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn PostState> {
        // Already pending - stay the same
        self
    }
    
    fn approve(self: Box<Self>) -> Box<dyn PostState> {
        // PendingReview → Published
        Box::new(Published {})
    }
    
    fn content<'a>(&self, _post: &'a Post) -> &'a str {
        // Still not showing content
        ""
    }
}

Step 4: Define the Published State

struct Published {}
 
impl PostState for Published {
    fn request_review(self: Box<Self>) -> Box<dyn PostState> {
        // Already published - stay the same
        self
    }
    
    fn approve(self: Box<Self>) -> Box<dyn PostState> {
        // Already published - stay the same
        self
    }
    
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        // NOW we return the actual content!
        &post.content
    }
}

This is the key moment: Only Published returns real content. The behavior is encoded in the state itself.

Step 5: Define the Post Struct

struct Post {
    state: Option<Box<dyn PostState>>,
    content: String,
}

Why Option?

This is a Rust workaround. When we transition states, we need to:

  1. Take the old state out
  2. Call a method on it
  3. Put the new state back

We can't move out of &mut self directly. Using Option lets us take() the value temporarily.

Step 6: Implement Post Methods

impl Post {
    fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
    
    fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
    
    fn content(&self) -> &str {
        // Delegate to whatever state we're in
        self.state.as_ref().unwrap().content(self)
    }
    
    fn request_review(&mut self) {
        // Take the state, transition it, put the new state back
        if let Some(state) = self.state.take() {
            self.state = Some(state.request_review());
        }
    }
    
    fn approve(&mut self) {
        if let Some(state) = self.state.take() {
            self.state = Some(state.approve());
        }
    }
}

Understanding the State Transition Pattern

Let's zoom in on this:

fn request_review(&mut self) {
    if let Some(state) = self.state.take() {
        self.state = Some(state.request_review());
    }
}

Step by step:

  1. self.state.take(): Removes the state from the Option, leaving None temporarily
  2. state.request_review(): Consumes old state, returns new state
  3. self.state = Some(...): Puts the new state back

It's like swapping out a battery:

  1. Remove old battery
  2. Old battery transforms into new battery (magically)
  3. Insert new battery

Part 12: Seeing It Work

fn main() {
    let mut post = Post::new();
    
    // State: Draft
    post.add_text("Hello, this is my blog post!");
    println!("Content: '{}'", post.content());  // Empty!
    
    // State: Draft → PendingReview
    post.request_review();
    println!("Content: '{}'", post.content());  // Still empty!
    
    // State: PendingReview → Published
    post.approve();
    println!("Content: '{}'", post.content());  // Now shows content!
}

Output:

Content: ''
Content: ''
Content: 'Hello, this is my blog post!'

What Just Happened?

The Post doesn't have any match statements or if checks for state. It just delegates to whatever state it currently holds:

fn content(&self) -> &str {
    self.state.as_ref().unwrap().content(self)
}

The state object itself decides the behavior. This is the essence of the State Pattern.


Part 13: Why Is This Better?

Benefit 1: Each State Is Self-Contained

All the logic for "what does Draft do?" is in the Draft struct. You don't have to hunt through giant match statements.

Benefit 2: Adding New States Is Easy

Want to add a "Rejected" state?

struct Rejected {}
 
impl PostState for Rejected {
    fn request_review(self: Box<Self>) -> Box<dyn PostState> {
        // Can go back to pending
        Box::new(PendingReview {})
    }
    
    fn approve(self: Box<Self>) -> Box<dyn PostState> {
        self  // Can't approve rejected post
    }
    
    fn content<'a>(&self, _post: &'a Post) -> &'a str {
        ""
    }
}

You add a new struct. You don't touch any existing code.

Benefit 3: Invalid Transitions Are Handled Gracefully

What if you call approve() on a Draft? In the naive approach, you need to remember to handle it.

With the State Pattern, Draft::approve simply returns self, staying in Draft. The state itself enforces the rules.


Part 14: An Alternative: Type-State Pattern

There's another way to model states in Rust: using the type system itself.

Instead of one Post type with internal state, you have separate types for each state:

struct DraftPost {
    content: String,
}
 
struct PendingReviewPost {
    content: String,
}
 
struct PublishedPost {
    content: String,
}

Different Types, Different Methods

impl DraftPost {
    fn new() -> DraftPost {
        DraftPost { content: String::new() }
    }
    
    fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
    
    // Returns a DIFFERENT TYPE
    fn request_review(self) -> PendingReviewPost {
        PendingReviewPost { content: self.content }
    }
}
 
impl PendingReviewPost {
    // Returns a DIFFERENT TYPE
    fn approve(self) -> PublishedPost {
        PublishedPost { content: self.content }
    }
}
 
impl PublishedPost {
    fn content(&self) -> &str {
        &self.content
    }
}

The Key Difference

Notice: only PublishedPost has a content() method.

fn main() {
    let mut post = DraftPost::new();
    post.add_text("My blog post");
    
    // post.content();  // ❌ WON'T COMPILE - DraftPost has no content()
    
    let post = post.request_review();
    // post.content();  // ❌ WON'T COMPILE - PendingReviewPost has no content()
    
    let post = post.approve();
    println!("{}", post.content());  // ✅ Works!
}

Compile-Time Enforcement

With this approach, invalid operations won't even compile.

  • Trying to approve a draft? DraftPost has no approve() method.
  • Trying to read content before publishing? DraftPost has no content() method.

The compiler catches bugs, not runtime behavior.


Part 15: Comparing the Two Approaches

AspectTrait Objects (State Pattern)Type-State Pattern
Invalid operationsHandled at runtime (returns empty, does nothing)Won't compile
Single collectionYes: Vec<Box<dyn PostState>> possibleNo: different types can't mix
Adding new statesEasy: new struct, implement traitNew type + new methods
FlexibilityMore flexible (runtime decisions)More rigid but safer
When types changeSame variable can change stateVariable type changes (rebinding)

Type-State Example of Rebinding

Notice how the variable gets rebound:

let post = DraftPost::new();           // post: DraftPost
let post = post.request_review();       // post: PendingReviewPost (shadows old)
let post = post.approve();              // post: PublishedPost (shadows old)

Each let post = shadows the previous one with a new type.


Part 16: When To Use Which?

Use Trait Objects (State Pattern) When:

States can change dynamically at runtime.

Example: A game character that can switch between "Idle", "Walking", "Running", "Attacking" based on player input.

You need to store multiple items with different states together.

let posts: Vec<Box<dyn PostState>> = vec![...];

The workflow isn't strictly linear.

Example: Posts can go Draft → Review → Rejected → Draft → Review → Published (complex transitions).

Use Type-State Pattern When:

You want compile-time guarantees.

"It should be impossible to read content from an unpublished post": enforced by the compiler.

The workflow is linear and predictable.

Draft → Review → Published, no going back.

Maximum safety is more important than flexibility.


Part 17: Summary of Chapter 18

Let's recap everything:

The Three OOP Concepts

ConceptTraditional OOPRust's Approach
EncapsulationClasses with private fieldsStructs with private fields, pub keyword
InheritanceClass extends another classNo struct inheritance; use default trait methods
PolymorphismBase class referencesGenerics (static) or dyn Trait (dynamic)

Trait Objects

SyntaxWhat It Means
dyn TraitA trait object (unknown concrete type)
&dyn TraitBorrowed trait object
Box<dyn Trait>Owned trait object on heap
self: Box<Self>Method that consumes boxed self

Object Safety Rules

A trait is object-safe if:

  • No methods return Self
  • No methods have generic type parameters

When To Use What

SituationUse
Types known at compile time, need speedGenerics
Different types in one collectionTrait objects
Types determined at runtimeTrait objects
Want compiler to prevent invalid statesType-state pattern
Complex state transitions at runtimeState pattern with trait objects

Chapter 18 Practice Exercises

Exercise 1: Encapsulation

Create a BankAccount struct with a private balance field. Provide methods to:

  • Create a new account with a starting balance
  • Deposit money (only positive amounts)
  • Get the current balance

Users should NOT be able to modify balance directly.

// Your code here
 
fn main() {
    let mut account = BankAccount::new(100.0);
    println!("Balance: ${}", account.balance()); // 100
    
    account.deposit(50.0);
    println!("Balance: ${}", account.balance()); // 150
    
    account.deposit(-20.0); // Should do nothing (negative)
    println!("Balance: ${}", account.balance()); // Still 150
}

Exercise 2: Default Trait Implementations

Create an Animal trait with:

  • A required method name(&self) -> &str
  • A default method speak(&self) that prints "{name} makes a sound"

Create two structs:

  • Dog: overrides speak to print "{name} says woof!"
  • Cat: uses the default speak
// Your code here
 
fn main() {
    let dog = Dog::new("Buddy");
    let cat = Cat::new("Whiskers");
    
    dog.speak(); // Buddy says woof!
    cat.speak(); // Whiskers makes a sound
}

Exercise 3: Trait Objects with Box<dyn Trait>

Create a Shape trait with a method area(&self) -> f64.

Implement it for Circle (has radius) and Square (has side).

Create a Vec<Box<dyn Shape>> containing different shapes and print each area.

// Your code here
 
fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle::new(2.0)),  // area = π * 4 ≈ 12.57
        Box::new(Square::new(3.0)),  // area = 9
        Box::new(Circle::new(1.0)),  // area = π ≈ 3.14
    ];
    
    for shape in shapes.iter() {
        println!("Area: {:.2}", shape.area());
    }
}

Exercise 4: Object Safety

Look at these two traits. One is object-safe, one is not.

trait Cloneable {
    fn clone_self(&self) -> Self;
}
 
trait Displayable {
    fn display(&self) -> String;
}

Task:

  1. Which trait is object-safe and why?
  2. Which trait is NOT object-safe and why?
  3. Write code that proves your answer by creating Box<dyn TraitName> for the object-safe one.

Exercise 5: Simple State Pattern

Create a Door that can be Open or Closed.

  • toggle() switches between states
  • is_open() returns true only when open

Use trait objects for the state pattern.

// Your code here
 
fn main() {
    let mut door = Door::new(); // Starts Closed
    
    println!("Door open? {}", door.is_open()); // false
    
    door.toggle();
    println!("Door open? {}", door.is_open()); // true
    
    door.toggle();
    println!("Door open? {}", door.is_open()); // false
}