写点什么

Rust 从 0 到 1- 面向对象编程 - 设计模式

用户头像
关注
发布于: 1 小时前
Rust从0到1-面向对象编程-设计模式

状态模式(state pattern)是面向对象的设计模式之一。其关键在允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的内部行为。在 Rust 中我们使用结构体和 trait 而不是对象和继承。每一个“状态”负责其自身的行为以及什么时候转变为另一个状态。包含“状态”的数据类型对于不同状态的行为以及何时进行状态转移完全不知道。

使用状态模式的好处是当业务需求改变时,我们无需改变包含状态的结构体或者使用到该结构体的代码。我们只需要修改某个状态对象中的代码或者是增加更多的状态对象。下面让我们通过一个例子看看如何在 Rust 中使用状态模式。

我们将一步步的实现一个发布博文(blog)的工作流。其最终看起来像这样:

  1. 博文的发布从空白的草稿开始。

  2. 当草稿完成以后,需要先请求审核。

  3. 当审核通过后,博文将被发布出来。

  4. 只有已经发布(处于发布状态)的博文的内容才能被获取到,这样就不会意外发布了没有审核通过的博文。

任何其它尝试对博客的修改都是无效的。譬如,如果尝试在我们请求审核之前审核通过一篇博文,该博文应该仍然保持未发布的状态。

我们将在一个名字为 blog 的 library crate 中实现相关 API,参考下面的例子:

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 创建博文草稿,之后可以通过 add_text 方法添加草稿内容。如果在此时我们尝试获得博文的内容,由于博文任然是草稿,什么也不会发生,我们将得一个空字符串。在例子中我们简单的使用 assert_eq! 进行断言,在实际编码中我们应该为其编写单元测试;)。接着,我们可以通过 request_review 请求审核博文,在等待审核时我们仍然无法获得博文的内容。最后当博文审核通过后,我们就可以获得到博文的内容,代表可以发布了。

目前我们唯一使用到的 blog crate 中的类型是 Post。在这个类型中会使用状态模式,在某一时刻其它将是草稿、等待审核和发布三种状态之一。状态的转变在 Post 类型内部进行管理。状态将依赖用户调用的 Post 实例的方法而改变,而不需要直接进行设置。这也将减少错误的发生,譬如在还没通过审核前发布博文(也就是说要发布博文,必须先完成审核的动作 )。

创建草稿

让我们开始实现 blog crate!首先我们从 Post 结构体的定义开始,根据之前的例子,我们需要一个创建 Post 实例的公有关联函数 new ,此外还需一个用于存放状态的字段和一个用于存放博文内容的字段,它们都是私用的。其中状态字段的类型为 Option<Box<dyn State>>。稍后我们将解释为什么需要使用 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 {}
复制代码

State trait 定义了所有不同状态共享的行为,状态 Draft、PendingReview 和 Published 都会实现 State trait。目前我们在这个 trait 中还没有定义任何方法,并且开始我们也只将定义 Draft 状态,因为这是创建博文草稿时的初始状态。

在 new 函数中我们将其 state 字段设置为状态 Draft ,content 字段设置为空字符串,由于 state 和 content 字段是私有的,这确保了无论何时创建新的 Post 实例,都是从空白的草稿状态开始。

存储博文内容

在前面的例子中我们通过调用 add_text 方法来将文本增加到博文的内容中。我们选择通过方法而不是直接暴露 content 字段的方式,主要是考虑之后我们还可以通过方法来控制 content 字段如何被读取。参考下面的例子:

