Rust 学习笔记:实现状态模式
Rust 学习笔记:实现状态模式
状态模式是一个面向对象的设计模式。该模式的关键在于我们定义了一个值可以在内部拥有的一组状态。状态由一组状态对象表示,值的行为根据其状态而变化。
我们将通过一个博客文章结构体的示例,该结构体有一个字段来保存其状态,该状态对象将是来自 “draft”、“review” 或 “published” 集合的状态对象。
状态对象共享功能。当然,在 Rust 中 ,我们使用 struct 和 trait,而不是对象和继承。每个状态对象都对自己的行为负责,并在应该转换为另一个状态时进行管理。保存状态对象的值不知道状态的不同行为,也不知道何时在状态之间转换。
使用状态模式的优点是,当程序的业务需求发生变化时,我们不需要更改保存状态的值的代码或使用该值的代码。我们只需要更新其中一个状态对象中的代码来更改其规则,或者添加更多的状态对象。
首先,我们将以一种更传统的面向对象的方式实现状态模式,然后我们将使用一种在 Rust 中更自然的方法。
最终的功能看起来像这样:
- 一篇博客文章开头是一篇空白的草稿。
- 草稿完成后,要求对该文章进行审查。
- 当文章被批准时,它就会被发布。
- 只有已发布的博客文章才会返回打印内容,因此未经批准的文章不会意外发布。
对帖子进行的任何其他更改都不会产生任何影响。例如,如果我们试图在要求审查之前批准一篇博客文章草稿,那么该文章应该保持为未发表的草稿。
这是我们将在名为 blog 的库 crate 中实现的 API 的一个示例用法:
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());
}
我们希望允许用户使用 Post::new 创建一个新的博客草稿。我们希望允许将文本添加到博客文章中。如果我们试图在批准之前立即获得帖子的内容,我们不应该获得任何文本,因为帖子仍然是草稿。
我们添加了 assert_eq! 在代码中进行演示。对此,一个很好的单元测试是断言博客文章草稿从 content 方法返回一个空字符串,但是我们不打算为这个示例编写测试。
接下来,我们希望启用对文章进行审查的请求,并希望内容在等待审查时返回一个空字符串。当帖子获得批准时,它应该被发布,这意味着在调用 content 时将返回帖子的文本。
注意,我们从 crate 中与之交互的唯一类型是 Post 类型。此类型将使用状态模式,并将保存一个值,该值将是三个状态对象中的一个,这些状态对象表示文章可能处于草稿、审查或发布的各种状态。从一种状态到另一种状态的变化将在 Post 类型内部进行管理。状态会随着库用户在 Post 实例上调用的方法而改变,但是他们不必直接管理状态的改变。此外,用户不能在状态上犯错误,例如在评论之前发布帖子。
在草稿状态下定义 Post 和创建新实例
我们首先定义 Post 结构体和一个相关的公共 new 函数来创建 Post 实例。我们还将创建一个私有的 State trait,它将定义 Post 的所有状态对象必须具有的行为。
然后 Post 将在名为 State 的私有字段中保存一个 Option<T> 中的 Box<dyn State> 的 trait 对象来保存状态对象。稍后你将看到为什么 Option<T> 是必要的。
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 {}
State trait 定义了不同 post 状态共享的行为。状态对象是 Draft、PendingReview 和 Published,它们都将实现 State trait。现在,trait 没有任何方法,我们将从定义 Draft 状态开始,因为这是我们希望 post 开始时的状态。
当我们创建一个新的 Post 时,我们将它的 state 字段设置为一个保存 Box 的 Some 值。此框指向 Draft 结构体的新实例。这确保了无论何时创建 Post 的新实例,它都以草稿的形式开始。因为 Post 的 state 字段是私有的,所以没有办法在任何其他状态下创建 Post!在 Post::new 函数中,我们将内容字段设置为一个新的空 String。
存储文章内容的文本
我们希望能够调用一个名为 add_text 的方法,并向它传递一个 &str,然后将其添加为博客文章的文本内容。我们将其实现为一个方法,而不是将内容字段公开为 pub,以便稍后我们可以实现一个方法来控制如何读取内容字段的数据。
add_text 方法非常简单,将实现添加到 impl Post 块中。
impl Post {
// --snip--
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
add_text 方法有一个对 self 的可变引用,因为我们在改变 Post 实例的 content 字段。此行为不依赖于帖子所处的状态,因此它不是状态模式的一部分。
确保草稿内容为空
即使在调用了 add_text 并向文章添加了一些内容之后,我们仍然希望 content 方法返回一个空字符串片,因为文章仍然处于草稿状态。
现在,让我们用最简单的方式实现 content 方法来满足这个要求:总是返回一个空字符串切片。一旦实现了更改帖子状态以使其能够发布的功能,我们将在稍后更改这一点。到目前为止,帖子只能处于草稿状态,因此帖子内容应该始终为空。
impl Post {
// --snip--
pub fn content(&self) -> &str {
""
}
}
请求审查会改变帖子的状态
接下来,我们需要添加请求审查帖子的功能,这应该将其状态从 Draft 更改为 PendingReview。
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
}
}
我们给 Post 一个 request_review 公共方法,它将接受一个对 self 的可变引用。然后我们对 Post 的当前状态调用一个内部 request_review 方法,第二个 request_review 方法使用当前状态并返回一个新状态。
我们将 request_review 方法添加到State trait 中,所有实现 trait 的类型现在都需要实现 request_review 方法。注意,方法的第一个参数是 self: Box<Self>。这种语法意味着该方法只有在包含该类型的 Box 上调用时才有效。该语法获得 Box<Self> 的所有权,使旧状态无效,以便 Post 的状态值可以转换为新状态。
要使用旧状态,request_review 方法需要获得状态值的所有权。这就是 Post 的状态字段中的 Option 的用武之地:我们调用 take 方法从状态字段中取出 Some 值,并在其位置留下 None,因为 Rust 不允许我们在结构中有未填充的字段。这让我们可以将状态值移出 Post,而不是借用它。然后将 Post 的 state 值设置为该操作的结果。
我们需要临时将 state 设置为 None,而不是用 self.state = self.state.request_review();
这样的代码直接设置,获得 state 值的所有权。这确保 Post 在我们将其转换为新状态后不能使用旧的状态值。
Draft 上的 request_review 方法返回一个新的 PendingReview 结构体的装箱实例,该结构体表示文章等待审查时的状态。PendingReview 结构体也实现了 request_review 方法,它返回自己,因为当我们请求对已经处于 PendingReview 状态的帖子进行审查时,它应该保持在 PendingReview 状态。
现在我们可以开始看到状态模式的优点:Post 上的 request_review 方法无论其状态值如何都是相同的。每个 State 对自己的规定负责。
我们将保留 Post 上的 content 方法,返回一个空字符串切片。我们现在可以在 PendingReview 状态和 Draft 状态下都有一个 Post,但是我们希望在 PendingReview 状态下也有相同的行为。
添加批准以更改内容的行为
approve 方法将类似于request_review方法:它将 state 设置为当前状态在该状态被批准时应该具有的值。
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
}
}
我们将 approve 方法添加到 State trait 中,并添加一个实现 State 的新结构体,即 Published,表示已发布状态。
类似于 PendingReview 上的 request_review 的工作方式,如果我们在 Draft上 调用 approve 方法,它将不起作用,因为 approve 将返回 self。当我们在 PendingReview 上调用 approve 时,它返回 Published 结构的一个新的 Box 实例。Published 结构体实现 State trait,对于 request_review 方法和 approve 方法,它都返回自身,因为在这些情况下,帖子应该保持在 Published 状态。
现在我们需要更新 Post 上的 content 方法。我们希望该方法返回的值依赖于 Post 的当前状态,因此我们将 Post 委托给根据其状态定义的 content 方法。
impl Post {
// --snip--
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
// --snip--
}
因为我们的目标是将所有这些规则保持在实现 State 的结构中,所以我们对 State 中的值调用 content 方法,并将 Post 实例(即 self)作为参数传递。然后我们返回在状态值上使用content方法返回的值。
我们在 Option 上调用 as_ref 方法是因为我们想要一个对 Option 内部值的引用,而不是值的所有权。因为 state 是一个 Option<Box<dyn state>>,当我们调用 as_ref 时,返回一个 Option<&Box<dyn state>>。如果不调用 as_ref,就会得到一个错误,因为我们无法将状态移出借用的函数参数 &self。
然后调用unwrap方法,我们知道它永远不会出现 panic,因为我们知道 Post 上的方法确保在这些方法完成时 state 总是包含一个 Some 值。
此时,当我们在 &Box<dyn state> 上调用 content 时,deref 强制转换将在 & 和 Box 上生效,因此 content 方法最终将在实现 State trait 的类型上调用。这意味着我们需要将 content 方法添加到 State trait 定义中,这就是我们根据拥有的状态返回内容的逻辑所在。
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
}
}
我们为 content 方法添加了一个默认实现,它返回一个空的字符串切片。这意味着我们不需要在 Draft 和 PendingReview 结构体上实现 content 方法,这两个都继承默认实现。而 Published 结构体将覆盖 content 方法并返回 post.content 的引用。
你可能想知道为什么我们不将 state 设置为枚举。这当然是一个可能的解决方案,但使用枚举的一个缺点是,每个检查枚举值的地方都需要一个匹配表达式或类似的表达式来处理所有可能的变体。这可能会比这个 trait 对象解决方案更加重复。
自此我们完成了状态模式的全部内容。
状态模式的权衡
我们已经展示了 Rust 能够实现面向对象的状态模式,以封装 Post 在每种状态下应该具有的不同类型的行为。Post 上的方法对各种行为一无所知。在我们组织代码的方式中,我们只需要查看一个地方就可以知道发布的 Post 的不同行为方式:在 Published 结构体上实现 State trait。
如果我们要创建一个不使用状态模式的替代实现,我们可以在 Post 的方法中使用匹配表达式,甚至在检查 Post 的状态并更改这些地方的行为的主代码中使用匹配表达式。这意味着我们必须查看几个地方才能理解处于发布状态的 Post 的所有含义!
使用状态模式,Post 方法和我们使用 Post 的地方不需要匹配表达式,要添加新状态,我们只需要添加一个新结构体并在该结构上实现 trait 方法。
使用状态模式的实现很容易扩展以添加更多功能。要了解使用状态模式维护代码的简单性,请尝试以下一些建议:
- 添加一个 reject 方法,将 Post 的状态从 PendingReview 更改回 Draft。
- 需要两次调用 approve 方法才能将状态更改为已发布。
- 允许用户仅在帖子处于草稿状态时添加文本内容。提示:让 state 对象负责内容可能发生的变化,但不负责修改 Post。
状态模式的一个缺点是,因为状态实现了状态之间的转换,所以一些状态是相互耦合的。如果我们在 PendingReview 和 Published 之间添加另一个状态,比如 Scheduled,我们将不得不更改 PendingReview 中的代码以转换为 Scheduled。
另一个缺点是我们重复了一些逻辑。为了消除一些重复,我们可以尝试在返回 self 的 State trait 上为 request_review 和 approve 方法做默认实现。然而,这不起作用:当使用 State 作为 trait 对象时,trait 不知道具体的 self 是什么,所以在编译时不知道返回类型。
这是前面提到的 dyn 兼容性规则之一。
其他重复包括 Post 上的 request_review 和 approve 方法的类似实现。这两个方法都使用 Post 的 state 字段的 Option::take,如果 state 是 Some,它们将委托给包装值的相同方法的实现,并将 state 字段的新值设置为结果。如果 Post 上有很多方法都遵循这种模式,我们可以考虑定义一个宏来消除重复。
将状态和行为编码为类型
下面将展示如何重新考虑状态模式以获得一组不同的权衡。
我们将状态编码为不同的类型,而不是完全封装状态和转换,从而使外部代码不知道它们。
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
}
我们仍然支持使用 Post::new 创建草稿状态下的新 Post,并支持在 Post 的 content 中添加文本。但是我们不会在草稿 Post 上使用一个返回空字符串的 content 方法,而是让草稿 Post 根本没有 content 方法。这样,如果我们试图获取草稿 Post 的 content,我们将得到一个编译器错误,告诉我们该方法不存在。因此,我们不可能在生产中意外地显示草稿 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);
}
}
Post 和 DraftPost 结构体都有一个私有 content 字段,用于存储博客文章文本。结构体不再有 state 字段,因为我们将状态的编码移到了结构体的类型中。Post 结构体将表示发布的文章,它有一个返回内容的 content 方法。
我们仍然有一个 Post::new 函数,但是它返回的不是 Post 的实例,而是 DraftPost 的实例。因为 content 是私有的,并且没有任何函数返回 Post,所以现在不可能创建 Post 的实例。
DraftPost 结构体有一个 add_text 方法,所以我们可以像以前一样将文本添加到 content 中,但请注意,DraftPost 没有定义 content 方法!因此,现在该程序确保所有 Post 都以 DraftPost 开始,DraftPost 没有可供显示的 content。任何绕过这些约束的尝试都会导致编译器错误。
将转换实现为不同类型的转换
那么我们如何获得发布的帖子呢?我们希望强制执行一项规则,即在发布草稿之前必须经过审查和批准。处于 pending review 状态的帖子仍然不应该显示任何内容。
让我们通过添加另一个结构体 PendingReviewPost 来实现这些约束,在 DraftPost 上定义 request_review 方法以返回一个 PendingReviewPost,在 PendingReviewPost 上定义一个 approve 方法以返回一个 Post。
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,
}
}
}
request_review 和 approve 方法获得 self 的所有权,因此使用 DraftPost 和 PendingReviewPost 实例,并将它们分别转换为 PendingReviewPost 和已发布的 Post。这样,在调用 request_review 之后,我们就不会有任何滞留的 DraftPost 或 PendingReviewPost 实例。PendingReviewPost 结构体没有定义 content 方法,因此试图读取其 content 会导致编译器错误,就像 DraftPost 一样。
现在,获得定义了 content 方法的已发布 Post 实例的唯一方法是调用 PendingReviewPost 上的 approve 方法,而获得 PendingReviewPost 的唯一方法是调用 DraftPost 上的 request_review 方法。
我们也必须对 main 做一些小的改变。request_review 和 approve 方法返回新实例,而不是修改调用它们的结构,因此我们需要添加更多赋值来保存返回的实例。我们不能再编译试图使用草稿和待审核帖子状态下的帖子内容的代码。main 中更新的代码如下所示。
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());
}
该实现不再完全遵循面向对象的状态模式:状态之间的转换不再完全封装在 Post 实现中。
我们已经看到,尽管 Rust 能够实现面向对象的设计模式,由于某些特性,比如所有权,面向对象模式在 Rust 中并不总是最好的解决方案,而这些特性是面向对象语言所不具备的。
练习题
答:A、B。