实现面向对象的设计模式

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

状态对象共享功能:在 Rust 中,我们当然使用结构体和 trait 而不是对象和继承。每个状态对象负责其自身的行为,并负责控制何时应该更改为另一种状态。保存状态对象的值对状态的不同行为或何时在状态之间转换一无所知。

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

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

最终的功能将如下所示

  1. 博客文章以空草稿开始。
  2. 草稿完成后,将请求对帖子进行审核。
  3. 帖子获得批准后,它将被发布。
  4. 只有已发布的博客文章才会返回要打印的内容,因此未经批准的帖子不会意外发布。

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

清单 17-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());
}

清单 17-11:演示我们希望 blog crate 具有的所需行为的代码

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

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

请注意,我们从 crate 中交互的唯一类型是 Post 类型。此类型将使用状态模式,并将保存一个值,该值将是表示帖子可能处于的各种状态(草稿、等待审核或已发布)的三个状态对象之一。从一个状态更改为另一个状态将在 Post 类型内部进行管理。状态的变化是对库用户在 Post 实例上调用的方法的回应,但它们不必直接管理状态变化。此外,用户不能在状态上犯错误,例如在审核之前发布帖子。

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

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

然后,Post 将在名为 state 的私有字段中保存一个 Box<dyn State> 的 trait 对象,该对象位于 Option<T> 中,以保存状态对象。您稍后将了解为什么 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 状态。

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

我们将保持 Postcontent 方法不变,返回一个空字符串切片。我们现在可以使 Post 处于 PendingReview 状态以及 Draft 状态,但我们希望在 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 方法

我们在 State trait 中添加了 approve 方法,并添加了一个新的结构体来实现 State,即 Published 状态。

PendingReviewrequest_review 工作方式类似,如果我们在 Draft 上调用 approve 方法,它将不起作用,因为 approve 将返回 self。当我们在 PendingReview 上调用 approve 时,它会返回一个新的 Published 结构体的 boxed 实例。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 方法,并将 post 实例(即 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,因此最终将在实现 State trait 的类型上调用 content 方法。这意味着我们需要将 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 的方法中使用 match 表达式,甚至在检查帖子状态并在这些地方更改行为的 main 代码中使用 match 表达式。这意味着我们需要查看多个地方才能理解帖子处于发布状态的所有含义!这只会随着我们添加的状态越来越多而增加:每个 match 表达式都需要另一个分支。

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

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

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

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

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

其他重复包括 Postrequest_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 结构体将表示已发布的帖子,它具有一个返回 contentcontent 方法。

我们仍然有 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:一个 PendingReviewPost,它通过在 DraftPost 上调用 request_review 来创建,以及一个 approve 方法,它将一个 PendingReviewPost 转换为已发布的 Post

request_reviewapprove 方法获取 self 的所有权,从而消耗 DraftPostPendingReviewPost 实例,并将它们分别转换为 PendingReviewPost 和已发布的 Post。这样,在对它们调用 request_review 后,我们就不会有任何遗留的 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 实现中。但是,我们的收获是,由于类型系统和编译时发生的类型检查,现在不可能存在无效状态!这确保了某些错误,例如显示未发布文章的内容,将在它们进入生产环境之前被发现。

在清单 17-21 之后,尝试本节开头建议的关于 blog crate 的任务,看看你如何看待此版本代码的设计。请注意,此设计中可能已完成一些任务。

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

总结

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

接下来,我们将研究模式,这是 Rust 的另一个功能,可以实现很大的灵活性。我们在本书中简要地研究过它们,但尚未看到它们的全部功能。让我们开始吧!