While Rust is not a strictly object-oriented language in the same way as Java or C++, it has features that allow developers to use object-oriented programming (OOP) patterns. Rust’s approach favors safety and performance, leading to a unique take on traditional OOP concepts like encapsulation, inheritance, and polymorphism.
This article explores how Rust implements these core OOP principles.
Encapsulation: Bundling Data and Behavior
Encapsulation is the practice of bundling data with the methods that operate on that data, and restricting direct access to some of an object’s components.
Rust achieves this using structs or enums combined with impl blocks. By default, all fields and methods in Rust are private to the module they are defined in. You can expose fields or methods by marking them with the pub keyword.
This allows you to create a public API for your struct while keeping its internal implementation details private.
Example: A BankAccount Struct
pub struct BankAccount {
// This field is private. It can only be accessed by methods within this module.
balance: f64,
}
impl BankAccount {
// A public constructor.
pub fn new() -> BankAccount {
BankAccount { balance: 0.0 }
}
// A public method to deposit money.
pub fn deposit(&mut self, amount: f64) {
if amount > 0.0 {
self.balance += amount;
}
}
// A public method to withdraw money.
pub fn withdraw(&mut self, amount: f64) -> Result<(), &'static str> {
if amount > 0.0 && self.balance >= amount {
self.balance -= amount;
Ok(())
} else {
Err("Insufficient funds or invalid amount.")
}
}
// A public method to check the balance.
pub fn balance(&self) -> f64 {
self.balance
}
}
fn main() {
let mut account = BankAccount::new();
account.deposit(100.0);
// This would fail to compile because `balance` is private:
// account.balance = 1_000_000.0;
println!("Current balance: ${}", account.balance());
}
Inheritance: Composition with Traits
Many OOP languages use inheritance, where an object can inherit data and behavior from a parent object. Rust deliberately does not have this form of inheritance, primarily to avoid the complexities and problems it can introduce (like the “diamond problem” and deep, rigid class hierarchies).
Instead, Rust favors composition over inheritance through the use of traits. A trait defines a set of methods that a type must implement, allowing for shared behavior across different structs.
Example: A Drawable Trait
Instead of having a base UIComponent class that Button and TextField inherit from, we can define a Drawable trait.
// This trait defines a shared behavior: the ability to be drawn on a screen.
pub trait Drawable {
fn draw(&self);
}
// A Button struct that implements the Drawable trait.
pub struct Button {
pub label: String,
}
impl Drawable for Button {
fn draw(&self) {
println!("Drawing a button with label: '{}'", self.label);
}
}
// A TextField struct that also implements the Drawable trait.
pub struct TextField {
pub placeholder: String,
}
impl Drawable for TextField {
fn draw(&self) {
println!("Drawing a text field with placeholder: '{}'", self.placeholder);
}
}
Here, both Button and TextField can be drawn, but they don’t share any parent-child relationship. This is a more flexible and loosely-coupled way to share functionality.
Polymorphism: Dynamic Dispatch with Trait Objects
Polymorphism is the ability to use an object of one type as if it were another, often a more general type. In Rust, this is achieved using trait objects.
A trait object points to an instance of a type that implements a specific trait. We create a trait object by using a pointer (like & or Box) followed by the dyn keyword and the trait name (e.g., Box<dyn Drawable>). This allows us to create collections of different types that all share the same trait.
The specific method that gets called is determined at runtime, which is known as dynamic dispatch.
Example: A Screen with Different Components
Building on the previous example, we can create a Screen that holds a list of Drawable components.
// Re-using the Drawable trait and structs from the previous example.
pub struct Screen {
// `components` is a vector of trait objects. Each element can be any type
// that implements the `Drawable` trait.
pub components: Vec<Box<dyn Drawable>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
fn main() {
let screen = Screen {
components: vec![
Box::new(Button {
label: String::from("OK"),
}),
Box::new(TextField {
placeholder: String::from("Enter your name"),
}),
],
};
screen.run();
}
The Screen doesn’t need to know the concrete types of its components. It only cares that they implement the Drawable trait, allowing it to call the draw method on each one.
Conclusion
Rust provides powerful tools to implement object-oriented patterns in a way that aligns with its core principles of safety and performance.
- Encapsulation is achieved through
structs andimplblocks with public/private visibility. - Inheritance is replaced by a more flexible system of composition using traits.
- Polymorphism is enabled through trait objects (
dyn Trait), which allow for dynamic dispatch.
By understanding these concepts, you can write clean, maintainable, and idiomatic Rust code that leverages the best of OOP design.