20 KiB
Implementing an Object-Oriented Design Pattern
The state pattern is an object-oriented design pattern.
The crux of the pattern is that we define a set of states a value can have internally.
The states are represented by a set of state objects and the value's behavior changes based on its state.
We are going to work through an example of a blog post struct that has aa filed to hold its state, this will be a state object form the set "draft", "review", or "published".
The sate objects share functionality. In Rust we use structs and traits rather than objects and inheritance.
Each state object is responsible for its own behavior and for governing when it should change into another state.
The value that holds a state object knows nothing about the different behavior of the states or when to transition between states.
The advantage of using this is that when the business requirements of the program change, we won't need to change the code of the value holding the state or the code that uses the value.
We will only need to update the code inside one of the state objects to change its rules or perhaps add more state objects.
We are going to implement the state pattern in a more tradition object-oriented way.
Next we will use an approach that is a bit more natural in Rust.
Lets start with incrementally implementing a blog post workflow using the state pattern.
The final functionality will look like this:
- A blog post starts as an empty draft
- When the draft is complete, a review of the post is requested
- When the post is approved, it will be published
- Only published blog posts return content to print, so unapproved posts can't be accidentally be published Any other changes attempted on a post should have no effect.
An example of this is if we try to approve a draft blog post before we have requested a review, the post should remain an unpublished draft.
In this example, it shows the workflow in code form.
This is an example usage of the API, we will implement in a library crate named blog
.
This will not compile yet because we haven't implemented the blog
crate.
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 want to allow the user to create a new draft blog post with Post::new
.
We also want to allow text to be added to be blog post.
If we try to get the post's content immediately, before approval, we shouldn't get any text because the post is still a draft.
We added assert_eq!
for demonstration purposes.
An excellent unit test for this would be to assert that a draft post returns an empty string from the content
method, but we are not going to write tests for this example.
Next, we want to enable a request for a review of the post and we want content
to return an empty string while waiting for the review.
Then when the post receives approval, it should get published, meaning the text of the post will be returned when content
is called.
Note that the only type we are interacting with from the crate s the Post type.
This type will use the state pattern and will hold a value that will be one of three state objects representing the various states a post can be in, draft, waiting for review or published.
Changing from one state change in response to the methods called by our library users on the Post
instance.
They don't have to manage the state changes directly.
Users can't make a mistake with the states, like publishing a post before it is reviewed.
Defining Post
and Creating a New Instance in the Draft State
First we need a public Post
struct that holds some content.
so we will start with the definition of the struct and the associated public new
function to create an instance of Post
.
This is shown below.
We will also make a private State
trait that will define the behavior that all state objects for a Post
must have.
Post
will hold a trait object of Box<dyn State>
inside an Option<T>
in a private field named state
to hold the state object.
You will see why the Option<T>
is necessary.
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(),
}
}
}
trait State {}
struct Draft {}
impl State for Draft {}
The State
trait defines the behavior shared by different post states.
The state objects are Draft
, PendingReview
and Published
, and they will all implement the State
trait.
For now the trait doesn't have any methods.
We will start by defining just the Draft
state because that is the state we want a post to start in.
When we create a new Post
, we set its state
field to a Some
value that holds a Box
.
This Box
points to aa new instance of the Draft
struct.
This ensures whenever we create a new instance of Post
, it will start out as a draft.
Due to the state field of Post
being private, there is no way to create a Psot
in any other state.
In the Post::new
function, we set the content
field to a new empty String
.
Storing the Text of the Post Content
Previously we saw that we wanted to be able to call a method named add_test
and pass it a &str
that is then added as the text content of the blog post.
We implemented this as a method, rather than exposing the content
field as pub
, so that later we can implement a method that will control how the content
field's data is read.
The add_text
method is fairly straightforward, so lets add the implementation below to the impl Post
block.
impl Post {
// --snip--
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
The add_text
method takes a mutable reference to self
.
Because we changed the Post
instance that we are calling add_text
on.
We then can call push_str
on the String
in content
and pass the text
argument to add to the saved content
.
This behavior doesn't depend on the state the post is in, so it is not part of the state pattern.
The add_text
method doesn't interact with the state
field at all, but it is part of the behavior we want to support.
Ensuring the Content of a Draft Post Is Empty
Even after we called add_text
and added some content to our post, we still want the content
method to return an empty string slice because the post is still in the draft state.
For now we will implement the content
method with the simplest thing that will fulfill this requirement.
Always returning an empty string slice.
We will change this late once we implement the ability to change a post's state so that it can be published.
Posts so far can only be in the draft state., so the post content should always be empty.
Here is a placeholder implementation:
impl Post {
// --snip--
pub fn content(&self) -> &str {
""
}
}
Requesting a Review of the Post Changes Its State
Next we need to add functionality to request a review of a post, which should change its state from Draft
to PendingReview
Here is the code that shows this
impl Post {
// --snip--
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
}
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
}
Here request_review
is a public method on the Post
struct, this will take a mutable reference to self
.
Then we call an internal request_review
method on the current state of Post
.
The second request_review
method consumes the current state and returns a new state.
We add the request_review
method to the State
trait.
Now all types that implement the trait will now need to implement the request_review
method.
Note, rather than having self
, &self
or &mut self
as the first parameter of the method, we have self: Box<Self>
.
This syntax means the method is only valid when called on a Box
holding the type.
This syntax takes ownership of Box<Self>
, invalidating the old state so the state value of the Post
can transform into a new state.
In order to consume the old state, the request_review
method needs to take ownership of the state value.
This is where the Option
in the state
filed of Post
comes in.
We call the take
method to take the Some
value out of the state
field and leave a None
in its place, because Rust not allowing us to have unpopulated fields in structs.
This lets us move the state
value out of Post
rather than borrowing it.
Then we will set the post's state
value to the result of this operation.
We need to set state
to None
temporarily rather than setting it directly with something like self.state = self.state.request_review();
to get ownership of the state
value.
This ensures that Post
can't use the old state
value after we transformed it into a new state.
The request_review
method on Draft
returns a new boxed instance of a new PendingReview
struct.
This represents the state when a post is waiting for a review.
The PendingReview
struct also implements the request_review
method but doesn't do any transformations.
It instead returns itself, because when we request a review on a post already in the PendingReview
state, it should stay in the PendingReview
state.
Now the advantages of the state pattern are staring to be seen: the request_review
method on Post
is the same no matter its state
value.
Each state is responsible for its own rules.
We leave the content
method on Post
as is, returning an empty string slice.
We can now have a Post
in the PendingReview
state as well as in the Draft
state, but we want the same behavior in the PendingReview
state.
Adding approve
to Change the Behavior of content
The approve
method will be similar to the request_review
method.
It will set state
to the value that the current state says it should have when that state is approved.
Here is the new code
impl Post {
// --snip--
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 {
// --snip--
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
// --snip--
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
}
}
Here we added the spprove
method to the State
trait and add a new struct that implements State
, the Published
state.
Similar to how request_review
on PendingReview
works, if we call the approve
method on a Draft
, it will have no effect because approve
will return self
.
When we call approve
on PendingReview
, it returns a new boxed instance of the Published
struct.
The Published
struct implements the State
trait, and for both the request_review
method and the approve
method, it returns itself, because the post should stay in the Published
state in those cases.
We now need a way to update the content
method on Post
.
We want the value returned from content
to depend on the current state of Post
, so we are going to have the Post
delegate to cotnent
method defined on its state
.
Here is the code for this
impl Post {
// --snip--
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
// --snip--
}
The goal is to keep all the rules inside the structs that implement State
.
We call a content
method on the value in state
and pass the post instance (that is self
) as an argument.
Then we return the value that is returned from using the content
method on the state
value.
As we call the as_ref
method on the Option
because we want a reference to the value inside the Option
rather than ownership of the value.
Because state
is an Option<Box<dyn State>>
, when we call as_ref
, an Option<&Box<dyn State>>
is returned.
If we didn't call as_ref
, we would get an error because we can't move state
out of the borrowed &self
of the function parameter.
Then we call the unwrap
method, we know this will never panic.
We know the methods on Post
ensure that state
will always contain a Some
value when those methods are done.
This is a case where we have more information than the compiler (previously discussed in Ch 9) when we know that a None
value is never possible, even though the compiler isn't able to understand that.
Now at this point, when we call content
on the &Box<dyn State>
, deref coercion will take effect on the &
and the Box
so the content
method will ultimately be called on the type that implements the State
trait.
This means we need to add content
to the State
trait definition, and that is where we will put the logic for what content to return depending on which state we have.
Here is that addition
trait State {
// --snip--
fn content<'a>(&self, post: &'a Post) -> &'a str {
""
}
}
// --snip--
struct Published {}
impl State for Published {
// --snip--
fn content<'a>(&self, post: &'a Post) -> &'a str {
&post.content
}
}
Here we added a default implementation for the content
method that returns an empty string slice.
This means we don't need to implement cotent
on the Draft
and PendingReview
structs.
The Published
struct will override the content
method and return the value in post.content
.
Note that we need a lifetime annotation on this method.
Here we are taking a reference to a post
as an argument and returning a reference to part of that post
, so the lifetime of the returned reference is related to the lifetime of the post
argument.
We have finally implemented the state pattern with the rules of the blog post workflow.
The logic related to the rules lives in the state objects rather than being scattered throughout Post
.
Final Code:
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 {
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 {
// --snip--
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
}
}
Why Not An Enum?
You may wonder why we didn't use an enum
with the different possible post states as variants.
This is a possible solution, you have to try it and compare the end results to see which is preferred.
One disadvantage of using an enum is every place that checks the value of the enum will need a match
expression or similar to handle every possible variant.
This could get more repetitive than this trait object solution.
Trade-offs of the State Pattern
Here we have shown that Rust is capable of implementing the object-oriented state pattern to encapsulate the different kinds of behavior a post should have in each state.
The methods on Post
know nothing about the various behaviors.
The way in which code is organized, we have to look only in one place to know the different ways a published post can behave: the implementation of the State
trait on the Published
struct.
If we were to create an alternative implementation that didn't use the state pattern, we might instead use match
expression in the Post
or even in the main
code.
This would check for the state of the post and changes behavior ion those places.
That means we would have to look in several places to understand all the implications of a post being in the published state.
This would only increase the more states we added: each of those match
expressions would need another arm.
With the state pattern, the Post
methods and the places we use Post
don't need match
expressions and to add a new state.
We would only need to add a new struct and implement the trait methods on that one struct.
The implementation using the state pattern is easy to extend to add more functionality.
To highlight the simplicity of maintaining code that uses the state pattern, try a few of these suggestions:
- Add a
reject
method that changes the post's state fromPendingReview
back toDraft
. - Require two calls to approve before the state can be changed to
Published
. - Allow users to add text content only when a post is in the
Draft
state.- Hint: have the state object responsible for what might change about the content but not responsible for modifying the
Post
. One downside of the state pattern is that, because the states implement the transitions between states, some of the states are coupled to each other.
- Hint: have the state object responsible for what might change about the content but not responsible for modifying the
If we add another state between PendingReview
and Published
, such as Scheduled
, we would have to change the code in PendingReview
to transitioned to Scheduled
instead.
It would be less work if PendingReview
didn't need to change with the addition of a new state, but that would mean switching to another design pattern.
Another downside is that we have dupliced some logic.
In order to eliminate some of the duplication, we may try to make default implementations for the request_review
and approve
methods on the State
trait that return self
However, this would not be dyn compatible.
This is because the trait doesn't know what the concrete self
will be exactly.
We want to be able to use State
as a trait object so we need its methods to be dyn compatible.
Other duplication includes the similar implementations of the request_review
and approve
methods on Post
.
Both methods delegate to the implementation of the same method on the value in the state
field of Option
and set the new value of the state
field to the result.
If we had a lot of methods on Post
that followed this pattern, we may consider defining a macro to eliminate the repetition (This will be discussed in Ch20).
By implementing the state pattern exactly as it is defined for object-oriented languages, we are not taking full advantage of Rust's strengths as we could.
Now lets look at some changes to make the blog
crate that can make invalid states and transitions into compile time errors.