impl Post {    // --snip--    pub fn add_text(&mut self, text: &str) {        self.content.push_str(text);    }}
复制代码

因为需要调用 add_text 改变 Post 实例,因此我们使用 self 的可变引用作为第一个参数。在方法中我们通过 String 类型的 push_str 方法来保存通过 text 参数传递的文本到 content 字段中。这部分并不涉及到状态模式,因为目前它的行为并不依赖博文所处的状态,其完全不与 state 字段交互,只是我们设计的 Post 功能的一部分(后面会提到,建议大家把只在草稿状态才能添加博文内容的功能作为练习,切身体会下状态模式)。

确保处于草稿状态的博文内容是空的

即使调用 add_text 并向博文增加内容之后,由于文仍然处于草稿状态,我们希望 content 方法仍然返回空字符串。我们先使用能满足这个要求的最简单的实现方式:让 content 方法总是返回一个空字符串。目前博文只有草稿状态,所以内容应该是空的;当后面我们实现了发布功能以后,再修改它的实现来满足需求(有点测试驱动开发的意思)。参考下面的例子:

impl Post {    // --snip--    pub fn content(&self) -> &str {        ""    }}
复制代码

请求审核博文

下面我们为 Post 增加请求审核博文的功能。在发起审核请求后,博文状态应由 Draft 变为 PendingReview。参考下面的例子:

impl Post {    // --snip--    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 }}
复制代码

在上面的例子中我们为 Post 增加了一个以 self 可变引用为参数的公有方法 request_review。在 request_review 方法中我们调用 Post 的当前“状态”的 request_review 方法,其会消费当前的状态(消费,我理解是根据当前的状态进行处理,并使老的状态无效)返回一个新状态。

为此,我们还为 State trait 增加了 request_review 方法;现在所有实现了这个 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 设置为进行 request_review 操作后的新状态。我们选择将 state 临时设置为 None 来获取 state 的所有权,而不是直接通过 self.state = self.state.request_review(); 直接进行赋值,这确保了当 Post 被转换为新状态后老的 state 无法再被使用。

Draft 结构体实现了 State trait 的 request_review 方法,其返回一个新的放在 Box<T> 中的 PendingReview 结构体实例,代表博文的等待审核状态。结构体 PendingReview 同样也实现了 State trait 的 request_review 方法,不过它返回自身,即状态不发生变化,因为博文已经处于等待审核状态,再次请求审核,状态不应该发生变化。

现在开始体现出状态模式的优势:Post 的 request_review 方法无需关心博文的当前状态,每个状态会负责实现它自己的规则。

审核通过博文

approve 方法的实现与 request_review 方法类似:它会将字段 state 设置为审核通过的状态。参考下面的例子:

impl Post {    // --snip--    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 approve(self: Box<Self>) -> Box<dyn State> { self }}
struct PendingReview {}
impl State for PendingReview { // --snip-- 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 }}
复制代码

在上面的例子中,我们为 State trait 增加了 approve 方法,并新增了一个实现了 State trait 的结构体 Published,代表发布状态。和 request_review 类似,如果调用 Draft 的 approve 方法,它会返回 self ,不会有任何效果;当调用 PendingReview 的 approve 方法时,它会返回一个新的放在 Box<T> 中的 Published 结构体实例。Published 结构体同样也实现了 State trait,其 request_review 和 approve 两个方法会返回自身,因为博文已经是发布状态,在这两种场景下其状态应保持不变。

现在我们回过头来修改 Post 的 content 方法:如果状态为 Published 则返回 Post 中 content 字段的值;否则返回空字符串。参考下面的例子:

impl Post {    // --snip--    pub fn content(&self) -> &str {        self.state.as_ref().unwrap().content(self)    }    // --snip--}
复制代码

因为我们的目的是在实现了 State trait 的结构体中封装和状态有关的逻辑,所以我们在 State trait 中增加了以博文实例为参数的 content  方法。这样我们就可以直接调用 state 的 content 方法返回博文内容。我们只需要读取博文内容,因此只需要获取 Option 中值的引用而不是所有权,在例子中我们使用 as_ref 方法返回一个 Option<&Box<State>>。注意,由于我们使用的是 self 的不可变引用,如果不使用 as_ref,我们会得到一个错误。其后,我们直接调用了 unwrap 方法,因为我们知道它永远也不会 panic: Post 的所有方法都只会为 state 赋予一个 Some 值,包括在创建实例的时候,因此 None 值是不可能出现的,这就是我们前面介绍错误处理章节讨论过的“我们比编译器知道的更多”的情况。到这一步我们就得到了 &Box<State>,最后,当调用 content 方法时,强制解引用会发生作用( & 和 Box) ,最终调用到实现了 State trait 的类型的 content 方法。下面我们为 State trait 增加 content 方法的定义,我们将在其实现中编写根据状态返回博文内容的逻辑。参考下面的例子:

trait State {    // --snip--    fn content<'a>(&self, post: &'a Post) -> &'a str {        ""    }}
// --snip--struct Published {}
impl State for Published { // --snip-- fn content<'a>(&self, post: &'a Post) -> &'a str { &post.content }}
复制代码

在上面的例子中,我们为 State trait 中定义的 content 方法增加了一个默认实现:返回一个空字符串。这样在 Draft 和 PendingReview 结构体中我们就无需再实现 content 方法了;在 Published 结构体中我们覆盖了 content 方法的默认实现,返回 post.content 的内容。注意,这个方法需要生命周期注解:因为我们使用 post 的引用作为参数,并返回 post 中 content 的引用,所以返回的引用的生命周期与 post 参数相关。

状态模式的取舍

我们通过封装博文在不同状态时的行为展示了 Rust 是如何实现面向对象设计模式中的状态模式的。对于调用者 Post ,其与这些被封装的行为实现了解耦。通过这种方式,如果我们需要了解博文在不同状态的行为,譬如发布状态,只需查看一处代码:实现了 State trait 的 Published 结构体。

如果我们不使用状态模式,则可能需要在 Post 的方法中,甚至在 main 函数中用到 match 语句,来根据博文不同的状态执行不同的动作。这样一来我们如果要了解某个状态下博文的处理逻辑就可能会需要查看很多位置!此外,这在后面我们需要增加更多状态时会变得更糟:我们需要修改每一处 match 语句来增加判断分支。

如果使用状态模式,在 Post 的方法中和使用到 Post 的地方都不需要 match 语句,并且增加新状态只涉及到增加一个新的实现了 State trait 的结构体和相关“状态”的结构体的修改。这易于提高代码的可扩展性。为了可以切身体会使用状态模式后代码更易维护,大家可以尝试在例子的基础上扩展下面的功能:

