OOP features of rust

Characteristics of OOP

Encapsulation that hides implementation details

pub struct AveragedCollection {
    // no pub keyword => fields remain private
    list: Vec<i32>,
    average: f64,
}
 
// methods are the only way to access or modify data
impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }
 
    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }
 
    pub fn average(&self) -> f64 {
        self.average
    }
 
    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}

Inheritance as a type system and as code sharing

There is no way in Rust to define a struct that inherits the parent struct’s fields and method implementations.

You choose inheritance for two main reasons:

  • reuse of code
    • you can share Rust code using default trait method implementations instead
  • polymorphism

Inheritance has recently fallen out of favor because it’s often at risk of sharing more code than necessary.

Rust takes a different approach, using trait objects instead of inheritance.

Using traits

Defining a trait for common behavior

In Rust, we refrain from calling structs and enums “objects” to distinguish them from other languages’ objects. In a struct or enum, the data in the struct fields and the behavior in impl blocks are separated, whereas in other languages, the data and behavior combined into one concept is often labeled an object. However, trait objects are more like objects in other languages in the sens that they combine data and behavior. But trait objects differ from traditional objects in that we can’t add data to a trait object. Trait objects aren’t as generally useful as objects in other languages: their specific purpose is to allow abstraction across common behavior.

pub trait Draw {
    fn draw(&self);
}
 
pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}
 
impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
// using trait objects to store values of != types that implemnt
// the same trait
use gui::Draw;
 
struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}
 
impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}
 
use gui::{Button, Screen};
 
fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };
 
    screen.run();
}

The advantage of using trait objects and Rust’s type system to write code similar to code using duck typing is that we never have to check whether a value implements a particular method at runtime or worry about getting errors if a value doesn’t implement a method but we call it anyway. Rust won’t compile our code if the values don’t implement the traits that the trait objects need.

Trait objects perform dynamic dispatch

When the compiler can’t tell at compile time which method you’re calling. It’s a trade-off to consider.

Object safety is required for trait objects

A trait is object safe if the methods defined in the trait have the following properties:

  • the return type isn’t self
  • there are no generic type parameter
// not object safe
pub trait Clone {
    fn clone(&self) -> Self;
}
pub struct Screen {
    // doesn't compile because the trait `Clone` cannot be made
    // into an object
    pub components: Vec<Box<dyn Clone>>,
}

Implementing an object-oriented design pattern

State pattern example:

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}
 
impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
 
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
 
    pub fn content(&self) -> &str {
        // `as_ref` method on the `Option` because we want a
        // reference to the value inside the `Option` rather than
        // ownership of the value.
        // We can't move `state` out of the borrowed `&self` of the
        // function parameter.
        self.state.as_ref().unwrap().content(self)
    }
 
    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
 
    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}
 
trait State {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
 
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}
 
// --snip--
 
struct Draft {}
 
impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
 
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
 
struct PendingReview {}
 
impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
 
    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}
 
struct Published {}
 
impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
 
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
 
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

By implementing the state pattern exactly as it’s defined for OOP, we’re not taking as full advantage of Rust’s strengths as we could.

Encoding states and behavior as types

use blog::Post;
 
pub struct Post {
    content: String,
}
 
pub struct DraftPost {
    content: String,
}
 
impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }
 
    pub fn content(&self) -> &str {
        &self.content
    }
}
 
impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
 
    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}
 
pub struct PendingReviewPost {
    content: String,
}
 
impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}
 
fn main() {
    let mut post = Post::new();
 
    post.add_text("I ate a salad for lunch today");
 
    let post = post.request_review();
 
    let post = post.approve();
 
    assert_eq!("I ate a salad for lunch today", post.content());
}

Advantage: invalid states are now impossible because of the type system and the type checking that happens at compile time.

OOP patterns won’t always be the best solution in Rust due to certain features, like ownership, that OOP don’t have.

You can use trait objects to get some object-oriented features in Rust. Dynamic dispatch can give your code some flexibility in exchange for a bit of runtime performance.