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());
}
- We use
Post::newto create a new blog post draft. First, we create aPostinstance namedpost. It is mutable because a post in the draft state can still be edited. - Then we use the
add_textmethod onPostto add the sentence"I ate a salad for lunch today". - Next, we call
request_reviewto request approval. - Finally, we call
approveto 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
}
}
The
Poststruct has two fields. One field isstate, which stores the article’s current state. It has three states: draft, pending review, and published.Box<dyn State>means any type that implements theStatetrait can be stored.
Through this field,Postcan manage state changes internally. Those state changes happen through methods called onPost, and users can change the value only by calling those methods (because the fields ofPostare not public, users cannot modify the fields directly).-
The following methods are implemented on
Postin animplblock:- The
newfunction creates aPostinstance whose initialcontentis an empty string. Its initialstateis draft, sostatestores aDraftstruct (explained below). -
add_textuses thepush_strmethod to add text to thecontentfield. - Even if we call
add_textand add some content to the post, we still want thecontentmethod to return an empty string slice because the post is still in the draft state. -
request_reviewtakes the state out of thestatefield. Once taken out,statetemporarily becomesNonebecause ownership has been moved out. Then it callsrequest_reviewon the state to request approval. When the state isDraft, therequest_reviewmethod on theDraftstruct is called (explained below), changing thestatefield fromDrafttoPendingReviewand putting the updated state back intostate.
- The
approvemeans the post is approved. Its implementation is similar torequest_review: it takes the state out, callsapproveon it, and updates the state.-
The
Statetrait currently defines two methods with signatures only, and no concrete implementation:-
request_reviewmeans requesting approval. -
approvemeans 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.
-
Draftis used to represent the draft state. It does not need any actual data, so a struct with no fields is enough. -
The
Statetrait is implemented forDraftin animplblock:-
request_reviewmeans requesting approval, and it changes the value toPendingReview. -
approvemeans approval. Becauseapproveis not useful at this point, we only need to returnself, so the return value isself.
-
PendingReviewis used to represent the pending-review state. It does not need any actual data, so a struct with no fields is enough.-
The
Statetrait is implemented forPendingReviewin animplblock:-
request_reviewmeans requesting approval. At this point the state does not change, so we only need to returnself. -
approvemeans approval, and it returns thePublishedstruct.
-
Publishedis used to represent the published state. It does not need any actual data, so a struct with no fields is enough.The
Statetrait is implemented forPublishedin animplblock. But since it is already in the published state, bothrequest_reviewandapproveare not useful, so we just returnself.
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 {
""
}
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 {
""
}
}
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
}
}
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())
}
}
}
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,
}
}
}
Two structs are declared:
PostandDraftPost. Both have acontentfield that stores aString.-
We implement the
newmethod and thecontentmethod forPostin animplblock:- The
newmethod creates an emptyDraftPoststruct. - The
contentmethod returns the value of its owncontentfield.
- The
-
We implement methods for
DraftPost:-
add_textadds text to thecontentofDraftPost. -
request_reviewrequests approval. Calling this method returns another state,PendingReviewPost, meaning it is under review. This state is defined below.
-
The
PendingReviewPoststruct is declared and has acontentfield of typeString. We implement anapprovemethod 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());
}
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)