  • 增加 reject 方法将博文的状态从 PendingReview 转变为 Draft

  • 需调要两次 approve 方法才可以将状态变为 Published 

  • 只允许在博文处于 Draft 状态时增加文本内容。提示:让状态对象负责修改博文内容而不是修改 Post。

状态模式的一个缺点是,因为在状态中实现了状态之间的转换,状态之间会产生耦合。譬如,如果在 PendingReview 和 Published 两个状态之间增加另一个状态 Scheduled,则需要修改 PendingReview 中的代码来实现到 Scheduled 状态的转换。如果 PendingReview 无需因为新增状态而改变将减少我们的工作,不过者需要改为使用另一种设计模式。

另一个缺点是,我们会实现一些重复的逻辑。为了减少重复的代码实现,可以尝试在 State trait 中为方法增加默认实现,譬如在 request_review 和 approve 方法中返回 self ,不过这会与对象安全性相违背( 前面介绍 trait 对象的时候介绍过对象安全的规则)。因为我们希望将 State 作为一个 trait 对象,所以其方法需要是对象安全的。

另一个重复是 Post 中 request_review 和 approve 的两个方法的实现是类似的,他们都委托调用了“状态”的同一方法,并将结果赋值给 state 字段。如果 Post 中的很多方法都是类似的逻辑,我们可以考虑定义一个宏来减少重复(在后面章节我们会讨论宏)。

完全按照面向对象语言中的定义实现状态模式并没有完全利用到 Rust 语言的优势。下面让我们做一些修改,来将无效的状态和状态转换变为编译时错误。

将状态及其行为封装为不同类型

我们将对状态模式进行反思,尝试另外一种不同的取舍。与对于外部代码完全封装状态和状态转移进行解耦不同,我们将状态封装进不同的博文类型。这样,如果我们在只能使用发布状态博文的地方使用了草稿状态的博文,Rust 的类型检查就会产生编译时错误。我们先再来看下之前 main 函数中的第一部分:

fn main() {    let mut post = Post::new();
post.add_text("I ate a salad for lunch today"); assert_eq!("", post.content());}
复制代码

我们仍然保持使用 Post::new 创建博文草稿,并能够增加博文的内容不变。不过和博文处于草稿状态时 content 方法返回空字符串不同,我们将使处于草稿状态的博文没有 content 方法。这样,如果我们尝试获取处于草稿状态的博文的内容,将会产生方法不存在的编译时错误,从而我们也就不可能在生产环境中意外展示出处于草稿状态的博文的内容,这样的代码甚至无法编译通过。为了实现这一点,我们将定义 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); }}
复制代码

Post 和 DraftPost 结构体都包含用于存储博文内容的私有字段 content,但是不再有表示博文状态的 state 字段,因为结构体类型代表了博文的状态。Post 代表发布状态的博文,它包含返回博文内容的 content 方法,并且其仍然定义了一个 Post::new 函数,不过它返回的是 DraftPost 的实例,而不是 Post 实例。DraftPost 代表草稿状态的博文,其中定义了一个 add_text 方法,用于像之前那样向博文中增加内容。注意,DraftPost  中并没有定义 content 方法!现在程序确保了所有博文都从草稿开始,并且处于草稿状态的博文的内容无法进行展示。任何绕过这些限制的尝试都会产生编译时错误。此外,目前还无法创建一个 Post 实例。

实现不同状态的转换

那么如何得到发布状态的博文呢?我们定义的规则是处于草稿状态的博文在发布之前必须通过审核。处于等待审核状态的博文也应该不显示任何内容。下面我们通过增加另一个包含 approve 方法的博文类型 PendingReviewPost 来实现,并在 DraftPost 上增加 request_review 方法来返回 PendingReviewPost。参考下面的例子:

impl DraftPost {    // --snip--    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, } }}
复制代码

在上面的例子中, request_review 和 approve 方法会获取 self 的所有权,因此会消费(类似我们在介绍迭代器时说的消费) DraftPost 和 PendingReviewPost 实例,并分别转换为 PendingReviewPost 和 Post 实例。这样就保证了在调用方法之后 DraftPost 和 PendingReviewPost 实例就会失效。PendingReviewPost 同样也没有定义 content 方法,所以尝试通过 content 方法获取博文内容会导致编译时错误。唯一获得定义了该方法的 Post 实例的途径是调用 PendingReviewPost 的 approve 方法,而得到 PendingReviewPost 的唯一方法是调用 DraftPost 的 request_review 方法,现在我们通过类型实现了发布博文的工作流。

我们还需要对 main 函数做一些小的修改。因为 request_review 和 approve 方法将返回新的类型实例而不是对实例进行修改,所以我们需要通过 let post = 来保存返回的实例;此外,我们不需要再断言处于草稿和等待审核状态的博文的内容为空字符串了,因为根本就无法编译通过。修改后的 main 函数如下:

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());}
复制代码

现在我们的实现不再完全遵守面向对象的状态模式:状态间的转换不再完全封装在 Post 中。但是,得益于 Rust 语言的类型机制和编译时的类型检查,我们获得的好处是,在运行时使用了不正确的状态几乎是不可能的!这避免了某些 bug 的产生,譬如我们将在编译时就发现尝试显示未发布博文内容的行为。为了有更深刻的体会,大家可以尝试通过这种模式实现我们之前建议的其它几个功能。

尽管 Rust 可以实现面向对象设计模式,但是其它的设计模式,如我们刚刚将状态封装进类型的模式,同时在 Rust 中也存在。这些不同的模式在设计上各有取舍。虽然你可能非常熟悉面向对象模式,但是结合 Rust 语言的特性重新对问题进行思考可能会带来额外的收益,譬如在编译时就避免某些 bug 的产生。在 Rust 中面向对象的设计模式并不一定是最好的解决方案,因为 Rust 语言的某些特性,如所有权,在面向对象的语言中是没有的。

总结

不管你是否认为 Rust 是一个面向对象的语言,在阅读本章后,我们知道可以利用 Rust 中的 trait 对象实现部分面向对象的特性。动态分发可以为我们的代码提供一些灵活性,代价是稍微牺牲一些运行时性能。我们可以利用这些灵活性实现面向对象的模式,从而增加代码的可维护性。Rust 还有一些面向对象语言所没有的功能,如所有权。因此,在 Rust 语言中面向对象模式并不总是最好的选择,但是它在我们解决问题时为我们提供了一种选择。

下一章我们将讨论模式(patterns),另一个提供了大量灵活性的 Rust 特性。我们在书中的各处都有看到它们的身影,但还没有见识过它们的全部能力。Let’s go!

发布于: 1 小时前阅读数: 3
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-面向对象编程-设计模式