Object-Oriented Programming in Rust
January 5, 2026What 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:
- What does "object-oriented" even mean?
- Which OOP ideas does Rust support?
- 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:
| Concept | Plain English |
|---|---|
| Encapsulation | Hiding the messy details, showing only what's needed |
| Inheritance | Creating new types based on existing types |
| Polymorphism | One 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:
- Bundling data with behavior: A struct contains data, and methods operate on that data
- 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
pubto 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:
- Default trait implementations: for sharing code
- 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()andsleep()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 methods | Types get default methods only |
| Hierarchy: Dog IS-A Animal | Relationship: Dog IMPLEMENTS Animal |
| Changing parent can break children | Traits are more loosely coupled |
| Complex multiple inheritance | Can 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:
| Approach | Name | When Type Is Known |
|---|---|---|
| Generics | Static dispatch | Compile time |
| Trait objects | Dynamic dispatch | Runtime |
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:
| Part | Meaning |
|---|---|
& |
A reference (borrowing) |
dyn |
"Dynamic": the type is determined at runtime |
Drawable |
The 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>:
| Part | Meaning |
|---|---|
Box<...> |
A heap-allocated smart pointer (fixed size: just a pointer) |
dyn Drawable |
Could 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
| Aspect | Generics | Trait Objects |
|---|---|---|
| Performance | Faster (no lookup) | Slight overhead (vtable lookup) |
| Binary size | Larger (code duplicated per type) | Smaller (one copy of code) |
| Flexibility | Types fixed at compile time | Types can vary at runtime |
| Collections | One type per collection | Mixed 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:
- Do any methods return
Self? → Not object-safe - Do any methods have generic parameters
<T>? → Not object-safe - 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
| Concept | What It Is | Rust's Approach |
|---|---|---|
| Encapsulation | Hiding internals | Private by default, pub for public |
| Inheritance | Types based on other types | No struct inheritance; use default trait methods |
| Polymorphism | Code working with multiple types | Generics (static) or trait objects (dynamic) |
| Syntax | Meaning |
|---|---|
&dyn Trait |
Borrowed 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:
| State | What You Can Do |
|---|---|
| Draft | Add/edit text, request review |
| PendingReview | Approve or reject |
| Published | Read 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
matchwith 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:
Draftstruct: knows how Draft behavesPendingReviewstruct: knows how PendingReview behavesPublishedstruct: 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:
| Part | Meaning |
|---|---|
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 becomePendingReview" - "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:
- Take the old state out
- Call a method on it
- 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:
self.state.take(): Removes the state from the Option, leavingNonetemporarilystate.request_review(): Consumes old state, returns new stateself.state = Some(...): Puts the new state back
It's like swapping out a battery:
- Remove old battery
- Old battery transforms into new battery (magically)
- 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?
DraftPosthas noapprove()method. - Trying to read content before publishing?
DraftPosthas nocontent()method.
The compiler catches bugs, not runtime behavior.
Part 15: Comparing the Two Approaches
| Aspect | Trait Objects (State Pattern) | Type-State Pattern |
|---|---|---|
| Invalid operations | Handled at runtime (returns empty, does nothing) | Won't compile |
| Single collection | Yes: Vec<Box<dyn PostState>> possible |
No: different types can't mix |
| Adding new states | Easy: new struct, implement trait | New type + new methods |
| Flexibility | More flexible (runtime decisions) | More rigid but safer |
| When types change | Same variable can change state | Variable 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
| Concept | Traditional OOP | Rust's Approach |
|---|---|---|
| Encapsulation | Classes with private fields | Structs with private fields, pub keyword |
| Inheritance | Class extends another class | No struct inheritance; use default trait methods |
| Polymorphism | Base class references | Generics (static) or dyn Trait (dynamic) |
Trait Objects
| Syntax | What It Means |
|---|---|
dyn Trait |
A trait object (unknown concrete type) |
&dyn Trait |
Borrowed 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
| Situation | Use |
|---|---|
| Types known at compile time, need speed | Generics |
| Different types in one collection | Trait objects |
| Types determined at runtime | Trait objects |
| Want compiler to prevent invalid states | Type-state pattern |
| Complex state transitions at runtime | State 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: overridesspeakto print "{name} says woof!"Cat: uses the defaultspeak
// 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:
- Which trait is object-safe and why?
- Which trait is NOT object-safe and why?
- 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 statesis_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
}