Welcome to a comprehensive introduction to Object-Oriented Programming (OOP) concepts in Rust. While Rust is not a traditional OOP language like Java or C++, it provides powerful features that enable you to apply OOP principles effectively. This document explores how Rust approaches encapsulation, polymorphism, and code organization using structs, impl blocks, and traits. You'll learn to use both rustc (the Rust compiler) and Cargo (Rust's build system and package manager).
π¦ Rust's Philosophy on OOP
Rust takes a different approach to OOP than languages like Java or C++. Instead of class-based inheritance, Rust emphasizes:
- Composition over Inheritance: Building complex types by combining simpler ones
- Traits for Polymorphism: Defining shared behavior without traditional inheritance
- Ownership and Borrowing: Memory safety without garbage collection
- Zero-Cost Abstractions: High-level features with no runtime overhead
β What is Object-Oriented Programming?
Object-Oriented Programming is a paradigm that organizes code around "objects" that contain both data (variables) and functions (methods). Think of an object like a blueprint: a Drink struct defines what properties all drinks have and what operations can be performed on them.
The core principles of OOP are:
- Encapsulation: Bundling data and methods together, hiding internal details
- Inheritance: Creating new classes based on existing ones (Rust uses traits and composition instead)
- Polymorphism: Objects of different types responding to the same interface
- Abstraction: Showing only essential features while hiding complexity
π Language Feature Comparison Table
| Feature | Java | C++ | Rust |
|---|---|---|---|
| Type Definition | class ClassName |
class ClassName |
struct StructName |
| Constructor | ClassName() |
ClassName() |
fn new() -> Self (convention) |
| Destructor | Automatic (Garbage Collected) | ~ClassName() |
impl Drop trait (automatic) |
| Inheritance | extends |
: |
Traits + Composition (no class inheritance) |
| Access Modifiers | public, private, protected |
public, private, protected |
pub (public) or private (default) |
| Interfaces | interface keyword |
Pure virtual classes | trait keyword |
| Polymorphism | Virtual by default | Must use virtual |
Trait objects or generics |
| Memory Management | Automatic (Garbage Collection) | Manual (new/delete) |
Ownership system (compile-time) |
| Method Definition | Inside class body | Inside or outside class | impl StructName { } block |
| Self Reference | this |
this |
self, &self, &mut self |
| Constants | final keyword |
const keyword |
const keyword |
| Compilation | javac |
g++ |
rustc or cargo build |
βοΈ Example 1: Basic Struct Definition
Let's compare how to define a basic "class" in all three languages:
public class Drink {
final int MAXIMUM = 10;
private String name;
public Drink(String brandName) {
name = brandName;
}
public String getName() {
return name;
}
}
class Drink {
private:
static const int MAXIMUM = 10;
string name;
public:
Drink(string brandName) {
name = brandName;
}
string getName() {
return name;
}
};
const MAXIMUM: i32 = 10;
pub struct Drink {
name: String,
}
impl Drink {
pub fn new(brand_name: &str) -> Self {
Drink {
name: brand_name.to_string(),
}
}
pub fn get_name(&self) -> &str {
&self.name
}
}
π Key Observations:
- Java: Requires access modifiers on each method; uses
finalfor constants - C++: Uses separate
private:andpublic:sections; usesstatic constfor class constants - Rust: Uses
structfor data andimplblock for methods; fields are private by default; usespubfor public visibility; constants are defined withconstoutside the struct;Selfrefers to the implementing type
βοΈ Example 2: Constructor Patterns
Rust doesn't have special constructor syntaxβinstead, it uses associated functions (commonly named new):
public Drink(String name) {
this.name = name;
this.cost = 1.75;
}
public Drink(String name, double cost) {
this.name = name;
this.cost = cost;
}
Drink(string name) {
this->name = name;
this->cost = 1.75;
}
Drink(string name, double cost) {
this->name = name;
this->cost = cost;
}
impl Drink {
// Default cost constructor
pub fn new(name: &str) -> Self {
Drink {
name: name.to_string(),
cost: 1.75,
}
}
// Custom cost constructor
pub fn with_cost(name: &str, cost: f64) -> Self {
Drink {
name: name.to_string(),
cost,
}
}
}
π Key Observations:
- Java: Uses
this.variableto reference instance variables; supports constructor overloading - C++: Uses
this->variablepointer notation; supports constructor overloading - Rust: Uses different function names instead of overloading (e.g.,
newvswith_cost);Selfis an alias for the struct type; field shorthand (costinstead ofcost: cost) when variable name matches field name
βοΈ Example 3: Methods and Encapsulation
Here's how methods work in each language, using the vending machine example:
public void vend() {
if (available > 0) {
available -= 1;
consumed += 1;
moneyMade += cost;
System.out.println(name + " Purchased.");
}
}
public double profit() {
return moneyMade;
}
void vend() {
if (available > 0) {
available -= 1;
consumed += 1;
moneyMade += cost;
cout << name << " Purchased." << endl;
}
}
double profit() {
return moneyMade;
}
pub fn vend(&mut self) {
if self.available > 0 {
self.available -= 1;
self.consumed += 1;
self.money_made += self.cost;
println!("{} Purchased.", self.name);
}
}
pub fn profit(&self) -> f64 {
self.money_made
}
π Key Observations:
- Java: Uses
System.out.println()for output; implicitthis - C++: Uses
coutandendlfrom iostream; implicitthis - Rust: Uses
println!()macro for output;&mut selffor methods that modify the struct;&selffor read-only access; explicitself.prefix required; no semicolon on last expression returns that value
π¦ Understanding self in Rust
In Rust, the first parameter of a method determines how the method can access the struct:
| Parameter | Meaning | Use Case |
|---|---|---|
&self |
Immutable borrow | Read-only methods (getters) |
&mut self |
Mutable borrow | Methods that modify the struct |
self |
Takes ownership | Methods that consume the struct |
| (no self) | Associated function | Constructors, utility functions |
π§ The Complete Vending Machine Example
Now let's look at a complete, working example using Rust. This program implements a simple vending machine that tracks drink sales and profits. We'll show both the single-file approach (using rustc) and the Cargo project approach.
β οΈ Important: Rust's Module System
Rust organizes code into modules. In a single file, you can use mod blocks. For larger projects, you use separate files and Cargo.toml to manage dependencies and build configuration.
Method 1: Single File with rustc
For simple programs, you can put everything in one file and compile directly with rustc:
// machine.rs
// Complete Vending Machine implementation in Rust
const MAXIMUM_CAPACITY: i32 = 10;
const DEFAULT_DRINK_COST: f64 = 1.75;
pub struct Drink {
name: String,
available: i32,
consumed: i32,
cost: f64,
money_made: f64,
}
impl Drink {
// Constructor with default cost
pub fn new(brand_name: &str) -> Self {
Drink {
name: brand_name.to_string(),
available: 0,
consumed: 0,
cost: DEFAULT_DRINK_COST,
money_made: 0.0,
}
}
// Constructor with custom cost
pub fn with_cost(brand_name: &str, cost: f64) -> Self {
Drink {
name: brand_name.to_string(),
available: 0,
consumed: 0,
cost,
money_made: 0.0,
}
}
// Vend a drink
pub fn vend(&mut self) {
if self.available > 0 {
self.available -= 1;
self.consumed += 1;
self.money_made += self.cost;
println!("{} Purchased.", self.name);
} else {
println!();
println!(" SOLD OUT ");
println!();
}
}
// Refill the drink to maximum capacity
pub fn refill(&mut self) {
self.available = MAXIMUM_CAPACITY;
}
// Get the profit (rounded to 2 decimal places)
pub fn profit(&self) -> f64 {
(self.money_made * 100.0).round() / 100.0
}
// Get the number of drinks sold
pub fn drinks_sold(&self) -> i32 {
self.consumed
}
// Get the drink name
pub fn get_name(&self) -> &str {
&self.name
}
// Get the amount needed to restock
pub fn restock_amount(&self) -> i32 {
MAXIMUM_CAPACITY - self.available
}
}
fn main() {
// Create drinks using different constructors
let mut drink1 = Drink::new("Coke");
let mut drink2 = Drink::new("Sprite");
let mut drink3 = Drink::new("Doctor Pepper");
let mut drink4 = Drink::with_cost("V8 Splash", 4.25);
// Refill all drinks
drink1.refill();
drink2.refill();
drink3.refill();
drink4.refill();
// Purchase 8 Cokes
for _ in 0..8 {
drink1.vend();
}
// Purchase 6 Sprites
for _ in 0..6 {
drink2.vend();
}
// Purchase 2 Doctor Peppers
drink3.vend();
drink3.vend();
// Purchase 11 V8 Splash (will exceed capacity)
for _ in 0..11 {
drink4.vend();
}
// Calculate totals
let total_sold = drink1.drinks_sold() + drink2.drinks_sold()
+ drink3.drinks_sold() + drink4.drinks_sold();
let total_profit = drink1.profit() + drink2.profit()
+ drink3.profit() + drink4.profit();
// Display results
println!("A Total of {} drinks were sold for a profit of ${:.2}",
total_sold, total_profit);
println!();
println!("{} {} needed to restock machine",
drink1.restock_amount(), drink1.get_name());
println!("{} {} needed to restock machine",
drink2.restock_amount(), drink2.get_name());
println!("{} {} needed to restock machine",
drink3.restock_amount(), drink3.get_name());
println!("{} {} needed to restock machine",
drink4.restock_amount(), drink4.get_name());
}
Method 2: Cargo Project (Recommended)
For larger projects, use Cargo to manage your Rust code:
β The Recommended Approach
Create a Cargo project with cargo new vending_machine, then organize your code into a library module (src/lib.rs) and a binary (src/main.rs).
Project Structure:
vending_machine/
βββ Cargo.toml
βββ src/
βββ lib.rs (library with Drink struct)
βββ main.rs (main program)
Cargo.toml - Project Configuration
[package]
name = "vending_machine"
version = "0.1.0"
edition = "2021"
authors = ["Your Name"]
[dependencies]
src/lib.rs - The Library Module
// src/lib.rs
// Library module containing the Drink struct
pub const MAXIMUM_CAPACITY: i32 = 10;
pub const DEFAULT_DRINK_COST: f64 = 1.75;
pub struct Drink {
name: String,
available: i32,
consumed: i32,
cost: f64,
money_made: f64,
}
impl Drink {
/// Creates a new Drink with the default cost
pub fn new(brand_name: &str) -> Self {
Drink {
name: brand_name.to_string(),
available: 0,
consumed: 0,
cost: DEFAULT_DRINK_COST,
money_made: 0.0,
}
}
/// Creates a new Drink with a custom cost
pub fn with_cost(brand_name: &str, cost: f64) -> Self {
Drink {
name: brand_name.to_string(),
available: 0,
consumed: 0,
cost,
money_made: 0.0,
}
}
/// Vends a drink if available, otherwise displays "SOLD OUT"
pub fn vend(&mut self) {
if self.available > 0 {
self.available -= 1;
self.consumed += 1;
self.money_made += self.cost;
println!("{} Purchased.", self.name);
} else {
println!();
println!(" SOLD OUT ");
println!();
}
}
/// Refills the drink to maximum capacity
pub fn refill(&mut self) {
self.available = MAXIMUM_CAPACITY;
}
/// Returns the profit rounded to 2 decimal places
pub fn profit(&self) -> f64 {
(self.money_made * 100.0).round() / 100.0
}
/// Returns the number of drinks sold
pub fn drinks_sold(&self) -> i32 {
self.consumed
}
/// Returns the drink name
pub fn get_name(&self) -> &str {
&self.name
}
/// Returns the amount needed to restock
pub fn restock_amount(&self) -> i32 {
MAXIMUM_CAPACITY - self.available
}
}
src/main.rs - The Main Program
// src/main.rs
// Main driver program for the Vending Machine
use vending_machine::Drink;
fn main() {
// Create drinks using different constructors
let mut drink1 = Drink::new("Coke");
let mut drink2 = Drink::new("Sprite");
let mut drink3 = Drink::new("Doctor Pepper");
let mut drink4 = Drink::with_cost("V8 Splash", 4.25);
// Refill all drinks
drink1.refill();
drink2.refill();
drink3.refill();
drink4.refill();
// Purchase 8 Cokes
for _ in 0..8 {
drink1.vend();
}
// Purchase 6 Sprites
for _ in 0..6 {
drink2.vend();
}
// Purchase 2 Doctor Peppers
drink3.vend();
drink3.vend();
// Purchase 11 V8 Splash (will exceed capacity)
for _ in 0..11 {
drink4.vend();
}
// Calculate totals
let total_sold = drink1.drinks_sold() + drink2.drinks_sold()
+ drink3.drinks_sold() + drink4.drinks_sold();
let total_profit = drink1.profit() + drink2.profit()
+ drink3.profit() + drink4.profit();
// Display results
println!("A Total of {} drinks were sold for a profit of ${:.2}",
total_sold, total_profit);
println!();
println!("{} {} needed to restock machine",
drink1.restock_amount(), drink1.get_name());
println!("{} {} needed to restock machine",
drink2.restock_amount(), drink2.get_name());
println!("{} {} needed to restock machine",
drink3.restock_amount(), drink3.get_name());
println!("{} {} needed to restock machine",
drink4.restock_amount(), drink4.get_name());
}
β‘ Compilation and Execution Instructions
Option A: Using rustc (Single File)
- Create
machine.rswith the complete single-file code (shown above) - Open a terminal and navigate to the directory containing the file
- Compile the program using rustc:
rustc machine.rs - Run the program:
(On Windows:./machinemachine.exe)
Option B: Using Cargo (Recommended)
- Create a new Cargo project:
cargo new vending_machine cd vending_machine - Replace
src/main.rswith the main program code - Create
src/lib.rswith the library module code - Build the project:
cargo build - Run the program:
cargo run
π‘ Cargo Commands Quick Reference
| Command | Description |
|---|---|
cargo new project_name |
Create a new project |
cargo build |
Compile the project (debug mode) |
cargo build --release |
Compile with optimizations |
cargo run |
Build and run the project |
cargo check |
Check for errors without building |
cargo test |
Run tests |
cargo doc --open |
Generate and view documentation |
Sample Output:
π Understanding the Code
Struct and impl Block Organization
In Rust, you separate data (struct) from behavior (impl block):
| Component | Purpose | Example |
|---|---|---|
struct Drink { } |
Defines the data fields | name, available, cost, etc. |
impl Drink { } |
Defines methods and associated functions | new(), vend(), profit(), etc. |
const |
Compile-time constants | MAXIMUM_CAPACITY, DEFAULT_DRINK_COST |
Visibility and Encapsulation
Rust provides strong encapsulation through its visibility system:
- Private by Default: All struct fields and functions are private unless marked
pub - Module Boundaries: Privacy is enforced at the module level
- Public Interface: Only
pubitems can be accessed from outside the module - Getter Methods: Provide controlled access to private fields
β This is encapsulation in Rust
The struct fields (name, available, money_made, etc.) are private. Users interact through public methods like vend(), refill(), and profit().
Ownership and Borrowing
Rust's unique ownership system ensures memory safety:
| Concept | Description | Example |
|---|---|---|
| Ownership | Each value has exactly one owner | let drink = Drink::new("Coke"); |
| Immutable Borrow | Read-only reference | &self in methods |
| Mutable Borrow | Read-write reference | &mut self in methods |
| Move | Transfer ownership | self (takes ownership) |
β Common Mistakes and Solutions
Mistake 1: Forgetting mut for Mutable Variables
let drink = Drink::new("Coke");
drink.vend(); // ERROR: cannot borrow `drink` as mutable
let mut drink = Drink::new("Coke");
drink.vend(); // Now we can call methods that take &mut self
Mistake 2: Wrong Self Parameter
impl Drink {
pub fn vend(&self) { // ERROR: cannot modify with &self
self.available -= 1;
}
}
impl Drink {
pub fn vend(&mut self) { // Use &mut self to modify
self.available -= 1;
}
}
Mistake 3: Missing self. Prefix
impl Drink {
pub fn profit(&self) -> f64 {
money_made // ERROR: cannot find value `money_made`
}
}
impl Drink {
pub fn profit(&self) -> f64 {
self.money_made // Access field through self
}
}
Mistake 4: Returning References to Owned Data
pub fn get_name(&self) -> String {
self.name // ERROR: cannot move out of borrowed content
}
pub fn get_name(&self) -> &str {
&self.name // Return a reference (borrow)
}
pub fn get_name(&self) -> String {
self.name.clone() // Return a copy
}
Mistake 5: Forgetting the Semicolon (or Adding One When Returning)
pub fn profit(&self) -> f64 {
self.money_made; // ERROR: returns () because of semicolon
}
pub fn profit(&self) -> f64 {
self.money_made // No semicolon - expression is returned
}
Mistake 6: Using :: vs . Incorrectly
let drink = Drink.new("Coke"); // ERROR: expected `::`
drink::vend(); // ERROR: expected `.`
let mut drink = Drink::new("Coke"); // :: for associated functions
drink.vend(); // . for methods on instances
Mistake 7: String Type Confusion
pub fn new(brand_name: String) -> Self { // Forces caller to allocate
// ...
}
pub fn new(brand_name: &str) -> Self { // Accepts &str or &String
Drink {
name: brand_name.to_string(), // Convert to owned String
// ...
}
}
Mistake 8: Forgetting use Statement
fn main() {
let drink = Drink::new("Coke"); // ERROR: cannot find type `Drink`
}
use vending_machine::Drink; // Import from lib.rs
fn main() {
let drink = Drink::new("Coke"); // Now Drink is in scope
}
βοΈ Your Programming Exercise
Objective:
Modify the vending machine program to replace the V8 Splash drink with a new beverage of your choice.
Assignment:
The current program includes V8 Splash at $4.25 as the fourth drink. Your task is to:
- Replace V8 Splash: Change the drink name to something different (e.g., "Orange Juice", "Apple Cider", "Iced Tea", "Lemonade", etc.) and choose a new price (different from $4.25)
- Modify the vending operations: Change how many times each drink is purchased by modifying the loop ranges
- Update the output display: Ensure all the drink names and restock amounts are printed correctly
- Test your program: Compile with
rustcorcargo runand ensure it works properly
Specific Changes to Make:
- In
main.rs(ormachine.rs), change the line:
to your new drink with a new price, for example:let mut drink4 = Drink::with_cost("V8 Splash", 4.25);let mut drink4 = Drink::with_cost("Orange Juice", 3.50); - Keep all four drinks in the program
- Keep all the vend() calls (you can change the loop ranges)
- Keep the final output section that displays totals and restocking information
Testing Your Solution:
Using rustc:
- Make your changes to
machine.rs - Compile:
rustc machine.rs - Run:
./machine
Using Cargo:
- Make your changes to
src/main.rs - Run:
cargo run
π― Challenge:
Before running your program, calculate what the total profit should be. Does your output match your prediction?
π¦ Bonus Challenge:
Add a new method is_sold_out(&self) -> bool to the Drink struct that returns true if available == 0. Use this method in main to print which drinks are sold out after all purchases.
β Key Takeaways About Object-Oriented Programming in Rust
- Rust uses
structfor data andimplblocks for methods - There is no class inheritanceβuse traits and composition instead
- Constructors are just associated functions (conventionally named
new) - Fields are private by default; use
pubfor public visibility - Use
&selffor read-only methods,&mut selffor mutating methods - Associated functions (no
self) are called withStruct::function() - Methods are called with
instance.method() - Variables must be declared
mutto be mutable - The last expression without a semicolon is the return value
- Use
&strfor string parameters,Stringfor owned strings - Use
println!()macro for output with{}placeholders - Use
rustc file.rsfor single files,cargo build/runfor projects - Cargo projects use
src/lib.rsfor libraries andsrc/main.rsfor binaries - Import items with
use crate_name::ItemName; - Rust's ownership system provides memory safety without garbage collection
π Language Strengths Summary
| Language | Strengths | Best For |
|---|---|---|
| Java | Write once, run anywhere; Strong ecosystem; Great for large teams | Enterprise applications, web backends, Android apps |
| C++ | High performance; Maximum control; Close to hardware | System software, games, performance-critical apps |
| Rust | Memory safety without GC; Zero-cost abstractions; Fearless concurrency; Modern tooling | Systems programming, WebAssembly, CLI tools, embedded systems, safety-critical software |
π¦ Why Choose Rust for OOP-Style Programming?
- Memory Safety: No null pointer exceptions, no data races, no use-after-freeβguaranteed at compile time
- Performance: Zero-cost abstractions mean you don't pay for what you don't use
- Modern Tooling: Cargo provides excellent package management, building, testing, and documentation
- Expressive Type System: Enums, pattern matching, and traits enable powerful abstractions
- Helpful Compiler: Rust's error messages guide you toward correct code
- Growing Ecosystem: Active community with libraries for web, CLI, embedded, and more