Object-Oriented Programming (OOP) is a paradigm central to languages like C++ and Java, often characterized by features such as classes, inheritance, and virtual methods. For C programmers, C++ introduces these concepts on top of C’s procedural foundation. OOP aims to structure software around objects that bundle data and behavior (encapsulation), allow types to inherit properties from others (inheritance), and enable interaction with different types through a common interface (polymorphism).
Rust supports the core goals of OOP, including encapsulation and polymorphism, but it achieves them differently. Rust deliberately omits class-based implementation inheritance, a cornerstone of traditional OOP. Instead, it leverages a combination of features: data structures (structs and enums) with associated methods (impl blocks), traits for defining shared behavior (interfaces), generics for compile-time polymorphism, its module system for encapsulation, and a preference for composition over inheritance. This chapter explores how Rust provides OOP-like capabilities using its distinct approach.
While C is primarily procedural, C++ incorporates OOP principles extensively. Rooted in languages like Simula and Smalltalk, OOP structures programs around objects, which encapsulate data (fields or members) and the procedures that operate on that data (methods). The primary motivations behind OOP include:
The three pillars commonly associated with traditional OOP (especially in C++) are:
public, protected, and private access specifiers. This prevents direct external manipulation of internal data, helping maintain invariants.Despite its prevalence, class-based OOP, particularly implementation inheritance, has faced criticisms that influenced Rust’s design:
Rust’s designers opted for alternative mechanisms—primarily composition, traits, and generics—aiming to provide the benefits of OOP (like code reuse and polymorphism) while mitigating these drawbacks.
Rust does not have a class keyword or implementation inheritance as found in C++. Instead, it provides orthogonal features that combine to offer similar capabilities:
impl Blocks: Associate methods (behavior) with structs and enums, separating data definition from implementation.pub, providing strong encapsulation boundaries at the module level, rather than the class level.Instead of class inheritance, Rust promotes code reuse through:
These mechanisms offer flexibility without the tight coupling often associated with inheritance hierarchies.
Rust achieves runtime polymorphism, similar to C++ virtual functions, through trait objects. This allows code to operate on values of different concrete types that implement the same trait, without knowing the specific type until runtime.
dyn TraitTrait objects are referenced using the dyn keyword followed by the trait name (e.g., dyn Drawable). Because the size of the concrete type underlying a trait object isn’t known at compile time, trait objects must always be used behind a pointer, such as:
&dyn Trait: A shared reference to a trait object.&mut dyn Trait: A mutable reference to a trait object.Box<dyn Trait>: An owned, heap-allocated trait object (similar to std::unique_ptr<Base> in C++).Rc<dyn Trait> or Arc<dyn Trait> (for shared ownership).Example using a reference:
trait Speaker {
fn speak(&self);
}
struct Dog;
impl Speaker for Dog {
fn speak(&self) { println!("Woof!"); }
}
struct Human;
impl Speaker for Human {
fn speak(&self) { println!("Hello!"); }
}
// Function accepts any type implementing Speaker via a shared reference
fn make_speak(speaker: &dyn Speaker) {
speaker.speak(); // Runtime dispatch: calls the correct implementation
}
fn main() {
let dog = Dog;
let person = Human;
make_speak(&dog); // Calls Dog::speak
make_speak(&person); // Calls Human::speak
}Example using Box for owned objects:
trait Speaker {
fn speak(&self);
}
struct Cat;
impl Speaker for Cat {
fn speak(&self) { println!("Meow!"); }
}
fn main() {
// Create a heap-allocated Cat, accessed via a trait object pointer
let animal: Box<dyn Speaker> = Box::new(Cat);
animal.speak(); // Runtime dispatch
}A trait object pointer (like &dyn Speaker or Box<dyn Speaker>) is a fat pointer. It contains two pieces of information:
Dog or Cat struct).Dog’s implementation of Speaker).The vtable is essentially an array of function pointers, one for each method in the trait, pointing to the concrete type’s implementation of those methods. When a method like speaker.speak() is called via a trait object, the program:
speak method within that vtable.self argument.This lookup and indirect call happen at runtime, enabling dynamic dispatch.
Trait objects allow storing different types that implement the same trait within a single collection, a common OOP pattern.
trait Drawable {
fn draw(&self);
}
struct Circle { radius: f64 }
impl Drawable for Circle {
fn draw(&self) { println!("Drawing a circle with radius {}", self.radius); }
}
struct Square { side: f64 }
impl Drawable for Square {
fn draw(&self) { println!("Drawing a square with side {}", self.side); }
}
fn main() {
// A vector holding different shapes, all implementing Drawable
let shapes: Vec<Box<dyn Drawable>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Square { side: 2.0 }),
Box::new(Circle { radius: 3.0 }),
];
// Iterate and call the draw method via dynamic dispatch
for shape in shapes {
shape.draw();
}
}Comparison with C++:
This Rust pattern closely mirrors using base class pointers and virtual functions in C++:
#include <iostream>
#include <vector>
#include <memory> // For std::unique_ptr
// Abstract base class (like a trait)
class Drawable {
public:
virtual ~Drawable() = default; // Essential virtual destructor
virtual void draw() const = 0; // Pure virtual function (interface)
};
// Derived class (like a struct implementing the trait)
class Circle : public Drawable {
double radius;
public:
Circle(double r) : radius(r) {}
void draw() const override {
std::cout << "Drawing a circle with radius " << radius << std::endl;
}
};
// Another derived class
class Square : public Drawable {
double side;
public:
Square(double s) : side(s) {}
void draw() const override {
std::cout << "Drawing a square with side " << side << std::endl;
}
};
int main() {
// Vector holding smart pointers to the base class
std::vector<std::unique_ptr<Drawable>> shapes;
shapes.push_back(std::make_unique<Circle>(1.0));
shapes.push_back(std::make_unique<Square>(2.0));
shapes.push_back(std::make_unique<Circle>(3.0));
// Iterate and call the virtual method
for (const auto& shape : shapes) {
shape->draw(); // Dynamic dispatch via vtable
}
return 0;
}Both achieve runtime polymorphism, allowing different types conforming to a common interface to be handled uniformly. Rust uses traits and dyn Trait, while C++ uses inheritance and virtual.
Not all traits can be made into trait objects. A trait must be object-safe. The key rules ensuring object safety are:
self, &self, or &mut self) as their first parameter, or be explicitly callable without requiring Self (e.g., using where Self: Sized).Self Return Type: Methods cannot return the concrete type Self.These rules ensure that the compiler can construct a valid vtable. For example, a method returning Self cannot be called through a trait object because the concrete type Self is unknown at runtime. Similarly, generic methods would require different vtable entries for each potential type substitution, which is not supported by the trait object mechanism.
Many common traits like std::fmt::Debug, std::fmt::Display, and custom traits defining behavior are object-safe. A notable example of a non-object-safe trait is Clone, because its clone method returns Self. If you need to clone trait objects, you typically define a separate clone_box method within the trait that returns Box<dyn YourTrait>.
Trait objects provide runtime flexibility, but this comes at a cost compared to Rust’s compile-time polymorphism using generics:
dyn Trait boundary. Generics allow the compiler to create specialized versions of the code for each concrete type, enabling more aggressive optimizations.&dyn Trait). The interaction is limited to the methods defined by the trait itself.Due to these performance implications, Rust culture often favors generics (compile-time polymorphism) when the set of types is known at compile time or when performance is critical. Trait objects are used when runtime flexibility or heterogeneous collections are explicitly required.
When dealing with a collection of related but distinct types that share common behavior, Rust offers two primary approaches: trait objects and enums.
Use Trait Objects (dyn Trait) when:
Use Enums when:
enum definition. Adding a new type requires modifying the enum definition.match statements require handling all enum variants, preventing errors from unhandled cases.match) can be more efficient than trait object vtable lookups, potentially allowing the compiler to optimize the dispatch (e.g., using jump tables).match arms.Guideline: If you can enumerate all possible types upfront and don’t need external extensibility, an enum is often simpler, safer (due to match exhaustiveness), and potentially faster. If you need the flexibility to add new types later without changing existing code, trait objects are the appropriate tool.
In C++, encapsulation relies on public, protected, and private specifiers within class definitions. Rust achieves encapsulation primarily at the module level using its visibility rules:
pub): The pub keyword makes an item visible outside its defining module. Visibility can be restricted further (e.g., pub(crate), pub(super)), but pub typically means public to any code that can access the module.struct is declared pub, its fields remain private by default. Each field must be individually marked pub to be accessible from outside the module. This allows structs to maintain internal invariants by controlling access through public methods defined in an impl block.This module-based system provides strong encapsulation boundaries, allowing library authors to clearly define a public API while hiding implementation details.
Example: Encapsulated Averaging Collection
mod math_utils {
// The struct is public.
pub struct AverageCollection {
// Fields are private, enforcing use of methods.
elements: Vec<i32>,
sum: i64, // Use i64 to avoid overflow on sum
}
impl AverageCollection {
// Public constructor-like associated function.
pub fn new() -> Self {
AverageCollection {
elements: Vec::new(),
sum: 0,
}
}
// Public method to add an element.
pub fn add(&mut self, value: i32) {
self.elements.push(value);
self.sum += value as i64;
}
// Public method to calculate the average.
// Returns None if the collection is empty.
pub fn average(&self) -> Option<f64> {
if self.elements.is_empty() {
None
} else {
Some(self.sum as f64 / self.elements.len() as f64)
}
}
// An internal helper method (private by default).
#[allow(dead_code)] // Prevent warning for unused private method
fn clear_cache(&mut self) {
// Potential internal logic irrelevant to the public API
}
}
}
fn main() {
let mut collection = math_utils::AverageCollection::new();
collection.add(10);
collection.add(20);
collection.add(30);
println!("Average: {:?}", collection.average()); // Output: Average: Some(20.0)
// These would fail to compile because fields are private:
// let _ = collection.elements;
// collection.sum = 0;
// This would fail as the method is private:
// collection.clear_cache();
}Users of AverageCollection interact solely through new, add, and average. The internal storage (elements, sum) and any private helper methods (clear_cache) are implementation details hidden within the math_utils module, ensuring the collection’s integrity.
While trait objects provide runtime polymorphism, Rust’s idiomatic approach for polymorphism, when possible, is through generics and traits, enabling compile-time polymorphism.
Generic code is written using type parameters constrained by traits (e.g., <T: Display>). The Rust compiler performs monomorphization: it generates specialized versions of the generic code for each concrete type used at the call sites.
Example: Generic Max Function
use std::cmp::PartialOrd;
use std::fmt::Display;
// Works for any type T that supports partial ordering and can be displayed.
fn print_larger<T: PartialOrd + Display>(a: T, b: T) {
let larger = if a > b { a } else { b };
println!("The larger value is: {}", larger);
}
fn main() {
print_larger(5, 10); // Works with i32
print_larger(3.14, 2.71); // Works with f64
print_larger("apple", "banana"); // Works with &str
}During compilation, specialized versions like print_larger_i32, print_larger_f64, and print_larger_str are effectively created. Method calls within these specialized functions are direct or potentially inlined, avoiding the runtime overhead of vtable lookups associated with trait objects. This leads to highly efficient code, equivalent to manually specialized code.
Serializing (saving) and deserializing (loading) data structures is a common requirement. However, directly serializing Rust trait objects (e.g., Box<dyn MyTrait>) using popular libraries like Serde is generally not straightforward or directly supported.
The fundamental issue is that a trait object is inherently tied to runtime information (the vtable pointer) which identifies the concrete type’s method implementations. This runtime information cannot be reliably serialized and deserialized. When deserializing raw data, there’s no inherent information to reconstruct the correct vtable pointer or even determine which concrete type the data represents.
Common strategies to handle serialization with polymorphic types include:
typetag can help automate this process for types implementing a specific trait.There is no built-in, transparent mechanism in Rust to serialize and deserialize arbitrary Box<dyn Trait> instances directly. Careful design is required at the serialization layer.
Rust offers powerful features to achieve the goals traditionally associated with Object-Oriented Programming—encapsulation, polymorphism, and code reuse—but employs a different set of tools compared to class-based languages like C++:
pub), controlling access primarily at the module boundary. Struct fields are private by default, promoting controlled access through methods.dyn Trait). Uses fat pointers and vtables, conceptually similar to C++ virtual functions, suitable for scenarios requiring runtime flexibility or heterogeneous collections.class Derived : public Base), no protected visibility, encapsulation is module-based, strong preference for composition and compile-time polymorphism.By combining structs, enums, impl blocks, traits, generics, and modules, Rust provides a flexible and safe system for building abstractions and managing complexity, aiming to avoid some common pitfalls of classical inheritance while retaining the core benefits of object-oriented design principles.