Rust Book 编译版 18 · 面向对象语言特性:trait 对象与多态
编译自 The Rust Programming Language(2024 edition),在原文基础上做了删减与补充。© The Rust Project Developers,依据 MIT OR Apache-2.0 许可编译转载。
开始之前
「Rust 是不是一门面向对象语言」是社区里被争论多年的经典问题,而它的答案取决于你拿哪把尺子量。「对象」这个概念 1960 年代诞生于 Simula;Alan Kay 受其影响提出「对象之间互相传递消息」的架构,并在 1967 年造出「面向对象编程」这个词——按消息传递那一派的标准,Rust 不是面向对象语言。可若按更宽松的定义(比如本章引用的「四人帮」定义),它又部分是。与其站队,不如把这个问题当引子:没有继承的 Rust,拿什么实现多态(polymorphism)?
第 10 章讲泛型时埋过一个伏笔:泛型加 trait 约束会在编译期单态化(monomorphization),调用目标在编译期就定死——静态分派(static dispatch);而本章的主角 trait 对象(trait object)走另一条路——运行期才决定调用目标,动态分派(dynamic dispatch)。两条路线各自的代价,以及如何用它们落地面向对象的设计模式(本章以状态模式为例),就是这一章的全部内容。
前置知识:第 17 章「异步编程基础」(连载顺序的上一章,本章本身不涉及异步);trait 与泛型语法是硬前置,生疏的话回看第 10 章。
你会学到:
- 对照「对象、封装、继承」三个 OOP 特征,逐条判断 Rust 满足与否
- 用 trait 对象(
Box<dyn Draw>)实现运行时多态,让一个集合混装不同类型 - 说清静态分派与动态分派各自的代价:单态化的代码膨胀 vs 一次间接跳转
- 用 trait 对象实现状态模式(
Post+State的博客工作流) - 用「把状态编码为类型」的写法,把非法状态变成编译错误
面向对象语言的特征
编程社区对「必须具备哪些特性才算面向对象」并无共识。Rust 本身受多种范式影响,OOP 只是其中之一。通常被归为 OOP 特征的是三样:对象、封装、继承。下面逐一看它们各自的含义,以及 Rust 是否提供。
对象包含数据和行为
Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 合著的《设计模式:可复用面向对象软件的基础》(Design Patterns: Elements of Reusable Object-Oriented Software,Addison-Wesley,1994),俗称「四人帮」(The Gang of Four)之书,是面向对象设计模式的目录式经典。它这样定义 OOP:
面向对象程序由对象组成。对象(object)把数据和操作这些数据的过程打包在一起。这些过程通常被称为方法(method)或操作(operation)。
按这个定义,Rust 是面向对象的:结构体和枚举持有数据,impl 块提供方法。虽然 Rust 刻意不把它们称作「对象」,但按四人帮的定义,功能上等同。
隐藏实现细节的封装
封装(encapsulation)指使用对象的代码无法触及对象的实现细节,交互的唯一途径是公开 API——这样程序员可以随意重构内部实现而不惊动调用方。第 7 章讲过 Rust 的这套机制:pub 决定哪些模块、类型、函数和方法公开,其余默认私有。
例如定义一个 AveragedCollection:一个字段存 i32 向量(vector),另一个字段缓存平均值,需要时不必重算。示例 18-1 给出了定义。
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
示例 18-1:一个维护整数列表以及集合中各元素平均值的 AveragedCollection 结构体
结构体标了 pub,字段却保持私有——这里至关重要,因为要保证每次增删元素时平均值同步更新。做法是实现 add、remove、average 三个方法,如示例 18-2 所示。
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 都会调用私有的 update_average,由它同步 average 字段。字段私有意味着外部无法绕过方法直接改 list,average 也就不会失去同步;average 方法允许外界读这个值,但不能改。
封装的回报是内部实现可以随时更换:比如把 list 从 Vec<i32> 换成 HashSet<i32>,只要公开方法的签名不变,调用方一行不用改——若 list 当初是公开的,直接操作它的外部代码就得跟着改了。所以,若以封装为判据,Rust 满足面向对象的要求;可选的 pub 正是封装得以成立的基础。
作为类型系统与代码共享手段的继承
继承(inheritance)让一个对象直接获得另一个对象的元素:父对象的数据和行为,无需重写。若一门语言必须有继承才算面向对象,Rust 不是——不借助宏,没有办法定义一个能继承父结构体字段和方法实现的结构体。
但先别急着数缺了什么,先问当初要继承干什么。人们用继承主要出于两个目的。
其一是代码复用:为一个类型写好的行为,想在另一个类型上重用。Rust 的对应物是 trait 方法的默认实现——第 10 章为 Summary trait 的 summarize 提供过默认实现,任何实现 Summary 的类型无需额外代码就能用它,也可以按需覆盖,效果类似父类方法被子类继承和覆写。
其二是类型系统:让子类型能用在父类型可以出现的地方——也就是多态。
多态
多态常被当成继承的同义词,其实是个更宽泛的概念:代码能处理多种类型的数据(对继承而言,这些类型一般是子类)。Rust 的做法是用泛型抽象「可能的类型」,用 trait 约束(trait bound)划定「类型必须提供什么」,这套组合有时被称为受约束的参数化多态(bounded parametric polymorphism)。
Rust 拒绝继承,是选了另一组权衡。继承常面临共享过多代码的风险:子类并不总该继承父类的全部特征,机制上却照单全收,设计因此僵化,甚至会在子类上调用到毫无意义的方法;不少语言还只允许单继承(single inheritance,一个子类只能继承一个类),进一步限制灵活性。所以 Rust 用 trait 对象取代继承来实现运行时多态。我们来看看 trait 对象是如何工作的。
使用 trait 对象抽象共享行为
第 8 章提过向量的局限:只能存同一种类型的元素。当时的变通是定义 SpreadsheetCell 枚举,用不同变体分别持有整数、浮点数和文本——当可互换的类型是编译期已知的固定集合时,这是好方案。
但有时我们希望库的用户能扩充合法类型的集合。来做一个例子:一个 GUI 工具遍历条目列表,对每个条目调用 draw 方法画到屏幕上——GUI 工具的常用技术。我们创建名为 gui 的库 crate,自带 Button、TextField 之类供人使用的类型;用户还会想创建自己的可绘制类型——有人加 Image,有人加 SelectBox。
写库时我们无法预知并定义用户将来要创建的所有类型,只知道 gui 需要跟踪许多不同类型的值,并对每个值调用 draw——不必知道 draw 时会发生什么,只要值上有这个方法可调。放在支持继承的语言里,我们会定义一个带 draw 方法的 Component 类,让 Button、Image、SelectBox 继承它并各自覆盖 draw,框架把它们统一当 Component 处理。Rust 没有继承,需要另一种方式组织 gui 库,允许用户创建与库兼容的新类型。
为共有行为定义 trait
方案:定义一个只有 draw 方法的 Draw trait,再定义一个装 trait 对象的向量。trait 对象同时指向两样东西:一个实现了指定 trait 的类型的实例,加上一张运行时在该类型上查找 trait 方法的表。创建方式是「某种指针 + dyn 关键字 + trait」,指针可以是引用或 Box<T> 智能指针(smart pointer)(为什么必须是指针,第 20 章的「动态大小类型与 Sized trait」再讨论)。trait 对象可以替代泛型或具体类型使用,Rust 的类型系统会在编译期保证:凡在该上下文中用到的值都实现了对应的 trait——我们不必在编译期知道所有可能的类型。
编译注:实现层面,
Box<dyn Draw>是一个「胖指针」(fat pointer):两个字——一个指向数据,一个指向 vtable,表里登记着该类型的方法地址以及大小、对齐、析构等信息。对照 C++ 会很直观:C++ 把虚表指针(vptr)存进对象内部,多态类的每个实例都背着它;Rust 把 vtable 挂在指针上,于是具体类型的Box<Button>仍是单字指针、调用直达。代价是 Rust 对「哪些 trait 能当 trait 对象用」有一套比 C++ 虚函数严格得多的规则——后文称 dyn 兼容性,本章我们会撞上它两次。
另外,Rust 刻意不把结构体和枚举称为「对象」,就是为了和 OOP 语言里数据与行为合一的对象区分开——结构体字段的数据与 impl 块的行为是分开写的。trait 对象则与 OOP 对象神似而不同:不能往 trait 对象里添加数据,它不如其他语言的对象通用,用途就是抽象共有行为。
示例 18-3 定义 Draw trait。
pub trait Draw {
fn draw(&self);
}
示例 18-3:Draw trait 的定义
定义 trait 的语法是第 10 章见过的,新东西在下一个:示例 18-4 的 Screen 结构体持有 components 向量,类型是 Box<dyn Draw>——这就是 trait 对象,代表任何装在 Box 里且实现了 Draw 的类型。
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
示例 18-4:Screen 结构体的定义,其 components 字段持有一个由实现了 Draw trait 的 trait 对象组成的向量
再给 Screen 实现一个 run 方法,对每个组件调用 draw,如示例 18-5 所示。
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
示例 18-5:Screen 上的 run 方法,它对每个组件调用 draw 方法
这与「泛型类型参数 + trait 约束」的写法工作方式不同:泛型参数一次只能代入一种具体类型,trait 对象则允许运行时用多种具体类型顶替。比如我们本可以像示例 18-6 那样用泛型定义 Screen。
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 约束的另一种实现
示例 18-6 把 Screen 限制为组件要么全是 Button、要么全是 TextField。若集合天然同构,泛型 + trait 约束更可取——定义会在编译期单态化为具体类型。而 trait 对象版本的一个 Screen 实例,可以同时持有装着 Box<Button> 和 Box<TextField> 的 Vec<T>。看看它怎么工作,再谈运行时性能的影响。
实现 trait
现在添加几个实现 Draw 的类型,从 Button 开始。真正的 GUI 库超出本书范围,draw 方法体里不会有有用的实现;可以设想 Button 有 width、height、label 字段,如示例 18-7 所示。
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 的字段将不同于其他组件——比如 TextField 可能是同样几个字段再加一个 placeholder。每个要绘制的类型都实现 Draw,但 draw 里的代码各写各的。Button 或许还有另一个 impl 块放点击相关的那些方法,它们对 TextField 这类类型并不适用。
库的用户若想要一个带 width、height、options 字段的 SelectBox,同样实现 Draw 即可,如示例 18-8 所示。
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// 真正绘制选择框的代码
}
}
示例 18-8:另一个 crate 使用 gui 并在一个 SelectBox 结构体上实现 Draw trait
然后是用户的 main:把 SelectBox 和 Button 分别装进 Box<T> 成为 trait 对象加入 Screen 实例,再调用 run,它会依次对每个组件调用 draw。示例 18-9 展示了完整实现。
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 照样能操作并绘制这个新类型——因为它实现了 Draw,也就实现了 draw 方法。
编译注:这一幕正是同一本《设计模式》里「优先使用对象组合,而非类继承」原则的落点:
Screen不是任何组件的父类,它只是持有Vec<Box<dyn Draw>>并把绘制工作委托出去。继承式 GUI 框架里,Button是一个Component(is-a);这里Button实现Draw(can-do),Screen拥有组件(has-a)。新增SelectBox不必碰gui库一行代码——对扩展开放、对修改关闭,靠的是组合而非继承链。
这种「只关心值会响应哪些消息、不关心值具体是什么类型」的做法,类似动态类型语言中的鸭子类型(duck typing):走起来像鸭子、叫起来像鸭子,那它就是鸭子。示例 18-5 的 run 不检查组件是 Button 还是 SelectBox 实例,只是调用 draw;把 components 的类型定为 Box<dyn Draw>,Screen 所要求的就只剩「值上能调用 draw」。
用 trait 对象加 Rust 类型系统来写「编译期检查的鸭子类型」,好处是:完全不必在运行时检查某个值是否实现了某方法,也不必担心对没实现该方法的值调用它而出错——值若没实现所需 trait,Rust 根本不会让代码编译通过。示例 18-10 试试用 String 当组件创建 Screen 会怎样。
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
示例 18-10:尝试使用一个没有实现 trait 对象所需 trait 的类型
String 没实现 Draw,于是得到这个错误:
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`
--> src/lib.rs:23:1
|
23 | impl Draw 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。
执行动态分发
第 10 章讨论过编译器对泛型执行的单态化(「使用泛型的代码的性能」):为每种替代泛型参数的具体类型,生成函数和方法的非泛型实现。单态化产物执行的是静态分派——编译期就知道调用的是哪个方法;与之相对的动态分派,则是编译期判断不出调用目标,生成的代码要到运行时才知道该调用哪个方法。
使用 trait 对象,Rust 就必须动态分派:编译器不知道代码会搭配哪些类型,也就不知道该调用实现在哪个类型上的哪个方法。运行时,Rust 利用 trait 对象内部的指针查出该调用的方法。这次查找带来静态分派下不存在的运行时开销;动态分发还让编译器无法内联方法代码,进而错过某些优化。另外,Rust 对哪些地方能用动态分发有一套规则,称 dyn 兼容性(dyn compatibility),超出本节范围,可查参考手册。不过示例 18-5 编写、示例 18-9 运用的代码确实换来了额外的灵活性,这是一笔值得斟酌的取舍。
编译注:给一组性能直觉:静态分派花的是空间——单态化按具体类型复制代码,一个泛型函数被 5 种类型实例化就是 5 份机器码,编译时间和二进制体积随之上涨;动态分派花的是时间——每次调用多一次经 vtable 的间接跳转,本身很便宜(与 C++ 虚函数同量级),真正的损失是编译器看不见调用目标、无法内联,而内联常常是后续优化的前提。这组取舍和 C++ 的「模板 vs 虚函数」完全同构,直觉可以直接平移。第 13 章说迭代器是零成本抽象,前提正是它走静态分派。
静态分派(泛型 + 单态化)
编译期为每种具体类型生成一份专属实现
Box<Button> ──draw()──► 直达 Button::draw(可内联)
代价:代码随实例化的类型数膨胀
收益:零成本抽象
动态分派(trait 对象)
只生成一份代码,运行时才确定调用目标
Box<dyn Draw> = [ 数据指针 │ vtable 指针 ]
draw() ──► 查 vtable ──► 跳转到 Button::draw
代价:一次间接跳转,难以内联
收益:一份代码,运行期混装多种类型
实现一种面向对象设计模式
状态模式(state pattern)是一种面向对象设计模式:为一个值内部可能处于的状态定义集合,状态由一组状态对象(state object)表示,值的行为随其状态改变。本章的例子是一个博客文章结构体,用一个字段保存状态对象,取值是「草稿」「审核」或「已发布」之一。
在 Rust 里,状态对象用结构体和 trait 实现(而非对象和继承)。每个状态对象各自负责自己的行为、掌管自己何时转变为另一种状态;持有状态对象的那个值,对各状态的差异和转换规则一无所知。状态模式的好处是:业务需求变化时,不必改持有状态的值的代码,也不必改使用它的代码,只需更新某个状态对象内部的规则,或新增状态对象。
我们先按传统面向对象风格实现一遍,再看一种更 Rust 的做法,用状态模式增量式实现一个博客文章工作流。最终功能:
- 博客文章最初是一篇空草稿。
- 草稿完成后,请求审核这篇文章。
- 文章获得批准后,即可发布。
- 只有已发布的文章才会返回可打印的内容,这样未经批准的文章就不可能被意外发布。
对文章尝试的其他变更一律无效——比如在请求审核之前就批准一篇草稿,它应仍是一篇未发布的草稿。
尝试传统的面向对象风格
同一问题有无数种组织方式,各有权衡。本节的实现偏传统面向对象风格:Rust 写得出来,但没用上 Rust 的一些优势;稍后会演示另一种方案,它仍属面向对象设计模式,组织方式却可能让有面向对象经验的程序员觉得陌生。对比两版,体会用不同于其他语言的方式设计 Rust 代码的得失。
示例 18-11 把工作流写成代码:这是将在名为 blog 的库 crate 中实现的 API 的用法示例,眼下还编译不过——blog crate 尚未实现。
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 创建草稿文章、add_text 添加文本;批准之前立刻取内容,应得不到任何文本——文章还是草稿。代码里的 assert_eq! 用于演示(绝佳的单元测试是断言草稿的 content 返回空串,这里不展开写测试)。接下来 request_review 发起审核,等待审核期间 content 仍返回空串;批准后文章发布,content 返回正文。
注意我们与这个 crate 打交道的类型只有 Post。它用状态模式持有三个状态对象之一——草稿、审核或已发布——状态切换在 Post 内部管理:用户调用方法时状态随之变化,但用户不必直接管理切换,也犯不下状态错误,比如发布一篇尚未审核的文章。
定义 Post 并创建新实例
开写。先定义保存内容的公有 Post 结构体和创建实例的关联公有函数 new,如示例 18-12;再加一个私有 State trait,定义 Post 的所有状态对象必须具备的行为。Post 在私有字段 state 里用 Option<T> 包着 Box<dyn State> 保存状态对象——为什么需要 Option,稍后见分晓。
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 结构体
状态对象将有 Draft、PendingReview、Published 三个,都实现 State trait。眼下 trait 还没有任何方法,先只定义 Draft——我们希望文章从这个状态起步。new 把 state 设为装着 Draft 新实例的 Some,保证每篇新文章都以草稿起步;state 字段私有,外界根本没办法造出处于其他状态的 Post。content 则初始化为空 String。
存储文章内容的文本
add_text 接受一个 &str,追加为文章的文本内容。把它实现为方法、而不是把 content 字段暴露为 pub,以后就能自行控制 content 的数据如何被读取。实现很简单,如示例 18-13。
impl Post {
// --省略--
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
示例 18-13:实现 add_text 方法,向文章的 content 添加文本
它拿 &mut self(要修改实例),对 content 里的 String 调用 push_str 追加文本。这个行为不依赖文章当前的状态,因此不属于状态模式的一部分——add_text 完全不与 state 字段交互,但确属我们想支持的行为。
确保草稿文章的内容为空
即便 add_text 加了内容,草稿状态下 content 也应返回空的字符串切片——示例 18-11 的第一个 assert_eq! 要求的正是这个。眼下先用最简单的占位实现:始终返回空串,等实现发布能力后再回来改。反正此刻文章只能处于草稿状态,内容理应为空。示例 18-14。
impl Post {
// --省略--
pub fn content(&self) -> &str {
""
}
}
示例 18-14:为 Post 的 content 方法添加总是返回空字符串切片的占位实现
有了这个方法,示例 18-11 直到第一个 assert_eq! 为止的代码都能如期工作。
请求审核,改变文章的状态
下一步:请求审核,把状态从 Draft 变为 PendingReview,如示例 18-15 所示。
impl Post {
// --省略--
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 拿 &mut self,在当前状态上调用一个内部的 request_review,由后者消费当前状态并返回新状态。
内部的 request_review 进入 State trait,所有实现该 trait 的类型都得实现它。注意第一个参数不是 self、&self 或 &mut self,而是 self: Box<Self>——这个方法只有经持有该类型的 Box 调用才有效,且会取得 Box<Self> 的所有权,让旧状态就此失效,Post 的状态值得以转变为新状态。
要消费旧状态就得拿走它的所有权,这正是 state 字段里 Option 的用武之地:调用 take 把 Some 值取出来、原位留下 None(Rust 不允许结构体中存在未填充值的字段),这样 state 值是从 Post 中移出而不是借用,随后再把结果赋回去。不能直接写 self.state = self.state.request_review();——等号右边需要 Box 的所有权,而 self.state 无法在被借用的 self 里整体移出。临时置 None 的写法也顺带保证了:转换成新状态之后,Post 再也用不上旧的 state 值。
Draft 的 request_review 返回装箱的全新 PendingReview 实例,代表文章等待审核;PendingReview 的 request_review 原样返回 self——已处于待审核的文章再次请求审核,应保持原状。
状态模式的优势开始显现:无论 state 是什么,Post 上的 request_review 都是同一份代码,每个状态各自负责自己的规则。content 保持原样仍返回空串,于是示例 18-11 能一直运行到第二个 assert_eq! 了。
添加 approve 来改变 content 的行为
approve 与 request_review 类似:把 state 设为当前状态被批准后应有的值,如示例 18-16 所示。
impl Post {
// --省略--
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 approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
// --省略--
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 状态。对 Draft 调用 approve 没有效果(返回 self);对 PendingReview 调用则返回装箱的 Published 实例;Published 的 request_review 和 approve 都返回自身——已发布的文章在这些情形下保持原状。
接着更新 Post::content:返回值应取决于当前状态,所以让 Post 把工作委托给 state 上定义的 content 方法,如示例 18-17 所示。
impl Post {
// --省略--
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
// --省略--
}
示例 18-17:更新 Post 上的 content 方法,委托给 State 上的 content 方法
目标是把规则都留在实现 State 的结构体内部,所以调用 state 值上的 content 并把文章实例(self)作为参数传入,返回其结果。先对 Option 调用 as_ref:要的是 Option 中值的引用而非所有权,state 是 Option<Box<dyn State>>,as_ref 返回 Option<&Box<dyn State>>;不这样做就无法把 state 从 &self 中移出,会得到错误。再调用 unwrap:Post 的各方法保证了执行完毕后 state 总包含 Some 值,所以它绝不会 panic——这属于第 9 章讨论过的「当你比编译器拥有更多信息时」的情形:我们明知 None 不可能出现,只是编译器无法理解。
此时在 &Box<dyn State> 上调用 content,解引用强转(deref coercion)会作用于 & 和 Box,让方法最终在实现 State 的具体类型上被调用。这意味着要把 content 加进 State trait 的定义,并把「根据状态决定返回什么」的逻辑放在那里,如示例 18-18 所示。
trait State {
// --省略--
fn content<'a>(&self, post: &'a Post) -> &'a str {
""
}
}
// --省略--
struct Published {}
impl State for Published {
// --省略--
fn content<'a>(&self, post: &'a Post) -> &'a str {
&post.content
}
}
示例 18-18:为 State trait 添加 content 方法
content 有了返回空字符串切片的默认实现,于是 Draft 和 PendingReview 不必实现它;Published 覆盖默认实现,返回 post.content 的值。方便是方便,但让 State 上的方法决定 Post 的内容,也让 State 与 Post 的职责界限变得模糊。另外,正如第 10 章讨论过的,这个方法需要生命周期标注:以引用形式接收 post,返回指向该 post 一部分的引用,返回值的生命周期就与 post 参数绑定了。
大功告成——示例 18-11 可以完整运行了。我们按博客工作流的规则实现了状态模式,规则相关的逻辑都驻留在各状态对象中,没有散落在 Post 各处。
为什么不用枚举?
也许你一直在纳闷:为什么不用一个把各种状态作为变体的枚举?这当然可行,不妨动手试试,对比看更喜欢哪种。缺点是:每个检查枚举值的地方都需要 match 表达式或类似构造来处理每一种变体,可能比这里的 trait 对象方案更重复。
权衡状态模式
我们展示了 Rust 能实现面向对象的状态模式,把文章在各状态下的不同行为封装起来。Post 的方法对这些行为一无所知;想知道已发布文章有哪些行为,只需看一个地方:Published 对 State trait 的实现。不用状态模式的版本会在 Post 的方法里、甚至 main 里用 match 检查状态并就地改变行为——理解「已发布」的全部影响得跑多个地方查看。采用状态模式后,Post 的方法和使用它的代码都不需要 match;新增状态只需在一个位置加一个结构体、为它实现相应 trait 方法。
这套实现很容易扩展,试试下面几条,体会维护状态模式代码有多简单:
- 增加一个
reject方法,把文章的状态从PendingReview改回Draft。 - 要求调用两次
approve之后,状态才能变为Published。 - 只允许用户在文章处于
Draft状态时添加文本内容。提示:让状态对象负责决定内容可能发生什么变化,但不负责修改Post。
缺点也有。其一,状态之间的转换由各状态自己实现,某些状态会彼此耦合:若在 PendingReview 和 Published 之间插入一个状态(比如 Scheduled),就得修改 PendingReview 让它改转 Scheduled。其二,逻辑有重复:想给 State 的 request_review、approve 提供返回 self 的默认实现来消重?行不通——State 用作 trait 对象时,trait 并不知道具体的 self 是什么,返回类型编译期定不下来(这正是前面提过的 dyn 兼容性规则之一)。还有一些重复在 Post 的 request_review 和 approve 两个相似的实现上:都是对 state 用 Option::take,是 Some 就委托给被包裹值的同名方法、把结果设为新 state。遵循这种模式的方法一多,可以考虑用宏消除重复(第 20 章的「宏」)。
完全照面向对象语言所定义的样子实现状态模式,意味着没有尽可能发挥 Rust 的优势。下面看对 blog crate 的改造,把无效的状态和转换变成编译期错误。
将状态和行为编码为类型
重新审视状态模式,换一组权衡:不再把状态和转换完全封装起来,而是把状态编码进不同的类型,让 Rust 的类型检查系统在「只允许已发布文章」的位置直接阻止草稿文章,发出编译器错误。
先看示例 18-11 中 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 方法。试图获取草稿内容会得到「方法不存在」的编译错误,于是再也不可能意外地在生产环境显示草稿内容:那样的代码根本编译不过。示例 18-19 给出 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);
}
}
示例 18-19:带有 content 方法的 Post 与没有 content 方法的 DraftPost
两个结构体都有保存文章文本的私有 content 字段,不再有 state 字段——状态的编码转移到了结构体的类型上。Post 代表已发布文章,有返回 content 的 content 方法。Post::new 依然存在,但返回的不再是 Post 实例而是 DraftPost 实例;由于 content 私有、且没有任何函数返回 Post,眼下根本造不出 Post 的实例。DraftPost 有 add_text,但没有定义 content 方法——程序由此保证所有文章从草稿起步、草稿内容无法显示,绕过约束的任何尝试都会招致编译器错误。
那怎么得到已发布文章?强制「草稿必须先经审核并获批准才能发布」,且等待审核的文章同样不可显示内容:再添加一个 PendingReviewPost,在 DraftPost 上定义返回它的 request_review,在 PendingReviewPost 上定义返回 Post 的 approve,如示例 18-20 所示。
impl DraftPost {
// --省略--
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——对 DraftPost 调用过 request_review 之后就不会再残留 DraftPost 实例,依此类推。PendingReviewPost 没有 content 方法,读它的内容会像 DraftPost 一样得到编译错误。得到已发布 Post 的唯一途径是在 PendingReviewPost 上调 approve,而得到 PendingReviewPost 的唯一途径是在 DraftPost 上调 request_review——博客工作流就这样编码进了类型系统。
**编译注:**这种写法在社区里叫 typestate 模式:把状态编码进类型,让类型系统替你当状态机的裁判。关键配合动作是
request_review(self)按值拿走self——旧状态被移动走,转换之后再想用DraftPost就是编译错误,全程没有任何运行时检查。这套手法在嵌入式 HAL 这类对状态安全要求苛刻的库里用得很多。和第一种实现对照:trait 对象版把灵活性留在运行期(状态是数据,可以按运行时信息流转),typestate 版把保证提前到编译期——依然是本章反复出现的那组取舍。
main 需要小改:两个方法返回新实例而不是原地修改,所以要加几个 let post = 遮蔽赋值保存返回值;断言草稿和待审核内容为空的语句也删掉了——不需要了,使用这些状态下文章内容的代码已经无法编译。更新后的 main 如示例 18-21 所示。
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 所做的修改
为了重新赋值 post 而做的这些改动,意味着实现已不再严格遵循面向对象的状态模式——状态转换不再完全封装在 Post 内部。但换来的是:凭类型系统和编译期类型检查,无效状态已不可能出现,「显示未发布文章内容」这类 bug 在进入生产环境之前就会被发现。
建议在示例 18-21 之后的 blog crate 上试试本节开头建议的那些任务,看看你觉得这版设计如何——注意其中一些任务在这个设计里可能已经完成了。
我们已经看到:Rust 有能力实现面向对象的设计模式,但也存在其他模式,比如把状态编码进类型系统。各模式权衡不同——对面向对象模式烂熟于心固然好,但换一种思路重新思考问题、发挥 Rust 特性的优势,能带来实打实的好处,比如在编译期就阻止某些 bug。而由于 Rust 拥有一些面向对象语言没有的特性(比如所有权),面向对象模式在 Rust 中并不总是最佳解决方案。
小结与下一步
- 按「对象 = 数据 + 行为」「封装」的判据,Rust 是面向对象的;按「必须有继承」的判据,不是
- Rust 的多态两套方案:泛型 + trait 约束走静态分派(单态化、可内联、零成本),trait 对象走动态分派(一次间接跳转、运行期异构集合)
- trait 对象是胖指针:数据指针 + vtable 指针;哪些 trait 可用受 dyn 兼容性规则约束
- 状态模式在 Rust 里写得出来(
Post+Box<dyn State>),但「把状态编码为类型」往往更地道——非法状态直接变成编译错误 - 面向对象模式是可用选项而非默认答案;所有权这类 Rust 独有的特性会改变最优解
下一篇:第 19 章「模式与匹配」——match 背后的模式语法全景,Rust 最有表现力的部分之一。