简介
欢迎来到实现面向对象设计模式。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习你的 Rust 技能。
在本实验中,我们将在面向对象设计中实现状态模式,以创建一个博客文章结构体,该结构体根据其行为在不同状态(草稿、审核和已发布)之间转换,确保只有已发布的博客文章才能返回内容。
实现面向对象设计模式
状态模式是一种面向对象设计模式。该模式的关键在于,我们在内部定义一个值可能具有的一组状态。这些状态由一组状态对象表示,并且该值的行为会根据其状态而变化。我们将通过一个博客文章结构体的示例来讲解,该结构体有一个字段用于保存其状态,这个状态将是来自“草稿”、“审核”或“已发布”集合中的一个状态对象。
状态对象共享功能:在 Rust 中,我们当然使用结构体和 trait 而不是对象和继承。每个状态对象负责自己的行为以及决定何时应转换为另一个状态。持有状态对象的值对状态的不同行为或何时在状态之间转换一无所知。
使用状态模式的优点是,当程序的业务需求发生变化时,我们无需更改持有状态的值的代码或使用该值的代码。我们只需要更新其中一个状态对象内部的代码以更改其规则,或者可能添加更多状态对象。
首先,我们将以更传统的面向对象方式实现状态模式,然后我们将使用一种在 Rust 中更自然的方法。让我们深入了解如何使用状态模式逐步实现博客文章工作流程。
最终功能如下:
- 博客文章最初是一个空草稿。
- 草稿完成后,请求对文章进行审核。
- 文章被批准后,它会被发布。
- 只有已发布的博客文章才会返回要打印的内容,因此未经批准的文章不会意外发布。
对文章尝试的任何其他更改都不应产生任何影响。例如,如果我们在请求审核之前尝试批准一篇草稿博客文章,该文章应保持为未发布的草稿。
清单 17-11 以代码形式展示了此工作流程:这是我们将在名为 blog 的库 crate 中实现的 API 的一个示例用法。由于我们尚未实现 blog crate,所以这段代码目前无法编译。
文件名:src/main.rs
use blog::Post;
fn main() {
1 let mut post = Post::new();
2 post.add_text("I ate a salad for lunch today");
3 assert_eq!("", post.content());
4 post.request_review();
5 assert_eq!("", post.content());
6 post.approve();
7 assert_eq!("I ate a salad for lunch today", post.content());
}
清单 17-11:展示我们希望 blog crate 具有的期望行为的代码
我们希望允许用户使用 Post::new 创建一个新的草稿博客文章[1]。我们希望允许向博客文章添加文本[2]。如果我们在批准之前立即尝试获取文章的内容,我们不应得到任何文本,因为文章仍然是草稿。为了演示目的,我们在代码中添加了 assert_eq![3]。对此的一个优秀单元测试是断言草稿博客文章从 content 方法返回一个空字符串,但我们不会为这个示例编写测试。
接下来,我们希望能够请求对文章进行审核[4],并且我们希望在等待审核时 content 返回一个空字符串[5]。当文章获得批准时[6],它应该被发布,这意味着当调用 content 时将返回文章的文本[7]。
请注意,我们从 crate 中与之交互的唯一类型是 Post 类型。这个类型将使用状态模式,并将持有一个值,该值将是表示文章可能处于的各种状态的三个状态对象之一——草稿、审核或已发布。从一种状态转换到另一种状态将在 Post 类型内部进行管理。状态根据我们库的用户在 Post 实例上调用的方法而变化,但他们不必直接管理状态变化。此外,用户不会在状态方面出错,例如在文章审核之前发布文章。
定义 Post 并创建处于草稿状态的新实例
让我们开始实现这个库吧!我们知道我们需要一个公共的 Post 结构体来保存一些内容,所以我们将从结构体的定义和一个相关的公共 new 函数开始,用于创建 Post 的实例,如清单 17-12 所示。我们还将创建一个私有的 State trait,它将定义 Post 的所有状态对象必须具备的行为。
然后,Post 将在一个名为 state 的私有字段中,在 Option<T> 内部持有一个 Box<dyn State> 的 trait 对象,以保存状态对象。稍后你会明白为什么需要 Option<T>。
文件名:src/lib.rs
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
pub fn new() -> Post {
Post {
1 state: Some(Box::new(Draft {})),
2 content: String::new(),
}
}
}
trait State {}
struct Draft {}
impl State for Draft {}
清单 17-12:Post 结构体和创建新 Post 实例的 new 函数的定义,State trait 和 Draft 结构体
State trait 定义了不同文章状态共享的行为。状态对象有 Draft、PendingReview 和 Published,它们都将实现 State trait。目前,这个 trait 没有任何方法,我们将从定义 Draft 状态开始,因为这是文章开始时的状态。
当我们创建一个新的 Post 时,我们将其 state 字段设置为一个包含 Box 的 Some 值[1]。这个 Box 指向 Draft 结构体的一个新实例。这确保了每当我们创建一个新的 Post 实例时,它将以草稿状态开始。因为 Post 的 state 字段是私有的,所以没有办法以任何其他状态创建 Post!在 Post::new 函数中,我们将 content 字段设置为一个新的空 String[2]。
存储文章内容的文本
我们在清单 17-11 中看到,我们希望能够调用一个名为 add_text 的方法,并向其传递一个 &str,然后将其作为博客文章的文本内容添加进去。我们将此实现为一个方法,而不是将 content 字段暴露为 pub,这样以后我们就可以实现一个方法来控制如何读取 content 字段的数据。add_text 方法非常简单,所以让我们在清单 17-13 中将实现添加到 impl Post 块中。
文件名:src/lib.rs
impl Post {
--snip--
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
清单 17-13:实现 add_text 方法以向文章的 content 中添加文本
add_text 方法接受一个对 self 的可变引用,因为我们正在更改调用 add_text 的 Post 实例。然后我们在 content 中的 String 上调用 push_str,并传递 text 参数以添加到保存的 content 中。此行为不依赖于文章所处的状态,因此它不是状态模式的一部分。add_text 方法根本不与 state 字段交互,但它是我们想要支持的行为的一部分。
确保草稿文章的内容为空
即使我们调用了 add_text 并向文章中添加了一些内容,我们仍然希望 content 方法返回一个空字符串切片,因为文章仍处于草稿状态,如清单 17-11 中的[3]所示。目前,让我们用满足此要求的最简单方法来实现 content 方法:始终返回一个空字符串切片。一旦我们实现了更改文章状态以便发布的功能,稍后我们会对此进行更改。到目前为止,文章只能处于草稿状态,所以文章内容应该始终为空。清单 17-14 展示了这个占位符实现。
文件名:src/lib.rs
impl Post {
--snip--
pub fn content(&self) -> &str {
""
}
}
清单 17-14:为 Post 上的 content 方法添加一个始终返回空字符串切片的占位符实现
有了这个添加的 content 方法,清单 17-11 中直到[3]行的所有内容都能按预期工作。
请求审核会更改文章的状态
接下来,我们需要添加功能来请求对文章进行审核,这应该会将其状态从“草稿”更改为“待审核”。清单 17-15 展示了此代码。
文件名:src/lib.rs
impl Post {
--snip--
1 pub fn request_review(&mut self) {
2 if let Some(s) = self.state.take() {
3 self.state = Some(s.request_review())
}
}
}
trait State {
4 fn request_review(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
5 Box::new(PendingReview {})
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
6 self
}
}
清单 17-15:在 Post 和 State trait 上实现 request_review 方法
我们为 Post 提供一个名为 request_review 的公共方法,它将接受对 self 的可变引用[1]。然后我们在 Post 的当前状态上调用一个内部的 request_review 方法[3],并且这个第二个 request_review 方法会消耗当前状态并返回一个新状态。
我们将 request_review 方法添加到 State trait 中[4];所有实现该 trait 的类型现在都需要实现 request_review 方法。注意,该方法的第一个参数不是 self、&self 或 &mut self,而是 self: Box<Self>。这种语法意味着该方法仅在对持有该类型的 Box 调用时才有效。这种语法获取了 Box<Self> 的所有权,使旧状态无效,这样 Post 的状态值就可以转换为新状态。
为了消耗旧状态,request_review 方法需要获取状态值的所有权。这就是 Post 的 state 字段中的 Option 发挥作用的地方:我们调用 take 方法从 state 字段中取出 Some 值,并在其位置留下一个 None,因为 Rust 不允许我们在结构体中有未填充的字段[2]。这使我们能够将 state 值从 Post 中移出而不是借用它。然后我们将文章的 state 值设置为这个操作的结果。
我们需要暂时将 state 设置为 None,而不是直接用像 self.state = self.state.request_review(); 这样的代码来设置它,以便获取 state 值的所有权。这确保了在我们将 Post 的状态转换为新状态后,它不能再使用旧的 state 值。
Draft 上的 request_review 方法返回一个新的、装箱的 PendingReview 结构体实例[5],它表示文章等待审核时的状态。PendingReview 结构体也实现了 request_review 方法,但不进行任何转换。相反,它返回自身[6],因为当我们对已经处于“待审核”状态的文章请求审核时,它应该保持在“待审核”状态。
现在我们可以开始看到状态模式的优点了:无论 Post 的 state 值如何,request_review 方法都是相同的。每个状态都负责自己的规则。
我们将 Post 上的 content 方法保持不变,返回一个空字符串切片。现在我们可以有处于“待审核”状态以及“草稿”状态的 Post,但我们希望在“待审核”状态下有相同的行为。清单 17-11 现在直到[5]行都能正常工作!
添加 approve 以更改 content 的行为
approve 方法将类似于 request_review 方法:它会将 state 设置为当前状态在被批准时应有的值,如清单 17-16 所示。
文件名:src/lib.rs
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> {
1 self
}
}
struct PendingReview {}
impl State for PendingReview {
--snip--
fn approve(self: Box<Self>) -> Box<dyn State> {
2 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
}
}
清单 17-16:在 Post 和 State trait 上实现 approve 方法
我们将 approve 方法添加到 State trait 中,并添加一个新的实现 State 的结构体,即 Published 状态。
类似于 PendingReview 上的 request_review 的工作方式,如果我们在 Draft 上调用 approve 方法,它将没有效果,因为 approve 将返回 self[1]。当我们在 PendingReview 上调用 approve 时,它会返回一个新的、装箱的 Published 结构体实例[2]。Published 结构体实现了 State trait,对于 request_review 方法和 approve 方法,它都返回自身,因为在这些情况下文章应该保持在 Published 状态。
现在我们需要更新 Post 上的 content 方法。我们希望 content 返回的值取决于 Post 的当前状态,所以我们将让 Post 委托给在其 state 上定义的 content 方法,如清单 17-17 所示。
文件名:src/lib.rs
impl Post {
--snip--
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
--snip--
}
清单 17-17:更新 Post 上的 content 方法以委托给 State 上的 content 方法
因为目标是将所有这些规则保留在实现 State 的结构体内部,所以我们在 state 中的值上调用 content 方法,并将文章实例(即 self)作为参数传递。然后我们返回在 state 值上使用 content 方法返回的值。
我们在 Option 上调用 as_ref 方法,因为我们想要 Option 内部值的引用而不是值的所有权。因为 state 是一个 Option<Box<dyn State>>,当我们调用 as_ref 时,会返回一个 Option<&Box<dyn State>>。如果我们不调用 as_ref,就会得到一个错误,因为我们不能从函数参数的借用 &self 中移出 state。
然后我们调用 unwrap 方法,我们知道它永远不会恐慌,因为我们知道 Post 上的方法确保在这些方法完成时 state 将始终包含一个 Some 值。这是我们在“你比编译器知道更多信息的情况”中讨论的情况之一,即我们知道 None 值永远不可能出现,即使编译器无法理解这一点。
此时,当我们在 &Box<dyn State> 上调用 content 时,解引用强制转换将对 & 和 Box 生效,所以最终将在实现 State trait 的类型上调用 content 方法。这意味着我们需要将 content 添加到 State trait 定义中,并且我们将在那里根据我们所处的状态放置返回什么内容的逻辑,如清单 17-18 所示。
文件名:src/lib.rs
trait State {
--snip--
fn content<'a>(&self, post: &'a Post) -> &'a str {
1 ""
}
}
--snip--
struct Published {}
impl State for Published {
--snip--
fn content<'a>(&self, post: &'a Post) -> &'a str {
2 &post.content
}
}
清单 17-18:将 content 方法添加到 State trait 中
我们为 content 方法添加了一个默认实现,它返回一个空字符串切片[1]。这意味着我们不需要在 Draft 和 PendingReview 结构体上实现 content。Published 结构体将覆盖 content 方法并返回 post.content 中的值[2]。
请注意,正如我们在第 10 章中讨论的,我们需要对这个方法添加生命周期注释。我们将 post 的引用作为参数,并返回对该 post 的一部分的引用,所以返回引用的生命周期与 post 参数的生命周期相关。
至此,我们完成了——清单 17-11 中的所有内容现在都能正常工作了!我们已经使用博客文章工作流程的规则实现了状态模式。与规则相关的逻辑存在于状态对象中,而不是分散在整个 Post 中。
为什么不使用枚举?
你可能一直在想为什么我们没有使用一个带有不同可能文章状态作为变体的枚举。这当然是一个可能的解决方案;试试看,并比较最终结果,看看你更喜欢哪种!使用枚举的一个缺点是,每个检查枚举值的地方都需要一个
match表达式或类似的东西来处理每个可能的变体。这可能比这个 trait 对象解决方案更繁琐。
状态模式的权衡
我们已经展示了 Rust 能够实现面向对象的状态模式,以封装文章在每个状态下应具有的不同行为。Post 上的方法对各种行为一无所知。按照我们组织代码的方式,我们只需要在一个地方查看就能了解已发布文章的不同行为方式:Published 结构体上 State trait 的实现。
如果我们要创建一个不使用状态模式的替代实现,我们可能会在 Post 上的方法中,甚至在检查文章状态并在这些地方更改行为的 main 代码中使用 match 表达式。这意味着我们必须在几个地方查找才能理解文章处于已发布状态的所有影响!随着我们添加的状态越来越多,情况只会变得更糟:每个 match 表达式都需要另一个分支。
使用状态模式时,Post 方法以及我们使用 Post 的地方都不需要 match 表达式,并且要添加一个新状态,我们只需要添加一个新结构体并在该结构体上实现 trait 方法。
使用状态模式的实现很容易扩展以添加更多功能。为了了解维护使用状态模式的代码的简单性,请尝试以下一些建议:
- 添加一个
reject方法,将文章的状态从“待审核”改回“草稿”。 - 在状态更改为“已发布”之前,需要调用两次
approve。 - 仅当文章处于“草稿”状态时,才允许用户添加文本内容。提示:让状态对象负责内容可能发生的变化,但不负责修改
Post。
状态模式的一个缺点是,由于状态实现了状态之间的转换,一些状态相互耦合。如果我们在“待审核”和“已发布”之间添加另一个状态,例如“已安排”,我们将不得不更改“待审核”中的代码以转换到“已安排”。如果“待审核”不需要随着新状态的添加而更改,工作量会更小,但这意味着要切换到另一种设计模式。
另一个缺点是我们重复了一些逻辑。为了消除一些重复,我们可能会尝试为 State trait 上的 request_review 和 approve 方法创建默认实现,返回 self。然而,这行不通:当将 State 用作 trait 对象时,trait 并不知道具体的 self 到底是什么,所以返回类型在编译时是未知的。
其他重复包括 Post 上 request_review 和 approve 方法的类似实现。这两个方法都委托给 Option 的 state 字段中的值上的相同方法的实现,并将 state 字段的新值设置为结果。如果我们在 Post 上有很多遵循此模式的方法,我们可能会考虑定义一个宏来消除重复(参见“宏”)。
通过完全按照为面向对象语言定义的方式实现状态模式,我们没有充分利用 Rust 的优势。让我们看看对 blog 包可以做哪些更改,以使无效状态和转换成为编译时错误。
将状态和行为编码为类型
我们将向你展示如何重新思考状态模式,以获得另一组权衡。我们不会将状态和转换完全封装起来,使外部代码对此一无所知,而是将状态编码为不同的类型。因此,Rust 的类型检查系统将通过发出编译器错误来阻止在只允许已发布文章的地方使用草稿文章的尝试。
让我们看看清单 17-11 中 main 的第一部分:
文件名:src/main.rs
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
}
我们仍然可以使用 Post::new 创建处于草稿状态的新文章,并能够向文章内容中添加文本。但是,我们不会让草稿文章有一个返回空字符串的 content 方法,而是让草稿文章根本没有 content 方法。这样,如果我们试图获取草稿文章的内容,就会得到一个编译器错误,告诉我们该方法不存在。结果,我们就不可能在生产环境中意外地显示草稿文章的内容,因为那段代码甚至无法编译。清单 17-19 展示了 Post 结构体和 DraftPost 结构体的定义,以及每个结构体上的方法。
文件名:src/lib.rs
pub struct Post {
content: String,
}
pub struct DraftPost {
content: String,
}
impl Post {
1 pub fn new() -> DraftPost {
DraftPost {
content: String::new(),
}
}
2 pub fn content(&self) -> &str {
&self.content
}
}
impl DraftPost {
3 pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
清单 17-19:一个有 content 方法的 Post 和一个没有 content 方法的 DraftPost
Post 和 DraftPost 结构体都有一个私有的 content 字段,用于存储博客文章的文本。结构体不再有 state 字段,因为我们将状态的编码转移到了结构体的类型上。Post 结构体将表示一篇已发布的文章,它有一个返回 content 的 content 方法[2]。
我们仍然有一个 Post::new 函数,但它返回的不是 Post 的实例,而是 DraftPost 的实例[1]。因为 content 是私有的,而且没有任何函数返回 Post,所以目前不可能创建 Post 的实例。
DraftPost 结构体有一个 add_text 方法,所以我们可以像以前一样向 content 中添加文本[3],但要注意 DraftPost 没有定义 content 方法!所以现在程序确保所有文章都从草稿文章开始,并且草稿文章的内容不可用于显示。任何试图绕过这些限制的尝试都会导致编译器错误。
将转换实现为转换为不同类型
那么我们如何得到一篇已发布的文章呢?我们要强制执行这样的规则:草稿文章必须经过审核和批准才能发布。处于待审核状态的文章仍然不应显示任何内容。让我们通过添加另一个结构体 PendingReviewPost 来实现这些约束,在 DraftPost 上定义 request_review 方法以返回一个 PendingReviewPost,并在 PendingReviewPost 上定义一个 approve 方法以返回一个 Post,如清单 17-20 所示。
文件名:src/lib.rs
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,
}
}
}
清单 17-20:通过在 DraftPost 上调用 request_review 创建的 PendingReviewPost,以及将 PendingReviewPost 转换为已发布 Post 的 approve 方法
request_review 和 approve 方法获取 self 的所有权,从而消耗 DraftPost 和 PendingReviewPost 实例,并分别将它们转换为一个 PendingReviewPost 和一篇已发布的 Post。这样,在我们对它们调用 request_review 之后,就不会有任何残留的 DraftPost 实例了,依此类推。PendingReviewPost 结构体没有定义 content 方法,所以尝试读取其内容会导致编译器错误,就像 DraftPost 一样。因为获得一个定义了 content 方法的已发布 Post 实例的唯一方法是在 PendingReviewPost 上调用 approve 方法,而获得一个 PendingReviewPost 的唯一方法是在 DraftPost 上调用 request_review 方法,我们现在已经将博客文章工作流程编码到类型系统中了。
但是我们还需要对 main 做一些小的修改。request_review 和 approve 方法返回新的实例,而不是修改它们所调用的结构体,所以我们需要添加更多的 let post = 遮蔽赋值来保存返回的实例。我们也不能再让关于草稿和待审核文章内容为空字符串的断言存在了,实际上我们也不需要它们了:我们再也无法编译尝试使用那些状态下文章内容的代码了。main 中更新后的代码如清单 17-21 所示。
文件名:src/main.rs
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-21:对 main 的修改,以使用博客文章工作流程的新实现
我们在 main 中为重新赋值 post 所做的更改意味着这个实现不再完全遵循面向对象的状态模式了:状态之间的转换不再完全封装在 Post 的实现内部。然而,我们的收获是,由于类型系统和编译时的类型检查,现在不可能出现无效状态了!这确保了某些错误,比如显示未发布文章的内容,在进入生产环境之前就会被发现。
在清单 17-21 之后的 blog 包上尝试本节开头提出的任务,看看你对这个版本代码的设计有什么看法。请注意,在这个设计中有些任务可能已经完成了。
我们已经看到,尽管 Rust 能够实现面向对象的设计模式,但其他模式,比如将状态编码到类型系统中,在 Rust 中也是可行的。这些模式有不同的权衡。尽管你可能非常熟悉面向对象模式,但重新思考问题以利用 Rust 的特性可以带来好处,比如在编译时防止一些错误。由于某些特性,比如所有权,面向对象语言没有这些特性,所以在 Rust 中面向对象模式并不总是最佳解决方案。
总结
恭喜你!你已经完成了“实现面向对象设计模式”实验。你可以在 LabEx 中练习更多实验来提升你的技能。