Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 20: Object-Oriented Programming in Rust

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.


20.1 A Brief Overview of Traditional OOP

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:

  • Managing Complexity: Decomposing large systems into smaller, self-contained objects that model conceptual entities.
  • Code Reuse: Extending existing code, often through inheritance, where new classes (derived/subclasses) acquire properties and behaviors from existing ones (base/superclasses).
  • Intuitive Modeling: Designing software based on object interactions.

The three pillars commonly associated with traditional OOP (especially in C++) are:

  • Encapsulation: Bundling data and methods within an object and controlling access to the internal state. C++ uses public, protected, and private access specifiers. This prevents direct external manipulation of internal data, helping maintain invariants.
  • Inheritance: Allowing a new class to inherit members (data and methods) from an existing class, establishing an “is-a” relationship. This promotes code reuse but can create strong coupling.
  • Polymorphism: Enabling objects of different derived classes to be treated uniformly through a common base class interface, typically via base class pointers or references and virtual function calls in C++. This allows for flexible and extensible systems.

20.2 Criticisms of Traditional OOP and Rust’s Rationale

Despite its prevalence, class-based OOP, particularly implementation inheritance, has faced criticisms that influenced Rust’s design:

  • Rigid Hierarchies and Coupling: Deep inheritance chains can tightly couple classes. Changes in a base class can unexpectedly affect derived classes (the “fragile base class” problem).
  • The “God Object” Problem: Overuse of inheritance can lead to complex, monolithic base classes.
  • Multiple Inheritance Issues: Languages allowing inheritance from multiple base classes (like C++) face complexities like the “diamond problem,” requiring careful resolution strategies.
  • Runtime Overhead: Polymorphism via virtual functions (common in C++) involves runtime dispatch (typically via vtables), incurring a performance cost compared to direct function calls.
  • State Management Complexity: Understanding and managing state spread across multiple layers of an inheritance hierarchy can be challenging.

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.


20.3 Rust’s Approach: Traits, Composition, and Encapsulation

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:

  • Structs and Enums: Define custom data types. They hold data.
  • impl Blocks: Associate methods (behavior) with structs and enums, separating data definition from implementation.
  • Traits: Define shared functionality, analogous to interfaces in other languages or abstract base classes with pure virtual functions in C++. They specify method signatures that types must implement to conform to the trait. Traits enable polymorphism. They can also provide default method implementations.
  • Modules and Visibility: Control the visibility of types, functions, methods, and fields. Items are private by default unless marked pub, providing strong encapsulation boundaries at the module level, rather than the class level.
  • Composition: Build complex types by including instances of other types as fields. Functionality is gained by having another type, rather than being another type (inheritance). Rust strongly encourages composition over inheritance.

20.3.1 Code Reuse Strategies in Rust

Instead of class inheritance, Rust promotes code reuse through:

  • Traits with Default Methods: Define shared behavior once within a trait’s default implementation. Any type implementing the trait automatically gets this behavior, which can optionally be overridden.
  • Generics: Write functions, structs, enums, and methods that operate on abstract types constrained by traits. The compiler generates specialized code for each concrete type used (monomorphization), achieving compile-time polymorphism and code reuse without runtime overhead.
  • Composition: Include instances of other types within a struct to delegate functionality or reuse data structures.
  • Shared Functions: Group related utility functions within modules for reuse across the codebase (similar to free functions in C++ namespaces).

These mechanisms offer flexibility without the tight coupling often associated with inheritance hierarchies.


20.4 Trait Objects: Runtime Polymorphism

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.

20.4.1 Syntax and Usage: dyn Trait

Trait 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++).
  • Other pointer types like 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
}

20.4.2 Internal Mechanism: Fat Pointers and Vtables

