Object-Oriented Design Pattern Implementation
Let's look at an example of the state design pattern and how to use it in Rust. The state pattern is when a value has some internal state, and the value's behavior changes based on the internal state. The internal state is represented by a set of objects that inherit shared functionality (we'll use structs and traits since Rust doesn't have objects and inheritance). Each state object is responsible for its own behavior and the rules for when it should change into another state. The value that holds one of these state objects doesn't know anything about the different behavior of the states or when to transition between states. In the future when requirements change, we won't need to change the code of the value holding the state or the code that uses the value. We'll only need to update the code inside one of the state objects to change its rules, or perhaps add more state objects.
In order to explore this idea, we're going to implement a blog post workflow in an incremental way. The workflow that we want our blog posts to follow, once we're done with the implementation, is:
- A blog post starts as an empty draft.
- Once the draft is done, we request a review of the post.
- Once the post is approved, it gets published.
- Only published blog posts return content to print so that we can't accidentally print the text of a post that hasn't been approved.
Any other changes attempted on a post should have no effect. For example, if we try to approve a draft blog post before we've requested a review, the post should stay an unpublished draft.
Listing 17-11 shows this workflow in code form. This is an example usage of the
API we're going to implement in a library crate named blog
:
Filename: src/main.rs
extern crate blog;
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 be able to create a new draft blog post with Post::new
. Then, we
want to add some text to the blog post while we're in the draft state. If we
try to print out the post's content immediately, though, we shouldn't get any
text, since the post is still a draft. We've added an assert_eq!
here for
demonstration purposes. Asserting that a draft blog post returns an empty
string from the content
method would make an excellent unit test in our
library, but we're not going to write tests for this example.
Next, we want to be able to request a review of our post, and content
should
still return an empty string while waiting for a review. Lastly, when we
approve the blog post, it should get published, which means the text we added
will be returned when we call content
.
Notice that the only type we're interacting with from the crate is the Post
type. The various states a post can be in (draft, waiting for review,
published) are managed internally to the Post
type. The states change due to
the methods we call on the Post
instance, but we don't have to manage the
state changes directly. This also means we won't make a mistake with the
states, like forgetting to request a review before publishing.
Defining Post
and Creating a New Instance in the Draft State
Let's get started on the implementation of the library! We know we want to have
a public Post
struct that holds some content, so let's start with the
definition of the struct and an associated public new
function to create an
instance of Post
as shown in Listing 17-12. We're also going to have a
private trait State
. Post
will hold a trait object of Box<State>
inside
an Option
in a private field named state
. We'll see why the Option
is
necessary in a bit. The State
trait defines all the behavior different post
states share, and the Draft
, PendingReview
, and Published
states will all
implement the State
trait. For now, the trait does not have any methods, and
we're going to start by defining just the Draft
state since that's the state
we want to start in:
Filename: src/lib.rs
pub struct Post {
state: Option<Box<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 {}
When we create a new Post
, we set its state
field to a Some
value holding
a Box
pointing to a new instance of the Draft
struct. This ensures whenever
we create a new instance of Post
, it'll start out as a draft. Because the
state
field of Post
is private, there's no way to create a Post
in any
other state!
Storing the Text of the Post Content
In the Post::new
function, we set the content
field to a new, empty
String
. In Listing 17-11, we showed that we want to be able to call a method
named add_text
and pass a &str
to it to add that text to the content of the
blog post. We're choosing to implement this as a method rather than exposing
the content
field as pub
because we want to be able to control how the
content
field's data is read by implementing a method later. The add_text
method is pretty straightforward though, let's add the implementation in
Listing 17-13 to the impl Post
block:
Filename: src/lib.rs
# pub struct Post {
# content: String,
# }
#
impl Post {
// ...snip...
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
add_text
takes a mutable reference to self
, since we're changing the Post
instance that we're calling add_text
on. We then call push_str
on the
String
in content
and pass the text
argument to add to the saved
content
. This isn't part of the state pattern since its behavior doesn't
depend on the state that the post is in. The add_text
method doesn't interact
with the state
field at all, but it is part of the behavior we want to
support.
Content of a Draft Post is Empty
After we've called add_text
and added some content to our post, we still want
the content
method to return an empty string slice since the post is still in
the draft state, as shown on line 8 of Listing 17-11. For now, let's implement
the content
method with the simplest thing that will fulfill this requirement:
always returning an empty string slice. We're going to change this later once
we implement the ability to change a post's state to be published. With what we
have so far, though, posts can only be in the draft state, which means the post
content should always be empty. Listing 17-14 shows this placeholder
implementation:
Filename: src/lib.rs
# pub struct Post {
# content: String,
# }
#
impl Post {
// ...snip...
pub fn content(&self) -> &str {
""
}
}
With this added content
method, everything in Listing 17-11 up to line 8
works as we intend.
Requesting a Review of the Post Changes its State
Next up is requesting a review of a post, which should change its state from
Draft
to PendingReview
. We want post
to have a public method named
request_review
that will take a mutable reference to self
. Then we're going
to call a request_review
method on the state that we're holding, and that
request_review
method will consume the current state and return a new state.
In order to be able to consume the old state, the state request_review
method
needs to take ownership of the state value. This is where the Option
comes
in: we're going to take the Some
value out of the state
field and leave a
None
in its place since Rust doesn't let us have unpopulated fields in
structs. Then we'll set the post's state
value to the result of this
operation. Listing 17-15 shows this code:
Filename: src/lib.rs
# pub struct Post {
# state: Option<Box<State>>,
# content: String,
# }
#
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<State>;
}
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<State> {
Box::new(PendingReview {})
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<State> {
self
}
}
We've added the request_review
method to the State
trait; all types that
implement the trait will now need to implement the request_review
method.
Note that 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>
, which is what we want because we're transforming the
old state into a new state, and we want the old state to no longer be valid.
The implementation for the request_review
method on Draft
is to return a
new, boxed instance of the PendingReview
struct, which is a new type we've
introduced that represents the state when a post is waiting for a review. The
PendingReview
struct also implements the request_review
method, but it
doesn't do any transformations. It returns itself since requesting a review on
a post that's already in the PendingReview
state should stay in the
PendingReview
state.
Now we can start seeing the advantages of the state pattern: the
request_review
method on Post
is the same no matter what its state
value
is. Each state is responsible for its own rules.
We're going to leave the content
method on Post
as it is, returning an
empty string slice. We can now have a Post
in the PendingReview
state, not
just the Draft
state, but we want the same behavior in the PendingReview
state. Listing 17-11 now works up until line 11!
Approving a Post Changes the Behavior of content
The approve
method on Post
will be similar to that of the request_review
method: it will set the state
to the value that the current state says it
should have when that state is approved. We'll need to add the approve
method
to the State
trait, and we'll add a new struct that implements State
, the
Published
state. Listing 17-16 shows the new code:
Filename: src/lib.rs
# pub struct Post {
# state: Option<Box<State>>,
# content: String,
# }
#
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<State>;
fn approve(self: Box<Self>) -> Box<State>;
}
struct Draft {}
impl State for Draft {
# fn request_review(self: Box<Self>) -> Box<State> {
# Box::new(PendingReview {})
# }
#
// ...snip...
fn approve(self: Box<Self>) -> Box<State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
# fn request_review(self: Box<Self>) -> Box<State> {
# Box::new(PendingReview {})
# }
#
// ...snip...
fn approve(self: Box<Self>) -> Box<State> {
Box::new(Published {})
}
}
struct Published {}
impl State for Published {
fn request_review(self: Box<Self>) -> Box<State> {
self
}
fn approve(self: Box<Self>) -> Box<State> {
self
}
}
Similarly to request_review
, if we call the approve
method on a Draft
, it
will have no effect since it 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 since the
post should stay in the Published
state in those cases.
Now for updating the content
method on Post
: we want to return the value in
the post's content
field if its state is Published
, otherwise we want to
return an empty string slice. Because the goal is to keep all the rules like
this in the structs that implement State
, we're going to call a content
method on the value in state
and pass the post instance (that is, self
) as
an argument. Then we'll return the value returned from the content
method on
the state
value as shown in Listing 17-17:
Filename: src/lib.rs
# trait State {
# fn content<'a>(&self, post: &'a Post) -> &'a str;
# }
# pub struct Post {
# state: Option<Box<State>>,
# content: String,
# }
#
impl Post {
// ...snip...
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(&self)
}
// ...snip...
}
We're calling the as_ref
method on the Option
because we want a reference
to the value inside the Option
. We're then calling the unwrap
method, which
we know will never panic because all the methods on Post
ensure that the
state
value will have a Some
value in it when those methods are done. This
is one of the cases we talked about in Chapter 12 where we know that a None
value is never possible even though the compiler isn't able to understand that.
The content
method on the State
trait is where the logic for what content
to return will be. We're going to add a default implementation for the
content
method that returns an empty string slice. That lets us not need to
implement content
on the Draft
and PendingReview
structs. The Published
struct will override the content
method and will return the value in
post.content
, as shown in Listing 17-18:
Filename: src/lib.rs
# pub struct Post {
# content: String
# }
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
}
}
Note that we need lifetime annotations on this method, like we discussed in
Chapter 10. We're taking a reference to a post
as an argument, and we're
returning a reference to a part of that post
, so the lifetime of the returned
reference is related to the lifetime of the post
argument.
Tradeoffs of the State Pattern
We've shown that Rust is capable of implementing the object-oriented state
pattern in order to encapsulate the different kinds of behavior that a post
should have that depends on the state that the post is in. The methods on
Post
don't know anything about the different kinds of behavior. The way this
code is organized, we have one place to look in order to find out all the
different ways that a published post behaves: the implementation of the State
trait on the Published
struct.
An alternative implementation that didn't use the state pattern might have
match
statements in the methods on Post
or even in the code that uses
Post
(main
in our case) that checks what the state of the post is and
changes behavior in those places instead. That would mean we'd have a lot of
places to look in order to understand all the implications of a post being in
the published state! This would get worse the more states we added: each of
those match
statements would need another arm. With the state pattern, the
Post
methods and the places we use Post
don't need match
statements and
adding a new state only involves adding a new struct
and implementing the
trait methods on that one struct.
This implementation is easy to extend to add more functionality. Here are some changes you can try making to the code in this section to see for yourself what it's like to maintain code using this pattern over time:
- Only allow adding text content when a post is in the
Draft
state - Add a
reject
method that changes the post's state fromPendingReview
back toDraft
- Require two calls to
approve
before changing the state toPublished
A downside of the state pattern is that since the states implement the
transitions between the states, some of the states are coupled to each other.
If we add another state between PendingReview
and Published
, such as
Scheduled
, we would have to change the code in PendingReview
to transition
to Scheduled
instead. It would be nicer if PendingReview
wouldn't need to
change because of the addition of a new state, but that would mean switching to
another design pattern.
There are a few bits of duplicated logic that are a downside of this
implementation in Rust. It would be nice if we could make default
implementations for the request_review
and approve
methods on the State
trait that return self
, but this would violate object safety since 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 object safe.
The other duplication that would be nice to get rid of is the similar
implementations of the request_review
and approve
methods on Post
. They
both delegate to the implementation of the same method on the value in the
Option
in the state
field, 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
might consider defining a macro to eliminate the repetition (see Appendix E on
macros).
A downside of implementing this object-oriented pattern exactly as it's defined for object-oriented languages is that we're not taking advantage of Rust's strengths as much as we could be. Let's take a look at some changes we can make to this code that can make invalid states and transitions into compile time errors.
Encoding States and Behavior as Types
We're going to show how to rethink the state pattern a bit in order to get a different set of tradeoffs. Rather than encapsulating the states and transitions completely so that outside code has no knowledge of them, we're going to encode the states into different types. When the states are types, Rust's type checking will make any attempt to use a draft post where we should only use published posts into a compiler error.
Let's consider the first part of main
from Listing 17-11:
Filename: src/main.rs
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
}
We still want to create a new post in the draft state using Post::new
, and we
still want to be able to add text to the post's content. But instead of having
a content
method on a draft post that returns an empty string, we're going to
make it so that draft posts don't have the content
method at all. That way,
if we try to get a draft post's content, we'll get a compiler error that the
method doesn't exist. This will make it impossible for us to accidentally
display draft post content in production, since that code won't even compile.
Listing 17-19 shows the definition of a Post
struct, a DraftPost
struct,
and methods on each:
Filename: src/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);
}
}
Both the Post
and DraftPost
structs have a private content
field that stores the
blog post text. The structs no longer have the state
field since we're moving
the encoding of the state to the types of the structs. Post
will represent a
published post, and it has a content
method that returns the content
.
We still have a Post::new
function, but instead of returning an instance of
Post
, it returns an instance of DraftPost
. It's not possible to create an
instance of Post
right now since content
is private and there aren't any
functions that return Post
. DraftPost
has an add_text
method defined on
it so that we can add text to content
as before, but note that DraftPost
does not have a content
method defined! So we've enforced that all posts
start as draft posts, and draft posts don't have their content available for
display. Any attempt to get around these constraints will be a compiler error.
Implementing Transitions as Transformations into Different Types
So how do we get a published post then? The rule we want to enforce is that a
draft post has to be reviewed and approved before it can be published. A post
in the pending review state should still not display any content. Let's
implement these constraints by adding another struct, PendingReviewPost
,
defining the request_review
method on DraftPost
to return a
PendingReviewPost
, and defining an approve
method on PendingReviewPost
to
return a Post
as shown in Listing 17-20:
Filename: src/lib.rs
# pub struct Post {
# content: String,
# }
#
# pub struct DraftPost {
# content: String,
# }
#
impl DraftPost {
// ...snip...
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,
}
}
}
The request_review
and approve
methods take ownership of self
, thus
consuming the DraftPost
and PendingReviewPost
instances and transforming
them into a PendingReviewPost
and a published Post
, respectively. This way,
we won't have any DraftPost
instances lingering around after we've called
request_review
on them, and so forth. PendingReviewPost
doesn't have a
content
method defined on it, so attempting to read its content is a compiler
error like it is with DraftPost
. Because the only way to get a published
Post
instance that does have a content
method defined is to call the
approve
method on a PendingReviewPost
, and the only way to get a
PendingReviewPost
is to call the request_review
method on a DraftPost
,
we've now encoded the blog post workflow into the type system.
This does mean we have to make some small changes to main
. Because
request_review
and approve
return new instances rather than modifying the
struct they're called on, we need to add more let post =
shadowing
assignments to save the returned instances. We also can't have the assertions
about the draft and pending review post's contents being empty string anymore,
nor do we need them: we can't compile code that tries to use the content of
posts in those states any longer. The updated code in main
is shown in
Listing 17-21:
Filename: src/main.rs
extern crate blog;
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());
}
Having to change main
to reassign post
is what makes this implementation
not quite following the object-oriented state pattern anymore: the
transformations between the states are no longer encapsulated entirely within
the Post
implementation. However, we've gained the property of having invalid
states be impossible because of the type system and type checking that happens
at compile time! This ensures that certain bugs, such as displaying the content
of an unpublished post, will be discovered before they make it to production.
Try the tasks suggested that add additional requirements that we mentioned at the start of this section to see how working with this version of the code feels.
Even though Rust is capable of implementing object-oriented design patterns, there are other patterns like encoding state into the type system that are available in Rust. These patterns have different tradeoffs than the object-oriented patterns do. While you may be very familiar with object-oriented patterns, rethinking the problem in order to take advantage of Rust's features can give benefits like preventing some bugs at compile-time. Object-oriented patterns won't always be the best solution in Rust, since Rust has features like ownership that object-oriented languages don't have.
Summary
No matter whether you think Rust is an object-oriented language or not after reading this chapter, you've now seen that trait objects are a way to get some object-oriented features in Rust. Dynamic dispatch can give your code some flexibility in exchange for a bit of runtime performance. This flexibility can be used to implement object-oriented patterns that can help with the maintainability of your code. Rust also has different features, like ownership, than object-oriented languages. An object-oriented pattern won't always be the best way to take advantage of Rust's strengths.
Next, let's look at another feature of Rust that enables lots of flexibility: patterns. We've looked at them briefly throughout the book, but haven't seen everything they're capable of yet. Let's go!