Developing with Rust

How Rust Mimics Object-Oriented Design

By David Post

For many of us, object-oriented programming (OOP) has been a staple of our coding journey—it's a paradigm built around organizing and representing real-world objects. But Rust, which stresses performance and concurrency, and enforces memory safety, takes a different approach. 

As a procedural language, it's more akin to C, lacking the built-in object-oriented structure we're used to. However, regardless of the programming paradigm, the core of any language is how data flows through the program. In this blog, I’ll explore how Rust, while not natively object-oriented, can still achieve similar structures to languages like C++ through its own unique mechanisms. Despite its strict design, Rust’s features offer a powerful illusion of OOP, allowing developers to model objects and behaviors in creative ways.

Basic Structure

In Rust, the core building blocks are structs and enums. Structs are used for storing plain data and are similar to structs in C/C++. Enums, however, are more powerful and could easily be the subject of an article on their differences between C++ and Rust.

Implementation 1: Methods for Data Structs

Let’s take a simple OOP example of animals and create a couple of structures to hold their data.

struct Cat {
   name: String,
   lives: f64,
}
struct Dog {
   name: String,
   bones: u32,
}

One major difference in how methods work with data structs is the organization of the code. In C++, methods are tightly coupled with the data—functions like constructors, setters, getters, and operator overloads are defined directly within a class or struct. 

Rust takes a different approach. Methods are defined separately from the data in a section called impl, which stands for "implementation." This helps keep the data protected and neatly organized.

For example, in C++ you’d typically use a constructor to initialize data. In Rust, however, you use impl to define functions, such as new(), that instantiate and initialize a struct. It’s similar to a constructor but the distinction is mainly in the semantics. You create a mutable variable, initialize the struct’s members, and return it.

impl Cat {
   // Like a constructor, by convention it is called new
   fn new(name: String, lives: f64) -> Cat {
       Cat { name, lives }
   }
   fn purr(&self) {
       println!("purrrrr");
   }
}
impl Dog {
   // Same as cat, but instead we call it create_dog, breaking convention
   fn create_dog(name: String, bones: u32) -> Dog {
       Dog { name, bones }
   }
   fn bark(&self) {
       println!("woof");
   }
}

We see that the name new() isn’t special, or required, but is typically used by convention.

Since these are two different structs we have no requirement that the functions be the same.

Traits

In Rust, traits are a way to define behaviors that can be applied to data types. This is somewhat similar to how C++ uses classes and inheritance to add functionality to objects. However, in Rust, traits are a set of methods that must be implemented for a behavior to exist. The key difference is that traits define behavior independently of the data itself. Think of traits like interfaces in C++, or base classes that define shared functionality but don’t hold data.

For our animal structs we’ll create a trait which holds common behavior.

trait AnimalSounds {
   fn make_sound(&self);
   fn make_happy_sound(&self);
   fn make_angry_sound(&self);
   fn speak(&self);
}

Implementation 2: Implementing Traits

The second use of impl is for implementing the functionality defined by a trait. When you implement a trait for a struct, you're telling the compiler that this data type will support the behavior described by the trait. The compiler then ensures that all required methods are correctly implemented.

Here is where it begins to look a little more like OOPs. We implement the trait in terms of the data structure.

impl AnimalSounds for Cat {
   fn make_sound(&self) {
       println!("meow");
   }
   fn make_happy_sound(&self) {
       self.purr();
   }
   fn make_angry_sound(&self) {
       println!("hiss");
   }
   fn speak(&self) {
       println!(
           "I am a cat, my name is {}, and I have {} lives left.",
           self.name, self.lives
       );
   }
}

And again for dogs, which had different functions available to it through Dog’s implementation, we implement the AnimalSounds trait so both now have a common interface.

impl AnimalSounds for Dog {
   fn make_sound(&self) {
       self.bark();
   }
   fn make_happy_sound(&self) {
       print!("woof ");
       self.bark();
   }
   fn make_angry_sound(&self) {
       println!("growl");
   }
   fn speak(&self) {
       println!(
           "I am a dog, my name is {}, and I have {} bones.",
           self.name, self.bones
       );
   }
}

Here we have showcased that each one can have implementations which are included in the structs implementation itself, and that the trait creates an adapter pattern, mapping the trait function calls to the struct implementation, or implementing them directly.

Boxing it All Up

The last piece is how to dynamically call one or the other without knowing which one is going to be called.

fn main() {
   let pets: [Box<dyn AnimalSounds>; 2] = [
       Box::new(Cat::new("Mittens".to_string(), 7.5)),
       Box::new(Dog::create_dog("Sarge".to_string(), 100)),
   ];
   for pet in pets.iter() {
       pet.make_sound();
       pet.make_happy_sound();
       pet.make_angry_sound();
       pet.speak();
   }
}

Boxes are essentially a fat pointer that points to the item inside the box, and to the vtable of methods associated with it, thus allowing for dynamic dispatching. In this way Rust maintains the array as a known size no matter what size the underlying data is.

Rust Bridges the Gap Between OOP and Procedural Design

While Rust doesn’t follow the traditional object-oriented paradigm like C++ and other languages, it offers powerful mechanisms that allow us to achieve similar structures and behaviors. By leveraging structs, enums, impl blocks, and traits, we can model data and encapsulate functionality in a way that feels familiar to object-oriented developers.

Here’s the bottom line: With its emphasis on safety and control over data, Rust offers a fresh perspective on how to build maintainable and scalable systems without the need for built-in object-oriented features. It is an excellent choice for developers who want the flexibility of procedural programming with the power of OOP-like patterns.

Read more on Rust.