实现面向对象设计模式

状态模式是一种面向对象设计模式。该模式的关键在于,我们定义了一个值在内部可以具有的状态集。状态由一组状态对象表示,并且值的行为根据其状态而变化。我们将通过一个博客文章结构体的示例来讲解,该结构体有一个字段来保存其状态,该字段将是“草稿”、“审核”或“已发布”状态集中的一个状态对象。

状态对象共享功能:当然,在 Rust 中,我们使用结构体和特征而不是对象和继承。每个状态对象负责其自身的行为,并负责管理何时应更改为另一种状态。持有状态对象的的值不知道状态的不同行为或何时在状态之间转换。

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

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

最终功能如下所示

  1. 博客文章最初为空白草稿。
  2. 草稿完成后,将请求审核该文章。
  3. 文章获得批准后,便会发布。
  4. 只有已发布的博客文章才会返回内容以供打印,因此未经批准的文章不会被意外发布。

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

清单 17-11 以代码形式显示了此工作流:这是我们将实现在名为 blog 的库包中的 API 的使用示例。这还不能编译,因为我们还没有实现 blog 包。

文件名: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());
}

清单 17-11:演示我们希望 blog 包具有的预期行为的代码

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

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

请注意,我们从包中交互的唯一类型是 Post 类型。此类型将使用状态模式,并将持有一个值,该值将是表示文章可以处于的各种状态(草稿、等待审核或已发布)的三个状态对象之一。从一种状态更改为另一种状态将在 Post 类型内部进行管理。状态会根据我们库的用户在 Post 实例上调用的方法而改变,但他们不必直接管理状态更改。此外,用户不会在状态方面出错,例如在文章经过审核之前发布文章。

定义 Post 并在草稿状态下创建新实例

让我们开始实现库!我们知道我们需要一个保存一些内容的公共 Post 结构体,因此我们将从结构体的定义和一个关联的公共 new 函数开始,以创建 Post 的实例,如清单 17-12 所示。我们还将创建一个私有 State 特征,该特征将定义 Post 的所有状态对象必须具有的行为。

然后,Post 将在一个名为 state 的私有字段中保存一个 Option<T> 内部的 Box<dyn State> 特征对象,以保存状态对象。您将在稍后看到为什么需要 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 {}

代码清单 17-12:定义了一个 Post 结构体和一个创建新 Post 实例的 new 函数,一个 State trait 和一个 Draft 结构体

State trait 定义了不同帖子状态共有的行为。状态对象有 DraftPendingReviewPublished,它们都将实现 State trait。目前,该 trait 没有任何方法,我们将首先定义 Draft 状态,因为这是我们希望帖子开始时的状态。

当我们创建一个新的 Post 时,我们将它的 state 字段设置为一个包含 BoxSome 值。这个 Box 指向一个新的 Draft 结构体实例。这确保了每当我们创建一个新的 Post 实例时,它都会从草稿状态开始。因为 Poststate 字段是私有的,所以没有办法创建一个处于其他状态的 Post!在 Post::new 函数中,我们将 content 字段设置为一个新的空 String

存储帖子内容的文本

我们在代码清单 17-11 中看到,我们希望能够调用一个名为 add_text 的方法,并向其传递一个 &str,然后将其作为博客帖子的文本内容添加。我们将其实现为一个方法,而不是将 content 字段公开为 pub,这样我们以后就可以实现一个方法来控制如何读取 content 字段的数据。add_text 方法非常简单,所以让我们在代码清单 17-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 {}

代码清单 17-13:实现 add_text 方法以将文本添加到帖子的 content

add_text 方法接受一个对 self 的可变引用,因为我们正在更改调用 add_textPost 实例。然后,我们在 content 中的 String 上调用 push_str,并传递 text 参数以添加到保存的 content 中。此行为不依赖于帖子的状态,因此它不是状态模式的一部分。add_text 方法根本不与 state 字段交互,但它是我们希望支持的行为的一部分。

确保草稿帖子内容为空

