键盘快捷键

按 ← 或 → 在章节之间导航

按 S 或 / 在书中搜索

按 ? 显示此帮助

按 Esc 隐藏此帮助

实现面向对象设计模式

状态模式(state pattern)是一种面向对象的设计模式。该模式 的关键在于:我们为一个值定义一组内部状态。这些状态 由一组状态对象(state objects)表示,而值的行为会 根据其状态而变化。我们将通过一个博客文章结构体的示例来 逐步演示,该结构体有一个存放状态的字段,状态是来自 「草稿」「审核中」或「已发布」这一集合中的某个状态对象。

这些状态对象共享功能:当然,在 Rust 中我们使用结构体和 trait 而不是对象和继承。每个状态对象负责 自己的行为,并决定何时应该转换为另一个 状态。持有状态对象的值对状态的不同 行为以及状态之间何时转换一无所知。

使用状态模式的优势在于:当程序的业务需求发生变化时, 我们无需更改持有状态的值的代码,也无需更改使用该值的 代码。只需更新某个状态对象内部的代码以改变其规则, 或者添加更多状态对象即可。

首先,我们将以更传统的面向对象方式实现状态模式。 然后,我们会使用一种在 Rust 中更自然的方式。让我们 深入研究,使用状态模式增量实现一个博客文章工作流。

最终的功能将如下所示:

  1. 博客文章从空的草稿开始。
  2. 草稿完成后,请求对文章进行审核。
  3. 文章获批后,将被发布。
  4. 只有已发布的博客文章会返回要打印的内容,这样未获批的 文章就不会被意外发布。

对文章尝试的任何其他更改都应无效。例如,如果我们在 请求审核之前就尝试批准一篇草稿博客文章,该文章应保持 为未发布的草稿。

尝试传统的面向对象风格

解决同一问题的代码结构方式有无数种,各有不同的取舍。 本节的实现更偏向传统的面向对象风格,这种风格在 Rust 中 可以编写,但并未利用 Rust 的一些优势。稍后,我们将演示 另一种解决方案,它仍然使用面向对象设计模式,但其结构方式 对有面向对象经验的程序员来说可能看起来不太熟悉。我们将 比较这两种解决方案,体会以不同于其他语言的方式设计 Rust 代码所带来的取舍。

示例 18-11 以代码形式展示了这个工作流:这是我们将要在 名为 blog 的库 crate 中实现的 API 的用法示例。它目前 还无法编译,因为我们尚未实现 blog crate。

文件名:src/main.rs
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());
}
示例 18-11:展示我们希望 blog crate 具有的期望行为的代码

我们希望允许用户使用 Post::new 创建一个新的草稿博客文章。我们 希望允许向博客文章添加文本。如果我们在批准之前尝试立即获取文章的 内容,我们不应该得到任何文本,因为该文章仍然是草稿。我们在代码中 添加了 assert_eq! 用于演示目的。一个很好的单元测试是断言草稿博客 文章从 content 方法返回空字符串,但我们不打算为这个示例编写测试。

接下来,我们希望启用对文章的审核请求,并且我们希望 在等待审核期间 content 返回空字符串。当文章 获得批准时,它应该被发布,这意味着当调用 content 时将返回 文章的文本。

请注意,我们与 crate 交互的唯一类型是 Post 类型。该类型将使用状态模式,并持有一个值,该值将是 代表文章可能处于的各种状态——草稿、待审核或已发布——的三个 状态对象之一。从一个状态到另一个状态的转换将由 Post 类型内部管理。状态会响应我们库的用户在 Post 实例上 调用的方法而改变,但用户不必直接管理状态变化。此外,用户 不会在状态上犯错,比如在文章审核之前就发布它。

定义 Post 并创建新实例

