DEV Community

Cover image for [Rust Guide] 17.3. Implementing an Object-Oriented Design Pattern
SomeB1oody
SomeB1oody

Posted on

[Rust Guide] 17.3. Implementing an Object-Oriented Design Pattern

If you find this helpful, please like, bookmark, and follow. To keep learning along, follow this series.

17.3.1 The State Pattern

The state pattern is an object-oriented design pattern in which a value’s internal state is represented by several state objects, and the value’s behavior changes as its internal state changes.

Using the state pattern means that when business requirements change, you do not need to modify the code for the value that holds the state, or the code that uses that value; you only need to update the code inside the state objects to change their rules, or add new state objects.

Take a look at this example:

A blog post starts as an empty draft. After the draft is completed, it must be reviewed. When the post is approved, it is published. Only published blog posts return content to print, so unapproved posts will not be published by accident.

main.rs:

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}
Enter fullscreen mode Exit fullscreen mode
  • We use Post::new to create a new blog post draft. First, we create a Post instance named post. It is mutable because a post in the draft state can still be edited.
  • Then we use the add_text method on Post to add the sentence "I ate a salad for lunch today".
  • Next, we call request_review to request approval.
  • Finally, we call approve to approve the post.

PS: The assert_eq! calls are used for demonstration purposes in the code. A unit test might assert that a draft blog post returns an empty string from the content method, but we are not going to write tests for this example.

lib.rs:

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 {
        ""
    }

    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 {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

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> {
        Box::new(Published {})
    }
}

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
    }
}
Enter fullscreen mode Exit fullscreen mode
  • The Post struct has two fields. One field is state, which stores the article’s current state. It has three states: draft, pending review, and published. Box<dyn State> means any type that implements the State trait can be stored.
    Through this field, Post can manage state changes internally. Those state changes happen through methods called on Post, and users can change the value only by calling those methods (because the fields of Post are not public, users cannot modify the fields directly).

  • The following methods are implemented on Post in an impl block:

    • The new function creates a Post instance whose initial content is an empty string. Its initial state is draft, so state stores a Draft struct (explained below).
    • add_text uses the push_str method to add text to the content field.
    • Even if we call add_text and add some content to the post, we still want the content method to return an empty string slice because the post is still in the draft state.
    • request_review takes the state out of the state field. Once taken out, state temporarily becomes None because ownership has been moved out. Then it calls request_review on the state to request approval. When the state is Draft, the request_review method on the Draft struct is called (explained below), changing the state field from Draft to PendingReview and putting the updated state back into state.
  • approve means the post is approved. Its implementation is similar to request_review: it takes the state out, calls approve on it, and updates the state.

  • The State trait currently defines two methods with signatures only, and no concrete implementation:

    • request_review means requesting approval.
    • approve means approving the post.

PS: Note that the parameter in the signature is Box<Self>, which is different from self and mut self. Box<Self> means it can only be used with a Box instance wrapping the current type. It takes ownership of the Box<Self> during the call and invalidates the old value, thereby changing the state.

  • Draft is used to represent the draft state. It does not need any actual data, so a struct with no fields is enough.
  • The State trait is implemented for Draft in an impl block:

    • request_review means requesting approval, and it changes the value to PendingReview.
    • approve means approval. Because approve is not useful at this point, we only need to return self, so the return value is self.
  • PendingReview is used to represent the pending-review state. It does not need any actual data, so a struct with no fields is enough.

  • The State trait is implemented for PendingReview in an impl block:

    • request_review means requesting approval. At this point the state does not change, so we only need to return self.
    • approve means approval, and it returns the Published struct.
  • Published is used to represent the published state. It does not need any actual data, so a struct with no fields is enough.

  • The State trait is implemented for Published in an impl block. But since it is already in the published state, both request_review and approve are not useful, so we just return self.


Why don’t we use enum variants as the post states? That is certainly a possible solution, but one of its drawbacks is that using an enum requires a match expression or something similar everywhere the enum value is checked, in order to handle every possible variant.


This style introduces a lot of repeated code, and some of it is not useful at all. But it also has a very clear advantage: regardless of what the state value is, the request_review method on Post does not need to change, because each state is responsible for its own rules.

The content method also needs to be modified. We want it to be visible in the published state, but not in the other two states. We can use the object-oriented design pattern for that as well. Here is the original code:

pub fn content(&self) -> &str {
    ""
}
Enter fullscreen mode Exit fullscreen mode

First, define the content method on the State trait:

trait State {
    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 {
        ""
    }
}
Enter fullscreen mode Exit fullscreen mode

A default implementation is provided here, and it returns an empty string. Note that we need lifetimes here because we are receiving a reference to Post, and what we may return is a reference to some part of Post, so the lifetime of the return value is tied to the lifetime of the Post parameter.

The default implementation is sufficient for Draft and PendingReview. We only need to override the default implementation with a method in 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
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, modify the content method on Post:

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 {
        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())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We first need to look at the reference inside the Option, so we call as_ref to get an Option<&T>. To unwrap it, we need one step of error handling, and unwrap is enough here. Finally, we call the content method, and the concrete implementation of content will vary according to the current state.

17.3.2 Trade-Offs of the State Pattern

The advantages of the state pattern are as shown above: regardless of what the state value is, the request_review method on Post does not need to change, because each state is responsible for its own rules.

But its disadvantages are also obvious:

  • Some logic has to be implemented repeatedly.
  • Some states are coupled to one another. If we add a new state, the code related to it must also be changed.

17.3.3 Encoding State and Behavior as Types

If we strictly follow the object-oriented style, that is certainly workable, but it does not let Rust show its full power.

Below, we will modify the design by combining Rust’s characteristics. Specifically, we will encode state and behavior as concrete types. Rust’s type system will prevent users from using invalid states through compile-time errors.

The revised code looks like this:
lib.rs:

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,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Two structs are declared: Post and DraftPost. Both have a content field that stores a String.

  • We implement the new method and the content method for Post in an impl block:

    • The new method creates an empty DraftPost struct.
    • The content method returns the value of its own content field.
  • We implement methods for DraftPost:

    • add_text adds text to the content of DraftPost.
    • request_review requests approval. Calling this method returns another state, PendingReviewPost, meaning it is under review. This state is defined below.
  • The PendingReviewPost struct is declared and has a content field of type String. We implement an approve method on it to approve the post.

Here, Post refers to a post that has been officially published, DraftPost represents an article still in draft state, and PendingReviewPost represents a post under review. When approval succeeds, the content value is moved into the content field of Post for use.

This style avoids accidental situations because only a Post that has been officially published through approval has a content method to retrieve the article content.

The main.rs file also needs a small change:

use blog::Post;

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());
}
Enter fullscreen mode Exit fullscreen mode

17.3.4 Summary

Rust can not only implement object-oriented design patterns, but it can also support additional patterns. One example is encoding state and behavior as types.

Classic object-oriented patterns are not always the best choice in Rust programming practice, because Rust has ownership features that other object-oriented languages do not have.

Top comments (0)