| Contents |

20 Object oriented programming

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:

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


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:

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:

20.3.1 Code Reuse Strategies in Rust

Instead of class inheritance, Rust promotes code reuse through:

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:

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:

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.

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:

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

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.



| Contents |