让我们开始实现这个库!我们知道需要一个公有的 Post 结构体来存放一些内容,因此我们从结构体的定义和一个创建 Post 实例的关联公有 new 函数开始,如示例 18-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 {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
示例 18-12:Post 结构体和创建新 Post 实例的 new 函数、State trait 和 Draft 结构体的定义

State trait 定义了不同文章状态共享的行为。 状态对象是 Draft、PendingReview 和 Published,它们都将 实现 State trait。目前,该 trait 没有任何方法,我们将 从只定义 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 方法非常简单直接,让我们把示例 18-13 中的实现添加到 impl Post 块中。

文件名:src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
示例 18-13:实现 add_text 方法以向文章的 content 添加文本

add_text 方法接受 self 的可变引用,因为我们要修改 调用 add_text 的 Post 实例。然后我们对 content 中的 String 调用 push_str,并传入 text 参数以追加到已保存 的 content。此行为不依赖于文章所处的状态,因此不属于 状态模式的一部分。add_text 方法完全不与 state 字段 交互,但它是我们希望支持的行为的一部分。

确保草稿文章的内容为空

即使在调用 add_text 并向文章添加了一些内容之后,我们仍 希望 content 方法返回一个空的字符串切片,因为文章仍 处于草稿状态,正如示例 18-11 中第一个 assert_eq! 所示。 现在,让我们用最简单的方式实现 content 方法来满足这个 要求:始终返回一个空的字符串切片。等之后实现了更改文章 状态以便发布的功能后,我们再修改它。到目前为止,文章只能 处于草稿状态,因此文章内容应始终为空。示例 18-14 展示了 这个占位实现。

文件名:src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
示例 18-14:为 Post 上的 content 方法添加一个始终返回空字符串切片的占位符实现

有了这个添加的 content 方法,示例 18-11 中直到第一个 assert_eq! 的所有内容都按预期工作。

请求审核,这会改变文章的状态

接下来,我们需要添加请求文章审核的功能,这应该 将其状态从 Draft 更改为 PendingReview。示例 18-15 展示了此代码。

文件名:src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    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
    }
}
示例 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 方法将 Some 值从 state 字段中取出,并在原处留下 None,因为 Rust 不允许结构体 中存在未填充的字段。这使我们可以将 state 值移出 Post 而不是借用它。然后,我们将文章的 state 值 设置为该操作的结果。

我们需要将 state 暂时设置为 None,而不是用 self.state = self.state.request_review(); 这样的代码直接 设置,以获取 state 值的所有权。这确保了在我们将旧 state 值转换为新状态之后,Post 无法再使用它。

Draft 上的 request_review 方法返回一个新的、装箱的 PendingReview 结构体实例,它表示文章等待审核时的状态。 PendingReview 结构体同样实现了 request_review 方法, 但不执行任何转换。相反,它返回自身,因为当我们对一篇 已处于 PendingReview 状态的文章请求审核时,它应该 保持在 PendingReview 状态。

现在我们可以开始看到状态模式的优势:Post 上的 request_review 方法无论其 state 值如何都是相同的。每个 状态都负责自己的规则。

我们将保持 Post 上的 content 方法不变,仍返回空 字符串切片。现在我们可以有处于 PendingReview 状态以及 Draft 状态的 Post,但我们希望在 PendingReview 状态下有相同的行为。示例 18-11 现在可以工作到第二个 assert_eq! 调用!

添加 approve 以改变 content 的行为

approve 方法将与 request_review 方法类似:它将 把 state 设置为当前状态被批准时应有的值,如示例 18-16 所示。

文件名:src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    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 request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    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
    }
}
示例 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 所示。

文件名:src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --snip--

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    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 {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    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
    }
}
示例 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 时, 解引用强制转换(deref coercion)会作用于 & 和 Box,使得 content 方法最终被调用到实现了 State trait 的类型上。这意味着我们需要把 content 添加到 State trait 的定义中,并根据所处的状态把返回何种内容 的逻辑放在那里,如示例 18-18 所示。

文件名:src/lib.rs
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(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}
示例 18-18:将 content 方法添加到 State trait

我们为 content 方法添加了一个返回空字符串切片的默认 实现。这意味着我们不需要在 Draft 和 PendingReview 结构体上实现 content。Published 结构体会覆盖 content 方法并返回 post.content 中的值。虽然这样 很方便,但让 State 上的 content 方法决定 Post 的 内容,模糊了 State 的职责与 Post 的职责之间的界限。

注意,正如我们在第 10 章讨论的那样,这个方法需要生命周期 注解。我们接受一个 post 的引用作为参数,并返回该 post 一部分的引用,因此返回引用的生命周期与 post 参数的生命周期相关。

至此我们就完成了——示例 18-11 的全部内容现在都能工作了! 我们已使用博客文章工作流的规则实现了状态模式。与规则相关 的逻辑存在于状态对象中,而不是散落在 Post 各处。

为什么不使用枚举?

你可能一直在想,为什么我们没有使用一个以不同可能的文章 状态为变体的枚举。这当然是一个可行的解决方案;试试看并 比较最终结果,看看你更喜欢哪种!使用枚举的一个缺点是, 每个检查枚举值的地方都需要一个 match 表达式或类似的 东西来处理每个可能的变体。这可能比 trait 对象方案更 重复。

评估状态模式

我们已经展示了 Rust 能够实现面向对象的状态模式,以封装 文章在每种状态下应有的不同行为。Post 上的方法对各种 行为一无所知。由于我们组织代码的方式,只需查看一个地方 就能知道已发布文章可以有哪些不同的行为方式:Published 结构体上 State trait 的实现。

如果我们要创建一个不使用状态模式的替代实现,可能会 改用 Post 方法中、甚至检查文章状态并在那些地方改变 行为的 main 代码中的 match 表达式。那就意味着我们 需要查看多个地方才能理解文章处于已发布状态的所有含义!

使用状态模式时,Post 方法以及我们使用 Post 的地方 不需要 match 表达式;而要添加一个新状态,我们只需 添加一个新结构体,并在这一个结构体上的同一处实现 trait 方法即可。

