第18章 面向对象编程特性
面向对象编程(Object-Oriented Programming,OOP)是一种对程序进行建模的方式。对象(object)作为编程概念,最早出现在 20 世纪 60 年代的 Simula 编程语言中。这些对象影响了 Alan Kay 的编程架构,在那套架构里,对象会互相传递消息。1967 年,他创造了 面向对象编程(object-oriented programming)这一术语。对于 OOP 到底是什么,存在许多彼此竞争的定义;按其中一些定义,Rust 是面向对象的,而按另一些定义,它又不是。在本章中,我们会考察一些通常被认为具有“面向对象”特征的能力,并看看这些特征在符合 Rust 习惯的写法中是如何体现的。随后,我们还会展示如何在 Rust 中实现一种面向对象的设计模式,并讨论:与改用 Rust 自身的某些优势来实现同类方案相比,这样做的取舍是什么。
面向对象语言的特征
面向对象语言的特征
关于一门语言必须具备哪些特征才能被视为面向对象,目前在编程社区中并没有共识。Rust 受到了许多编程范式的影响,包括面向对象编程(OOP);例如,在第 13 章中,我们探讨了来自函数式编程的特性。可以说,面向对象的语言共有一些共同的特征,即对象、封装和继承。我们将会讨论这些特征分别是什么,以及 Rust 是否支持它们。
对象包含数据和行为
由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(Addison-Wesley, 1994)编写的书 Design Patterns: Elements of Reusable Object-Oriented Software,通称 The Gang of Four,是一本面向对象设计模式的目录。它这样定义面向对象编程:
Object-oriented programs are made up of objects. An object packages both data and the procedures that operate on that data. The procedures are typically called methods or operations.
面向对象的程序由对象组成。一个对象同时封装了数据以及操作这些数据的过程。这些过程通常被称为方法或操作。
在这个定义下,Rust 是面向对象的:结构体和枚举包含数据而 impl 块提供了在结构体和枚举之上的方法。虽然带有方法的结构体和枚举并不被称为对象,但是参考 The Gang of Four 中对象的定义,它们提供了与对象相同的功能。
封装隐藏了实现细节
另一个通常与面向对象编程关联的概念是 封装(encapsulation):一个对象的实现细节对使用该对象的代码不可见。因此,对象交互的唯一方式是通过其公有 API;使用对象的代码不应能直接触及对象的内部并改变数据或行为。这使得程序员能够更改和重构一个对象的内部实现,而无需改变使用该对象的代码。
我们在第七章讨论了如何控制封装:我们可以使用 pub 关键字来决定代码中的哪些模块、类型、函数和方法是公有的,而默认情况下其他所有内容都是私有的。例如,我们可以定义一个 AveragedCollection 结构体,其中有一个存有 Vec<i32> 的字段。该结构体还可以有一个字段存储向量中值的平均值,从而无需在每次需要时重新计算。换句话说,AveragedCollection 会为我们缓存已计算的平均值。示例 18-1 给出了 AveragedCollection 结构体的定义:
文件名:src/lib.rs
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
示例 18-1: AveragedCollection 结构体维护了一个整型列表及其所有元素的平均值。
该结构体被标记为 pub,这样其他代码就可以使用它,但结构体内的字段仍保持私有。这在这种情况下很重要,因为我们想确保每当列表中添加或删除值时,平均值也会更新。我们通过实现结构体上的 add、remove 和 average 方法来做到这一点,如示例 18-2 所示:
文件名:src/lib.rs
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
示例 18-2: 在 AveragedCollection 结构体上实现了 add、remove 和 average 公有方法
公有方法 add、remove 和 average 是访问或修改 AveragedCollection 实例中数据的唯一途径。当使用 add 方法把一个元素加入到 list 或者使用 remove 方法来删除时,这些方法的实现同时会调用私有的 update_average 方法来更新 average 字段。
list 和 average 是私有的,所以没有其他方式来使得外部的代码直接向 list 增加或者删除元素,否则 list 改变时可能会导致 average 字段不同步。average 方法返回 average 字段的值,这使得外部的代码只能读取 average 而不能修改它。
因为我们已经封装了 AveragedCollection 的实现细节,改动数据结构等内部实现非常简单。例如,可以使用 HashSet<i32> 代替 Vec<i32> 作为 list 字段的类型。只要 add、remove 和 average 这些公有方法的签名保持不变,使用 AveragedCollection 的代码就无需改变。如果我们将 list 设为公有,情况就未必如此:HashSet<i32> 和 Vec<i32> 使用不同的方法增加或移除项,所以直接修改 list 的外部代码很可能需要相应改动。
如果封装被认为是面向对象语言所必要的特征,那么 Rust 满足这个要求。在代码中不同的部分控制 pub 的使用来封装实现细节。
作为类型系统与代码共享的继承
继承(Inheritance)是一种机制:一个对象可以从另一个对象的定义中继承元素,从而获得父对象的数据和行为,无需再次定义。
如果一种语言必须拥有继承才能算作面向对象,那么 Rust 就不是这样的语言。Rust 没有办法在不借助宏的情况下,定义一个结构体去继承父结构体的字段和方法实现。
不过,如果你已经习惯了把继承作为编程工具箱的一部分,那么 Rust 也会根据你最初想借助继承解决的问题,提供其他方案。
选择继承通常有两个主要原因。其一是复用代码:你可以先为某种类型实现特定行为,然后借助继承把这份实现复用到另一种类型上。在 Rust 中,可以通过 trait 方法的默认实现,在一定程度上做到这一点。你在示例 10-14 中已经见过:我们为 Summary trait 的 summarize 方法提供了默认实现。这样,任何实现 Summary trait 的类型,都会自动拥有 summarize 方法,而无需额外编写代码。这很像父类已经实现了某个方法,而继承它的子类也随之拥有这份实现。同样地,在实现 Summary trait 时,我们也可以覆盖 summarize 的默认实现,这又类似于子类去重写从父类继承而来的方法实现。
另一个使用继承的原因和类型系统有关:它可以让子类型出现在父类型能出现的地方。这也被称为多态(polymorphism),意思是如果多个对象共享某些共同特征,那么在运行时就可以把它们彼此替换使用。
多态(Polymorphism)
对很多人来说,多态性与继承同义。但它实际上是一个更广义的概念,指的是可以处理多种类型数据的代码。对继承而言,这些类型通常是子类。
Rust 使用泛型来抽象不同可能的类型,并通过 trait 约束来约束这些类型所必须提供的内容。这有时被称为 bounded parametric polymorphism。
Rust 通过不提供继承,选择了另一组不同的权衡。继承常常有“共享了超出需要的代码”的风险。子类并不总是应该继承父类的全部特征,但使用继承时却往往会这样发生。这会让程序设计变得不够灵活。它还会引入这样一种可能:在子类上调用一些其实并不适用的方法,结果这些方法要么根本说不通,要么会导致错误。另外,一些语言只支持单继承(即一个子类只能继承一个父类),这也进一步限制了程序设计的灵活性。
基于这些原因,Rust 采取了不同的做法:它使用 trait object,而不是继承,来实现运行时多态。接下来我们就来看看 trait object 是如何工作的。
使用 trait object 来抽象出共享行为
使用 trait object 来抽象出共享行为
在第八章中,我们谈到了 vector 只能存储同种类型元素的局限性。示例 8-9 中提供了一个替代方案,通过定义 SpreadsheetCell 枚举,来储存整型、浮点型或文本类型的变体。这意味着,我们可以在每个单元中储存不同类型的数据,并仍能拥有一个代表一排单元的 vector。只要我们需存储的值由一组固定的类型组成,并且在代码编译时就知道具体会有哪些类型,那么这种使用枚举的办法是完全可行的。
然而有时我们希望库用户在特定情况下能够扩展有效的类型集合。为了展示如何实现这一点,这里将创建一个图形用户接口(Graphical User Interface,GUI)工具的例子,它通过遍历列表并调用每一个项目的 draw 方法来将其绘制到屏幕上 —— 此乃一个 GUI 工具的常见技术。我们将要创建一个叫做 gui 的库 crate,它含一个 GUI 库的结构。这个 GUI 库包含一些可供开发者使用的类型,比如 Button 或 TextField。在此之上,gui 的用户希望创建自定义的可以绘制于屏幕上的类型:比如,一个程序员可能会增加 Image,另一个可能会增加 SelectBox。
这个例子中并不会实现一个功能完善的 GUI 库,不过会展示其中各个部分是如何结合在一起的。编写库的时候,我们不可能知晓并定义所有其他程序员希望创建的类型。我们所知晓的是 gui 需要记录一系列不同类型的值,并需要能够对其中每一个值调用 draw 方法。这里无需知道调用 draw 方法时具体会发生什么,只要该值会有那个方法可供我们调用即可。
在拥有继承的语言中,可以定义一个名为 Component 的类,该类上有一个 draw 方法。其他的类比如 Button、Image 和 SelectBox 会从 Component 派生并因此继承 draw 方法。它们各自都可以重写 draw 方法来定义自己的行为,但是框架会把所有这些类型当作是 Component 的实例,并在其上调用 draw。不过 Rust 并没有继承,我们需要寻找另一种方式来设计 gui 库,以便用户能够使用新类型进行扩展。
定义通用行为的 trait
为了实现 gui 所期望的行为,让我们定义一个 Draw trait,其中包含名为 draw 的方法。接着可以定义一个存放trait 对象(trait object)的 vector。trait 对象指向一个实现了我们指定 trait 的类型的实例,以及一个用于在运行时查找该类型的 trait 方法的表。我们通过指定某种指针来创建 trait 对象,例如 & 引用或 Box<T> 智能指针,还有 dyn 关键字,以及指定相关的 trait(第二十章 “动态大小类型和 Sized trait” 部分会介绍 trait 对象必须使用指针的原因)。我们可以使用 trait 对象代替泛型或具体类型。任何使用 trait 对象的位置,Rust 的类型系统会在编译时确保任何在此上下文中使用的值会实现其 trait 对象的 trait。如此便无需在编译时就知晓所有可能的类型。
之前提到过,在 Rust 中,我们刻意不把结构体和枚举称为“对象”,以便把它们和其他语言里的对象区分开来。在结构体或枚举里,字段中的数据和 impl 块里的行为是分开的,而在其他语言中,数据和行为往往会被合并进一个被称为对象的概念里。trait object 和其他语言中的对象也不完全相同,因为我们不能向 trait object 中添加数据。trait object 没有其他语言中的对象那么通用;它的特定用途是为共享行为提供抽象。
示例 18-3 展示了如何定义一个带有 draw 方法的 trait Draw:
文件名:src/lib.rs
pub trait Draw {
fn draw(&self);
}
示例 18-3:Draw trait 的定义
因为第十章已经讨论过如何定义 trait,其语法看起来应该比较眼熟。接下来就是一些新语法:示例 18-4 定义了一个存放了名叫 components 的 vector 的结构体 Screen。这个 vector 的类型是 Box<dyn Draw>,此为一个 trait 对象:它是 Box 中任何实现了 Draw trait 的类型的替身。
文件名:src/lib.rs
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
示例 18-4: 一个 Screen 结构体的定义,它带有一个字段 components,其包含实现了 Draw trait 的 trait 对象的 vector
在 Screen 结构体上,我们将定义一个 run 方法,该方法会对其 components 上的每一个组件调用 draw 方法,如示例 18-5 所示:
文件名:src/lib.rs
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
示例 18-5:在 Screen 上实现一个 run 方法,该方法在每个 component 上调用 draw 方法
这与定义使用了带有 trait 约束的泛型类型参数的结构体不同。泛型类型参数一次只能替代一个具体类型,而 trait 对象则允许在运行时替代多种具体类型。例如,可以定义 Screen 结构体来使用泛型和 trait 约束,如示例 18-6 所示:
文件名:src/lib.rs
pub trait Draw {
fn draw(&self);
}
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
示例 18-6: 一种 Screen 结构体的替代实现,其 run 方法使用泛型和 trait 约束
这限制了 Screen 实例必须拥有一个全是 Button 类型或者全是 TextField 类型的组件列表。如果只需要同质(相同类型,homogeneous)集合,则倾向于使用泛型和 trait 约束,因为其定义会在编译时采用具体类型进行单态化(monomorphized)。
另一方面,通过使用 trait 对象的方法,一个 Screen 实例可以存放一个既能包含 Box<Button>,也能包含 Box<TextField> 的 Vec<T>。让我们看看它是如何工作的,接着会讲到其运行时性能影响。
实现 trait
现在来增加一些实现了 Draw trait 的类型。我们将提供 Button 类型。再一次重申,真正实现 GUI 库超出了本书的范畴,所以 draw 方法体中不会有任何有意义的实现。为了想象一下这个实现看起来像什么,一个 Button 结构体可能会拥有 width、height 和 label 字段,如示例 18-7 所示:
文件名:src/lib.rs
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// 实际绘制按钮的代码
}
}
示例 18-7: 一个实现了 Draw trait 的 Button 结构体
在 Button 上的 width、height 和 label 字段会和其他组件不同;比如 TextField 可能有 width、height、label 以及 placeholder 字段。每一个我们希望能在屏幕上绘制的类型都会使用不同的代码来实现 Draw trait 的 draw 方法来定义如何绘制特定的类型,像这里的 Button 类型(如上提到的并不包含任何实际的 GUI 代码)。除了实现 Draw trait 之外,比如 Button 还可能有另一个包含按钮点击如何响应的方法的 impl 块。这类方法并不适用于像 TextField 这样的类型。
如果一些库的使用者决定实现一个包含 width、height 和 options 字段的结构体 SelectBox,并且也为其实现了 Draw trait,如示例 18-8 所示:
文件名:src/main.rs
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
fn main() {}
示例 18-8: 另一个使用 gui 的 crate,其在 SelectBox 结构体上实现 Draw trait
库使用者现在可以在他们的 main 函数中创建一个 Screen 实例。至此可以通过将 SelectBox 和 Button 放入 Box<T> 转变为 trait 对象再放入 Screen 实例中。接着可以调用 Screen 的 run 方法,它会调用每个组件的 draw 方法。示例 18-9 展示了这个实现:
文件名:src/main.rs
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
use gui::{Button, Screen};
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}
示例 18-9: 使用 trait 对象来存储实现了相同 trait 的不同类型的值
当编写库的时候,我们不知道何人会在何时增加 SelectBox 类型,不过 Screen 的实现能够操作并绘制这个新类型,因为 SelectBox 实现了 Draw trait,这意味着它实现了 draw 方法。
这个概念 —— 只关心值所反映的信息而不是其具体类型 —— 类似于动态类型语言中称为鸭子类型(duck typing)的概念:如果它走起来像一只鸭子,叫起来像一只鸭子,那么它就是一只鸭子!在示例 18-5 中 Screen 上的 run 实现中,run 并不需要知道各个组件的具体类型是什么。它并不检查组件是 Button 或者 SelectBox 的实例,而是直接调用组件的 draw 方法。通过指定 Box<dyn Draw> 作为 components vector 中值的类型,我们就定义了 Screen 为需要可以在其上调用 draw 方法的值。
使用 trait 对象和 Rust 类型系统来进行类似鸭子类型操作的优势是无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现 trait 对象所需的 trait 则 Rust 不会编译这些代码。
例如,示例 18-10 展示了当创建一个使用 String 做为其组件的 Screen 时发生的情况:
文件名:src/main.rs
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
示例 18-10: 尝试使用一种没有实现 trait 对象的 trait 的类型
我们会遇到这个错误因为 String 没有实现 Draw trait:
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src/main.rs:5:26
|
5 | components: vec![Box::new(String::from("Hi"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
= help: the trait `Draw` is implemented for `Button`
= note: required for the cast from `Box<String>` to `Box<dyn Draw>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error
这个错误告诉我们:要么是我们把一个本来就不该传给 Screen 的类型传进来了,因此应该换成别的类型;要么就该为 String 实现 Draw,让 Screen 能够对它调用 draw。
trait 对象执行动态分发
回忆一下第十章 “泛型代码的性能” 部分讨论过的,当对泛型使用 trait 约束时编译器所执行的单态化处理:编译器为每一个被泛型类型参数代替的具体类型生成了函数和方法的非泛型实现。单态化产生的代码在执行静态分发(static dispatch),也就是说编译器在编译时就知晓要调用什么方法。这与动态分发 (dynamic dispatch)相对,这时编译器在编译时无法知晓要调用哪个方法。在动态分发的场景下,编译器会生成负责在运行时确定该调用什么方法的代码。
当使用 trait 对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于 trait 对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust 在运行时使用 trait 对象中的指针来知晓需要调用哪个方法。这种查找会带来在静态分发中不会产生的运行时开销。动态分发也阻止编译器有选择地内联方法代码,这会相应地禁用一些优化,Rust 还定义了一些规则,称为dyn 兼容性(dyn compatibility),用于规定可以和不可以在哪些地方使用动态分发。这些规则超出了本讨论范围,但你可以在参考资料中详细了解。尽管在编写示例 18-5 和可以支持示例 18-9 中的代码的过程中确实获得了额外的灵活性,但仍然需要权衡取舍。
面向对象设计模式的实现
实现一个面向对象设计模式
状态模式(state pattern)是一个面向对象设计模式。该模式的关键在于定义值的一系列内含状态。这些状态体现为一系列的状态对象(state objects),同时值的行为随着其内部状态而改变。我们将编写一个博客发布结构体的例子,它拥有一个包含其状态的字段,该字段可以是 “draft”、“review” 或 “published” 状态对象之一。
状态对象共享功能:当然,在 Rust 中使用结构体和 trait 而不是对象和继承。每一个状态对象负责其自身的行为,以及该状态何时应当转移至另一个状态。持有一个状态对象的值对于不同状态的行为以及何时状态转移毫不知情。
使用状态模式的优点在于,程序的业务需求改变时,无需改变值持有状态或者使用值的代码。我们只需更新某个状态对象中的代码来改变其规则,或者是增加更多的状态对象。
首先我们将以一种更加传统的面向对象的方式实现状态模式,接着使用一种在 Rust 中更自然的方式。让我们使用状态模式来增量式地实现一个发布博文的工作流以探索这个概念。
最终功能看起来像这样:
- 博文从空白的草稿开始。
- 一旦草稿完成,请求审核博文。
- 一旦博文过审,它将被发表。
- 只有被发表的博文的内容会被打印,这样就不会意外打印出没有被审核的博文的文本。
任何其他对博文的修改尝试都不会生效。例如,如果尝试在请求审核之前通过一个草稿博文,博文应该保持未发布的状态。
尝试采用传统的面向对象风格
实现同一个问题的代码组织方式有无穷多种,而且各自都有不同取舍。本节的实现更偏向一种传统的面向对象风格:在 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 所示。还需定义一个私有 trait State 用于定义 Post 的状态对象所必须有的行为。
Post 将在私有字段 state 中存放一个 Option<T> 类型的 trait 对象 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 {}
示例 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 方法返回一个空字符串 slice,因为博文仍然处于草稿状态,如示例 18-11 的第 7 行所示。现在让我们使用能满足要求的最简单的方式来实现 content 方法:总是返回一个空字符串 slice。当实现了将博文状态改为发布的能力之后将改变这一做法。但是目前博文只能是草稿状态,这意味着其内容应该总是空的。示例 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 方法的占位实现,它总是返回一个空字符串 slice
通过增加这个 content 方法,示例 18-11 中直到第 7 行的代码能如期运行。
请求审核,从而改变文章状态
接下来需要增加请求审核博文的功能,这应当将其状态由 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 增加一个获取 self 可变引用的公有方法 request_review。接着在 Post 的当前状态下调用内部的 request_review 方法,并且第二个 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 值设置为这个操作的结果。
我们需要将 state 临时设置为 None 来获取 state 值,即老状态的所有权,而不是使用 self.state = self.state.request_review(); 这样的代码直接更新状态值。这确保了当 Post 被转换为新状态后不能再使用老 state 值。
Draft 的 request_review 方法需要返回一个新的,装箱的 PendingReview 结构体的实例,其用来代表博文处于等待审核状态。结构体 PendingReview 同样也实现了 request_review 方法,不过它不进行任何状态转换。相反它返回自身,因为当我们请求审核一个已经处于 PendingReview 状态的博文,它应该继续保持 PendingReview 状态。
现在我们能看出状态模式的优势了:无论 state 是何值,Post 的 request_review 方法都是一样的。每个状态只负责它自己的规则。
我们将继续保持 Post 的 content 方法实现不变,返回一个空字符串 slice。现在我们可以拥有 PendingReview 状态和 Draft 状态的 Post 了,不过我们希望在 PendingReview 状态下 Post 也有相同的行为。现在示例 18-11 中直到 10 行的代码是可以执行的!
添加 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 方法
这里为 State trait 增加了 approve 方法,并新增了一个实现了 State 的结构体,Published 状态。
类似于 PendingReview 中 request_review 的工作方式,如果对 Draft 调用 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 值。这就是一个第十二章 “当你比编译器知道更多时” 部分讨论过的我们知道 None 是不可能的而编译器却不能理解的情况之一。
接着我们就有了一个 &Box<dyn State>,当调用其 content 时,解引用强制转换会作用于 & 和 Box ,这样最终会调用实现了 State trait 的类型的 content 方法。这意味着需要为 State trait 定义增加 content,这也是放置根据所处状态返回什么内容的逻辑的地方,如示例 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: 为 State trait 增加 content 方法
这里增加了一个 content 方法的默认实现来返回一个空字符串 slice。这意味着无需为 Draft 和 PendingReview 结构体实现 content 了。Published 结构体会重写 content 方法并会返回 post.content 的值。
注意这个方法需要生命周期注解,如第十章所讨论的。这里获取 post 的引用作为参数,并返回 post 一部分的引用,所以返回的引用的生命周期与 post 参数相关。
现在示例完成了 —— 现在示例 18-11 中所有的代码都能工作!我们通过发布博文工作流的规则实现了状态模式。围绕这些规则的逻辑都存在于状态对象中而不是分散在 Post 之中。
为什么不用枚举?
你可能会好奇为什么不用包含不同可能的博文状态变体的
enum作为变量。这确实是一个可能的方案;尝试实现并对比最终结果来看看哪一种更适合你!使用枚举的一个缺点是每一个检查枚举值的地方都需要一个match表达式或类似的代码来处理所有可能的变体。这相比 trait 对象模式可能显得更重复。
评估状态模式
我们展示了 Rust 是能够实现面向对象的状态模式的,以便能根据博文所处的状态来封装不同类型的行为。Post 的方法并不知道这些不同类型的行为。通过这种组织代码的方式,要找到所有已发布博文的不同行为只需查看一处代码:Published 的 State trait 的实现。
如果要创建一个不使用状态模式的替代实现,则可能会在 Post 的方法中,或者甚至于在 main 代码中用到 match 语句,来检查博文状态并在这里改变其行为。这意味着需要查看很多位置来理解处于发布状态的博文的所有逻辑!这在增加更多状态时会变得更糟:每一个 match 语句都会需要另一个分支。
对于状态模式来说,Post 的方法和使用 Post 的位置无需 match 语句,同时增加新状态只涉及到增加一个新 struct 和为其实现 trait 的方法。
这个实现易于扩展增加更多功能。为了体会使用此模式维护代码的简洁性,请尝试如下一些建议:
- 增加
reject方法将博文的状态从PendingReview变回Draft。 - 在将状态变为
Published之前要求两次approve调用。 - 只允许博文处于
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 中的很多方法都遵循这个模式,我们可能会考虑定义一个宏来消除重复(查看第二十章的 “宏” 部分)。
完全按照面向对象语言的定义实现这个模式并没有尽可能地利用 Rust 的优势。让我们看看一些代码中可以做出的修改,来将无效的状态和状态转移变为编译时错误。
将状态和行为编码为类型
我们将展示如何稍微反思状态模式来进行一系列不同的权衡取舍。不同于完全封装状态和状态转移使得外部代码对其毫不知情,我们将状态编码进不同的类型。如此,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 的实例。现在不可能创建一个 Post 实例,因为 content 是私有的同时没有任何函数返回 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: PendingReviewPost 通过调用 DraftPost 的 request_review 创建,approve 方法将 PendingReviewPost 变为发布的 Post
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 实现中。然而,得益于类型系统和编译时类型检查,我们得到的收获是无效状态是不可能的了!这确保了某些特定的 bug,比如显示未发布博文的内容,将在部署到生产环境之前被发现。
尝试为示例 18-21 之后的 blog crate 实现这一部分开始所建议的任务来体会使用这个版本的代码是何感觉。注意在这个设计中一些需求可能已经完成了。
我们已经看到,虽然 Rust 能够实现面向对象设计模式,但 Rust 还提供了诸如将状态编码进类型系统之类的其他模式。这些模式有着不同的权衡取舍。虽然你可能非常熟悉面向对象模式,重新思考这些问题来利用 Rust 提供的像在编译时避免一些 bug 这样的有益功能。在 Rust 中面向对象模式并不总是最好的解决方案,因为 Rust 拥有像所有权这样的面向对象语言所没有的特性。
总结
阅读本章后,不管你是否认为 Rust 是一个面向对象语言,现在你都见识了 trait 对象是一个 Rust 中获取部分面向对象功能的方法。动态分发可以通过牺牲少量运行时性能来为你的代码提供一些灵活性。这些灵活性可以用来实现有助于代码可维护性的面向对象模式。Rust 也有像所有权这样不同于面向对象语言的特性。面向对象模式并不总是利用 Rust 优势的最好方式,但也是一个可用选项。
接下来,让我们看看另一个提供了多样灵活性的 Rust 功能:模式。我们在全书中已多次简要提及它们,但尚未充分领略它们的全部威力。让我们开始探索吧!