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