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