即使在我们调用 add_text 并向帖子添加了一些内容之后,我们仍然希望 content 方法返回一个空的字符串切片,因为帖子仍处于草稿状态,如代码清单 17-11 的第 7 行所示。现在,让我们用最简单的方法来实现 content 方法,以满足此要求:始终返回一个空的字符串切片。我们稍后会在实现更改帖子状态的功能以便发布帖子时更改它。到目前为止,帖子只能处于草稿状态,因此帖子内容应始终为空。代码清单 17-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 {}

代码清单 17-14:在 Post 上添加 content 方法的占位符实现,该方法始终返回一个空的字符串切片

添加了此 content 方法后,代码清单 17-11 中第 7 行之前的所有内容都按预期工作。

请求审阅帖子会更改其状态

接下来,我们需要添加请求审阅帖子的功能,这会将其状态从 Draft 更改为 PendingReview。代码清单 17-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
    }
}

代码清单 17-15:在 PostState 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 方法需要获取状态值的所有权。这就是 Poststate 字段中的 Option 发挥作用的地方:我们调用 take 方法从 state 字段中取出 Some 值并在其位置留下 None,因为 Rust 不允许我们在结构体中拥有未填充的字段。这使我们能够将 state 值移出 Post 而不是借用它。然后,我们将帖子的 state 值设置为此操作的结果。

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

Draft 上的 request_review 方法返回一个新的 PendingReview 结构体实例,该实例表示帖子正在等待审阅时的状态。PendingReview 结构体也实现了 request_review 方法,但不进行任何转换。相反,它返回自身,因为当我们请求审阅已经处于 PendingReview 状态的帖子时,它应该保持在 PendingReview 状态。

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

我们将保留 Post 上的 content 方法,返回一个空的字符串切片。我们现在可以拥有处于 PendingReview 状态和 Draft 状态的 Post,但我们希望在 PendingReview 状态下具有相同的行为。代码清单 17-11 现在可以工作到第 10 行!

添加 approve 以更改 content 的行为

approve 方法类似于 request_review 方法:它会将 state 设置为当前状态在被批准时应该具有的值,如代码清单 17-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
    }
}

代码清单 17-16:在 PostState 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 方法,如代码清单 17-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
    }
}

代码清单 17-17:更新 Post 上的 content 方法以委托给 State 上的 content 方法

因为目标是将所有这些规则保留在实现 State 的结构体中,所以我们在 state 中的值上调用 content 方法,并将帖子实例(即 self)作为参数传递。然后,我们返回使用 state 值上的 content 方法返回的值。

我们在 Option 上调用 as_ref 方法,因为我们希望引用 Option 内部的值,而不是获取该值的所有权。因为 stateOption<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 定义中,这就是我们将根据我们拥有的状态放置返回什么内容的逻辑的地方,如代码清单 17-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
    }
}

代码清单 17-18:将 content 方法添加到 State trait 中

我们为 content 方法添加了一个默认实现,该方法返回一个空的字符串切片。这意味着我们不需要在 DraftPendingReview 结构体上实现 contentPublished 结构体将覆盖 content 方法并返回 post.content 中的值。

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

我们完成了——代码清单 17-11 中的所有内容现在都可以工作了!我们已经使用博客帖子工作流的规则实现了状态模式。与规则相关的逻辑位于状态对象中,而不是分散在整个 Post 中。

为什么不使用枚举?

您可能想知道为什么我们没有使用带有不同可能帖子状态作为变体的 enum。这当然是一种可能的解决方案,请尝试一下并将最终结果与您喜欢的结果进行比较!使用枚举的一个缺点是,检查枚举值的每个地方都需要一个 match 表达式或类似的东西来处理每个可能的变体。这可能会比这种 trait 对象解决方案更加重复。

状态模式的权衡

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