A trait object pointer (like &dyn Speaker or Box<dyn Speaker>) is a fat pointer. It contains two pieces of information:

  1. A pointer to the instance’s data (e.g., the memory holding the Dog or Cat struct).
  2. A pointer to a virtual table (vtable) specific to the combination of the trait and the concrete type (e.g., the vtable for 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:

  1. Follows the vtable pointer in the fat pointer to find the vtable.
  2. Looks up the appropriate function pointer for the speak method within that vtable.
  3. Calls the function using that pointer, passing the data pointer as the self argument.

This lookup and indirect call happen at runtime, enabling dynamic dispatch.

Example: Heterogeneous Collection

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.

20.4.3 Object Safety

Not all traits can be made into trait objects. A trait must be object-safe. The key rules ensuring object safety are:

  1. Receiver Type: All methods must have a receiver (self, &self, or &mut self) as their first parameter, or be explicitly callable without requiring Self (e.g., using where Self: Sized).
  2. No Self Return Type: Methods cannot return the concrete type Self.
  3. No Generic Parameters: Methods cannot have generic type parameters.

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>.


20.5 Trade-offs: Trait Objects vs. Generics

Trait objects provide runtime flexibility, but this comes at a cost compared to Rust’s compile-time polymorphism using generics:

  • Runtime Performance Cost: Method calls via trait objects involve pointer indirection and a vtable lookup, which is generally slower than a direct function call or an inlined call generated through generic monomorphization. This can also impact CPU cache efficiency.
  • Limited Compiler Optimizations: Because the concrete type and the specific method implementation are unknown until runtime, the compiler cannot perform optimizations like inlining across the dyn Trait boundary. Generics allow the compiler to create specialized versions of the code for each concrete type, enabling more aggressive optimizations.
  • No Direct Field Access: You cannot access the fields of the underlying concrete type directly through a trait object reference (&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.


20.6 Choosing Between Trait Objects and Enums

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:

    • You need an open set of types: New types implementing the trait can be added later, even in downstream crates, without modifying the original code that uses the trait object. This is essential for extensibility (e.g., plugin systems).
    • The exact types involved are determined at runtime.
    • You need to store truly heterogeneous types (that only share the trait) in a collection.
  • Use Enums when:

    • You have a closed set of types: All possible variants are known at compile time and defined within the enum definition. Adding a new type requires modifying the enum definition.
    • You want compile-time exhaustiveness checking: match statements require handling all enum variants, preventing errors from unhandled cases.
    • Performance is a higher priority: Dispatching behavior based on enum variants (often via match) can be more efficient than trait object vtable lookups, potentially allowing the compiler to optimize the dispatch (e.g., using jump tables).
    • You need to access variant-specific data easily within 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.


20.7 Encapsulation via Modules and Visibility

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:

  • Private by Default: Items (structs, enums, functions, methods, constants, modules, fields) are private to the module they are defined in. They cannot be accessed from outside the module, including parent or child modules, unless explicitly made public.
  • Public Interface (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 Field Privacy: Even if a 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.


20.8 Generics: Compile-Time Polymorphism

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.


20.9 Serialization and Trait Objects

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:

  1. Using Enums: If working with a closed set of types, define an enum where each variant wraps one of the possible concrete types. Enums can typically be serialized easily with Serde, assuming the contained types are serializable. This is often the simplest solution when applicable.
  2. Type Tagging and Manual Dispatch: Store an explicit type identifier (e.g., a string name or an enum discriminant) alongside the serialized data for the object. During deserialization, read the identifier first, then use it to determine which concrete type to deserialize the remaining data into. Libraries like typetag can help automate this process for types implementing a specific trait.
  3. Avoiding Trait Objects at Serialization Boundaries: Convert trait objects into a serializable representation (perhaps a concrete enum or a struct with a type tag) before serialization. Upon deserialization, reconstruct the trait objects if needed for runtime logic.

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.


20.10 Summary

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++:

  • Encapsulation: Achieved via modules and the visibility system (pub), controlling access primarily at the module boundary. Struct fields are private by default, promoting controlled access through methods.
  • Code Reuse: Favors composition over inheritance. Reuse is also facilitated by generics and traits with default method implementations.
  • Polymorphism:
    • Compile-time Polymorphism (Static Dispatch): The preferred approach in Rust, achieved through generics and trait bounds. Monomorphization yields high performance comparable to non-polymorphic code.
    • Runtime Polymorphism (Dynamic Dispatch): Enabled by trait objects (dyn Trait). Uses fat pointers and vtables, conceptually similar to C++ virtual functions, suitable for scenarios requiring runtime flexibility or heterogeneous collections.
  • Alternatives: Enums provide a robust alternative for handling closed sets of related types, offering compile-time exhaustiveness checks and often better performance than trait objects.
  • Key Differences from C++ OOP: No implementation inheritance (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.