使用状态模式的实现很容易扩展以添加更多功能。要体验 使用状态模式维护代码的简单性,请尝试以下建议:

  • 添加一个 reject 方法,将文章的状态从 PendingReview 改回 Draft。
  • 要求调用两次 approve 才能将状态更改为 Published。
  • 只允许用户在文章处于 Draft 状态时添加文本内容。 提示:让状态对象负责内容可能发生的变化,但不负责修改 Post。

状态模式的一个缺点是:由于状态实现了状态之间的转换, 一些状态相互耦合。如果我们在 PendingReview 和 Published 之间添加另一个状态,例如 Scheduled,就 必须修改 PendingReview 中的代码以改为转换到 Scheduled。如果 PendingReview 不需要随着新状态的 添加而改变,工作量会更小,但那意味着要切换到另一种 设计模式。

另一个缺点是我们重复了一些逻辑。为了消除部分重复, 我们可能会尝试为 State trait 上的 request_review 和 approve 方法提供返回 self 的默认实现。然而,这 行不通:当把 State 用作 trait 对象时,trait 并不知道 具体的 self 究竟是什么,因此返回类型在编译时是未知的。 (这是前面提到的 dyn 兼容性规则之一。)

其他的重复包括 Post 上 request_review 和 approve 方法相似的实现。两个方法都对 Post 的 state 字段使用 Option::take,如果 state 是 Some,它们就委托给被包装值对同名方法的实现,并将 state 字段的新值设置为结果。如果 Post 上有许多遵循 这种模式的方法,我们可能会考虑定义一个宏来消除重复 (参见第 20 章的「宏」一节)。

通过完全按照面向对象语言中定义的方式实现状态模式, 我们并没有尽可能充分地利用 Rust 的优势。让我们看看 可以对 blog crate 做哪些修改,将无效的状态和转换 变成编译时错误。

将状态和行为编码为类型

我们将向你展示如何重新思考状态模式,以获得一组不同的 取舍。我们将把状态编码为不同的类型,而不是完全封装 状态和转换使外部代码对它们一无所知。这样一来,Rust 的 类型检查系统将通过发出编译错误,阻止在只允许使用已 发布文章的地方使用草稿文章的尝试。

让我们考虑示例 18-11 中 main 的第一部分:

文件名:src/main.rs
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 创建处于草稿状态的新文章, 以及向文章内容添加文本的能力。但我们不再让草稿文章拥有 一个返回空字符串的 content 方法,而是让草稿文章根本 没有 content 方法。这样,如果我们尝试获取草稿文章的 内容,就会得到一个告诉我们该方法不存在的编译错误。因此, 我们不可能在生产环境中意外显示草稿文章的内容,因为那样的 代码甚至无法编译。示例 18-19 展示了 Post 结构体和 DraftPost 结构体的定义,以及各自的方法。

文件名: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);
    }
}
示例 18-19:带有 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 所示。

文件名: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 {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    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,
        }
    }
}
示例 18-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 中更新后的代码如示例 18-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());
}
示例 18-21:对 main 的修改以使用博客文章工作流的新实现

我们需要对 main 进行的重新赋值 post 的修改意味着这个 实现不再完全遵循面向对象的状态模式:状态之间的转换不再完全 封装在 Post 实现中。然而,我们的收获是由于类型系统和 编译时发生的类型检查,无效状态现在是不可能的!这确保了某些错误, 比如显示未发布文章的内容,将在它们进入生产环境之前被发现。

在示例 18-21 之后的 blog crate 上尝试本节开头建议的任务, 看看你对这个版本代码的设计有何看法。请注意,其中一些任务在这个 设计中可能已经完成。

我们已经看到,尽管 Rust 能够实现面向对象的设计模式, 但其他模式,比如将状态编码到类型系统中,在 Rust 中也是可用的。 这些模式有不同的权衡。尽管你可能非常熟悉面向对象的模式, 但重新思考问题以利用 Rust 的特性可以带来好处,比如在编译时 防止一些错误。由于面向对象语言所不具备的某些特性(如所有权), 面向对象的模式在 Rust 中并不总是最佳解决方案。

总结

无论你在阅读本章后是否认为 Rust 是一种面向对象的语言, 你现在都知道可以使用 trait 对象在 Rust 中获得一些面向对象的特性。 动态分派可以为你的代码提供一些灵活性,但会牺牲一些运行时性能。 你可以利用这种灵活性来实现有助于代码可维护性的面向对象模式。 Rust 还具有面向对象语言所不具备的其他特性,如所有权。 面向对象的模式并不总是充分利用 Rust 优势的最佳方式,但它是 一个可用的选项。

接下来,我们将看看模式,这是 Rust 的另一个提供很大灵活性的特性。 我们在整本书中都简要地介绍过它们,但还没有看到它们的全部能力。 让我们开始吧!