如果我们要创建一个不使用状态模式的替代实现,我们可能会在 Post 的方法中,甚至在检查帖子状态并在这些地方更改行为的 main 代码中使用 match 表达式。这意味着我们将不得不查看多个地方才能了解帖子处于已发布状态的所有含义!这只会随着我们添加更多状态而增加:每个 match 表达式都需要另一个分支。

使用状态模式,Post 方法和我们使用 Post 的地方不需要 match 表达式,并且要添加新状态,我们只需要添加一个新的结构体并在该结构体上实现 trait 方法。

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

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

状态模式的一个缺点是,由于状态实现了状态之间的转换,因此某些状态相互耦合。如果我们在 PendingReviewPublished 之间添加另一个状态,例如 Scheduled,我们将不得不更改 PendingReview 中的代码以转换到 Scheduled。如果 PendingReview 不需要随着新状态的添加而改变,那将会减少工作量,但这意味着要切换到另一种设计模式。

另一个缺点是我们重复了一些逻辑。为了消除一些重复,我们可能会尝试为 State trait 上的 request_reviewapprove 方法创建默认实现,这些实现返回 self;但是,这会违反对象安全,因为 trait 不知道具体的 self 究竟是什么。我们希望能够将 State 用作 trait 对象,因此我们需要它的方法是对象安全的。

其他重复包括 Post 上的 request_reviewapprove 方法的类似实现。这两种方法都委托给 Optionstate 字段中的值上的相同方法的实现,并将 state 字段的新值设置为结果。如果我们在 Post 上有很多遵循这种模式的方法,我们可能会考虑定义一个宏来消除重复(请参阅第 19 章中的“宏”部分)。

通过完全按照面向对象语言的定义实现状态模式,我们并没有充分利用 Rust 的优势。让我们看看我们可以对 blog crate 进行哪些更改,以便在编译时将无效状态和转换转换为错误。

将状态和行为编码为类型

我们将向您展示如何重新思考状态模式以获得一组不同的权衡。我们不会完全封装状态和转换,以便外部代码不知道它们,而是将状态编码为不同的类型。因此,Rust 的类型检查系统将通过发出编译器错误来防止在仅允许发布帖子的地方使用草稿帖子。

让我们考虑清单 17-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 方法。这样,如果我们尝试获取草稿帖子的内容,我们将收到一个编译器错误,告诉我们该方法不存在。因此,我们不可能在生产环境中意外显示草稿帖子内容,因为该代码甚至无法编译。清单 17-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);
    }
}

清单 17-19:具有 content 方法的 Post 和没有 content 方法的 DraftPost

PostDraftPost 结构体都有一个私有的 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,如清单 17-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,
        }
    }
}

清单 17-20:通过在 DraftPost 上调用 request_review 创建的 PendingReviewPost,以及将 PendingReviewPost 转换为已发布的 Postapprove 方法

request_reviewapprove 方法获取 self 的所有权,从而消耗 DraftPostPendingReviewPost 实例,并将它们分别转换为 PendingReviewPost 和已发布的 Post。这样,我们在对它们调用 request_review 后就不会有任何 lingering DraftPost 实例,依此类推。PendingReviewPost 结构体上没有定义 content 方法,因此尝试读取其内容会导致编译器错误,就像 DraftPost 一样。因为获取具有已定义 content 方法的已发布 Post 实例的唯一方法是在 PendingReviewPost 上调用 approve 方法,而获取 PendingReviewPost 的唯一方法是在 DraftPost 上调用 request_review 方法,所以我们现在已将博客帖子工作流编码到类型系统中。

但我们还必须对 main 进行一些小的更改。request_reviewapprove 方法返回新实例,而不是修改调用它们的结构体,因此我们需要添加更多 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 实现中。但是,我们获得的好处是,由于类型系统和编译时发生的类型检查,无效状态现在是不可能的!这确保了某些错误,例如显示未发布帖子的内容,将在它们进入生产环境之前被发现。

尝试在本节开头针对 blog crate 建议的任务,因为它在清单 17-21 之后,以了解您对此版本代码设计的看法。请注意,某些任务可能已在此设计中完成。

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

总结

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

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