面向对象编程(Object-Oriented Programing, OOP)是一种程序建模的方法。
一、面向对象语言的特性
编程社区对面向对象语言的特性没有一个共识性的结论。但是对Rust来说,面向对象语言的特性通常应该包含以下内容:命名对象、封装及继承。
1、对象包含数据和行为
在经典书籍《Design Patterns:Elements of Reisanle Object-Oriented Software》中,你可以找到各式各样面向对象的设计模式。它们给面向对象编程做出了这样的定义:
面向对象的程序由对象组成。对象包装了数据和操作这些数据的流程,这些流程通常被称作方法或操作。
基于这个定义,Rust是面向对象的:结构体和枚举包含数据,而impl块则提供了可用于结构体和枚举的方法。
2、封装实现细节
另外一个常常伴随着面向对象编程的思想便是封装:调用对象的外部代码无法直接访问对象内部的实现细节,而唯一可以与对象进行交互的方法便是通过它公开的接口。使用对象的代码不应当深入对象的内部去改变数据或行为。封装使得开发者在修改或重构对象的内部实现时无须改变调用这个对象的外部代码。
在之前我们介绍过如何控制封装:我们可以使用pub关键字来解决代码中哪些模块、类型、函数和方法是公开的,而默认情况下其他所有内容都是私有的。
我们定义一个名为AveragedCollection
的结构体,它的字段中包含了一个存储i32元素的动态数据。除此之外,为了避免在每次读取元素平均值的时候重复计数,我们添加了一个用于存储动态数组平均值的时候重复计算,我们添加了一个用于存储动态数组平均值的字段。AveragedCollection
会缓存计算出的平均值、
//示例17-1:维护一个整数列表及集合平均值的AveragedCollection结构体
pub struct AveragedCollection{
list: Vec<i32>,
average: f64,
}
结构体本身被标记为pub来使其他代码可以使用自己,但其内部字段则依然保持私有。这一封装在本例中十分重要,因为我们希望在每次增加或删除值的时候平均值能够相应地得到更新。通过在结构体中实现add、remove和averge方法便可以完成这些需求。
//示例17-2:在AveragedCollection结构体中实现公共方法add、remove和average
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;
}
}
公共方法add、remove和average是仅有几个可以访问或修改AveragedCollection
实例中数据的方法。当用户调用add方法想list中增加元素,或者调用remove方法从list中删除元素时,方法内部的实现都会调用私有方法update_average来更新average字段。
由于list和average字段是私有的,所以外部代码无法直接读取list字段来增加或删除其中的元素。一旦缺少了这样的封装,average字段便无法在用户私自更新list字段时保持同步更新。另外,用户可以通过average方法来读取average字段的值,却不能修改它。
因为结构体AveragedCollection
封装了内部的实现细节,所以我们能够在未来轻松地改变数据结构等内部实现。我们可以在list字段上使用HashSet<i32>
代替Vec<i32>
,只要add、remove和average这几个公共方法的签名保持不变,正在使用AveragedCollection
的外部代码就无须进行任何修改;而假如我们将list声明为pub,那么就必然会失去这一优势:由于Hashset<i32>
与Vec<i32>
在增加或删除元素时使用的具体方法有所不同,因此如果直接修改list,那么外部代码就不得不发生变化。
3、作为类型系统和代码共享机制的继承
继承(inheritance)机制使得对象可以沿用另一个对象的数据与行为,而无须重复定义代码。
如果一门语言必须拥有继承才能算作面向对象语言,那么Rust就不是。
选择使用继承有两个主要原因:
- 其一是实现代码复用,你可以为某个类型实现某种行为,并接着通过继承来让另一个类型直接复用这一实现。作为替代解决防范,我们可以直接使用Rust中的默认trait方法来进行代码共享。任何实现了Summary trait的类型都会自动拥有summarize方法,而无须添加额外的重复代码。
- 其二是与类型系统有关:希望子类型能够被应用在一个需要父类型的地方,也就是所谓的多态(polymorphism):如果一些对象具有某些共同的特性,那么这些对象就可以在运行时相互替换使用。
多态
许多人将“多态”视作“继承”。但是“多态”是一个更为通用的概念,它指代所有能够适应多种数据类型的代码。对于继承概念而言,这些类型就是所谓的子类。
许多较为新潮的语言不太喜欢将继承作为内置的程序设计方案,因为使用继承意味着你会在无意间共享出比所需内容更多的代码。子类并不应该总是共享父类的所有特性,但使用继承机制却会始终产生这样的结果,进而使程序设计缺乏灵活性。子类在继承的过程中有可能会引入一些毫无意义甚至根本就不适用于子类的方法。另外,某些语言强制要求子类只能继承自单个父类,进一步限制了程序设计的灵活性。基于上述原因,Rust选择了trait对象来代替继承。
二、使用trait对象来存储不同类型的值
我们曾经在之前提到过动态数组的使用限制:它只能存储同一类型的元素。但总有某些时候,我们希望用户能够在特定的应用场景下为这个类型的集合进行扩展。为了展示该特性,我们会在示例中创建一个图形用户界面(GUI)工具。这个工具会遍历某个元素列表,并依次调用元素的draw方法来讲其绘制到屏幕中,这是GUI工具最为基本的功能之一。
1、为共有行为定义一个trait
为了在gui中实现期望的行为,我们首先要定义一个拥有draw方法的Draw trait。接着,我们便可以定义一个持有trait对象的动态数组。trait对象能够指向实现了指定trait的类型实例,以及一个用于在运行时查找trait方法的表,并添加dyn
关键字与指定相关trait来创建trait对象。trait对象可以被用在泛型或具体类型所处的位置。
Rust有意避免将结构体和枚举称为“对象”,以便与其他语言中的对象概念区分。对于结构体或枚举而言,它们字段中的数据与impl块中的行为是分开的;而在其他语言中,数据和行为往往被组合在名为对象的概念中。trait对象则有些类似于其他语言中的对象,因为它也在某种程度上组合了数据与行为。但trait对象与传统对象不同的地方在于,我们无法为trait对象添加数据。由于trait对象被专门用于抽象某些共有行为,所以它没有其他语言中的对象那么通用。
下述示例展示了如何定义一个拥有draw方法的Draw trait:
//示例17-3:Draw trait的定义
pub trait Draw {
fn draw(&self);
}
下面示例定义了一个持有components
动态数组的Screen结构体。这个动态数组的元素类型使用了新语法Box<dynDraw>
来定义trait对象,它被用来代表所有被放置在Box中且实现了Draw triat的具体类型。
//示例17-4:持有components字段的Screen结构体定义,components字段存储了实现Draw trait的trait对象动态数组
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
这个Screen结构体还定义了一个名为run的方法,它会逐一调用components中每个元素的draw方法,如下示例:
//示例17-5:在Screen中实现run方法会逐一调用components中每个元素的draw方法
impl Screen {
pub fn run(&self) {
for components in self.components.iter() {
components.draw();
}
}
}
我们同样可以使用带有trait约束的泛型参数来定义结构体,但它与此处示例代码的工作机制截然不同。泛型参数一次只能被替代为一个具体的类型,而trait对象则允许你在运行时填入多种不同的具体类型。例如,使用泛型参数与trait约束来定义Screen结构体,如下示例:
//示例17-6:使用泛型参数与trait约束定义的Screen结构体及run方法
pub struct Screen {
pub components: Vec<T>,
}
impl<T> Screen<T> {
where T: Draw {
pub fn run(&self) {
for components in self.components.iter() {
components.draw();
}
}
}
}
为了使用新定义的Screen实例,我们被限制在list中存储完全由Button类型组成的列表,或完全由TextField类型组成的列表。如果你需要的仅仅是同质集合,那么使用泛型和trait约束再好不过了,因为这段定义会在编译时被多态化以便使用具体类型。
另一方面,借助于在方法中使用trait对象,单个Screen实例持有的Vec可以同时包含Box<Button>
和Box<TextField>
。
2、实现trait
现在,让我们来添加一些实现了Draw trait的具体类型。
//实例17-7:实现了Draw trait的Button结构体
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
//实际绘制一个按钮的代码
}
}
Button中持有的width、height和lable字段也许会不同于其他组件中的字段。每一个希望绘制在屏幕上的类型都应当实现Draw trait,并在draw方法中使用不同的代码来自定义具体的绘制行为,就像上面代码中的Button那样。除了实现Draw trait,我们的Button类型也许还会再另外的impl块中实现响应用户点击按钮时的行为,而这些方法并不适用于TextField等其他类型。
如果某个用户决定实现一个带有width、height和options字段的SelectBox结构体,那么他也同样可以为SelectBox类型实现Draw trait。
//示例17-8:在另外某个依赖gui库的包中,定义一个实现了Draw trait的SelectBox结构体
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
}
}
用户已经可以在编写main函数时创建Screen实例了。另外,还可以使用Box<T>
来生成SelectBox
或Button
的trait对象,并将这些trait对象添加到Screen实例中。接着,他们便可以运行Screen实例的run方法来一次调用所有组件的draw实现,如下示例:
//示例17-9:使用trait对象来存储实现了相同trait的不同类型值
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// 实际绘制一个选择框的代码
}
}
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();
}
我们在编写库的时候无法得知用户是否会添加自定义的SelectBox类型。但我们的Screen实现依然能够接收新的类型并顺利完成绘制工作,因为SelectBox实现了Draw trait及其draw方法。
run方法中的代码只关心值对行为的响应,而不在意值的具体类型,这一概念称为“鸭子类型”(duck typing)。示例17-5在实现run方法的过程中并不知晓每个组件的具体类型,它仅仅调用了组件的draw方法,而不会去检查某个组件巨鲸时Button实例还是Select实例。通过在定义动态数组components时指定Box<dyn Draw>
元素类型,Screen实例只会接收哪些能够调用draw方法的值。
使用trait对象与类型系统来实现“鸭子类型”有一个明显的有点:我们永远不需要再运行时检查某个值是否实现了指定的方法,或者担心出现“调用未定义方法”等运行时错误。Rust根本就不会允许这样代码通过编译。
例如,我们可以尝试将String类型用作Screen的组件,如下示例:
//17-10:尝试使用一个没有实现指定trait的类型
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
3、trait对象会执行动态派发
我们曾经介绍过Rust编译器会在泛型使用trait约束时执行单态化:编译器会为每一个具体类型生成对应泛型函数和泛型方法的非泛型实践,并使用这些具体的类型来替换泛型参数。通过单态化生成的代码会执行静态派发(static dispatch),这意味着编译器能够在编译过程中确定你调用的具体方法。这个概念与动态派发(dynamic dispatch)相对应,动态派发下的编译器无法在编译过程中确定你调用的究竟是哪一个方法。
Rust必然会在我们使用trait对象时执行动态派发。因为编译器无法知晓所有能够用于trait对象的具体类型,所以它无法在编译时确定需要调用哪个类型的哪个具体方法。不过,Rust在运行时通过trait对象内部的指针去定位具体调用哪个方法。该定位过程会产生一些不可避免地运行时开销,而这并不会出现在静态派发中。动态派发还会阻止编译器内联代码,进而使得部分优化操作无法进行。
4、trait对象必须保证对象安全
需要注意的是,你只能把满足对象安全(object-safe)的trait转换为trait对象。Rust采用了一套较为复杂的规则来决定某个trait是否对象安全。但在实际应用中,你只需要关注其中两条规则即可。如果一个trait中定义的所有方法满足下面两条规则,那么这个trait就是对象安全的:
- 方法的返回类型不是Self;
- 方法中不包含任何泛型参数;
标准库中的Clone trait就是一个不符合对象安全的例子。Clone trait中的clone方法拥有这样的签名:
pub trait Clone {
fn clone(&self) -> Self;
}
由于String类型实现了Clone trait,所以我们可以在String实例上调用clone方法来获得一个新的String实例。类似地,我们也可以在Vec<T>
实例上调用clone来获得新地Vec<T>
实例。clone方法的签名需要直到Self究竟代表了哪一种具体类型,因为这是它作为结果返回的类型。
编译器会在你使用trait对象时,指出违反了对象安全规则的地方。我们修改17-4示例种的代码,让我们在Screen结构体种存储实现了Clone trait的类型:
pub struct Screen {
pub components: Vec<Box<dyn Clone>>,
}
三、实现一种面向对象的设计模式
状态模式(state pattern)是一种面向对象的涉及模式,它的关键特点是,一个值拥有的内部状态由数个状态对象(state object)表达而成,而值的行为则随着内部状态的改变而改变。这种设计模式会通过状态对象来共享功能:相对应地,Rust使用了结构体与trait而不是对象与继承来实现这一特性。每个状态对象都会负责自己的行为并掌控自己转换为其他状态的时机。而持有状态对象的值则对状态的不同行为和状态转换的时机一无所知。
使用状态模式意味着在业务需求发生变化时我们不需要修改持有状态对象的值,或者使用这个值的代码。我们只需要更新状态对象的代码或增加一些信的状态对象,就可以改变程序的运转规则。
如下示例采用增量式的开发过程来实现一个用于发布博客的工作流程。这个博客最终的工作流程如下:
- 在新建博客文章时生成一份空白的草稿文档;
- 在草稿撰写完毕后,请求对这篇草稿状态的文章进行审批;
- 在文章通过审批后正式对外发布;
- 仅返回并打印成功发布后的文章,而不能意外地发布没有通过审批的文章;
除了上面描述的流程,任何其他对文章的修改行为都应当是无效的。
//示例17-11:演示blog包预期行为的代码示例
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
来创建一篇信的文章草稿。接着,我们还可以使用户可以在文章处于草稿状态时自由地将文字添加到文章中。加入用户立即获得文章中的内容,那么他什么也获取不到,因为文章处于草稿状态。处于演示的目的,我们在代码中添加了用于检查这一行为的assert_eq!
断言。断言在文章处于草稿状态时调用content
方法返回一个空字符串。
接着,我们希望用户可以发出审批文章的请求,而处于等待阶段的content
方法则依然会在调用时返回空字符串。当文章获得审批并能够正式对外发布时,调用content
方法则应当返回完整的文章内容。
需要注意的是,用户与这个库进行交互式涉及的数据类型只有Post
类型。这个类型会采用状态模式,它持有的值会是3种不同的文章状态对象中的一个:草稿、等待审批或已发布。Post类型会在内部管理状态与状态之间的变化过程。虽然状态变化的行为会在用户调用Post实例的对应方法时发生,但无须用户对这一过程进行管理。
1、定义Post并新建一个处于草稿状态下的新实例
我们需要一个用来存储内部的公共结构体Post,所以我们开始定义这个结构体,并声明一个用于创建Post
实例的公共关联函数new
。如下示例,另外,我们还需要创建一个私有的State trait。Post类型会在私有的state字段中持有包裹在Option<T>
内的trait对象Box<dyn State>
。
//示例17-12: Post结构体的听译,以及用于创建Post实例的new函数、State trait和Draft结构体
pub struct Post {
state: Optio<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这一状态的定义,一位内它是文章创建时所处的初始状态。
这段代码在创建Post实例时把它的State字段设置为了持有Box的Some值,而该Box则指向了Draft结构体的一个实例,这保证了任何创建出来的Post实例都会从草稿状态开始,因为Post的State字段是私有的,所以用户无法采用其他状态来创建Post。Post::new函数将content字段设置为了新的空String。
2、存储文章内容的文本
示例17-11调用了一个名为add_text
的方法来将传入的&str
参数添加至文章中。之所以将这个功能作为方法来实现而不是通过pub关键字来直接暴露content字段,是因为我们需要控制用户访问content字段中的数据时的具体行为。add_text
方法的实现过程一目了然,你只需要简单地把它添加值impl Post
块中即可,如下所示:
//示例17-13:add_text方法地实现使用户可以将字符串添加至文章地content中
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);
}
}
由于调用add_text
方法会修改对应的Post实例,所以该方法需要接收self的可变引用作为参数。接着,我们调用content中基于String类型的push_str方法来将text参数中的字符串添加到content字段中。由于该行为不依赖于文章当前所处的状态,所以它不是状态模式的一部分。虽然add_text
方法没有与state
字段进行交互,但它仍然是我们希望对外提供的行为之一。
3、确保草稿的可读内容为空
即便用户调用add_text
方法为文章添加了一些内容,但只要文章处于草稿状态,我们就需要在用户调用content
方法时返回一个空的字符串。因此我们采用临时方法:将content
方法设置成永远返回一个空的字符串切片。我们会在后续修改这一方法。
//示例17-14:临时的Post::content方法会总是返回一个空的字符串切片
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 {
""
}
}
4、请求审批文章并改变其状态
我们添加一个请求审批文章的功能,这个功能会将文章的状态从Draft变为PendingReview。
//示例17-15:基于Post和State trait实现request_review方法
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 {
""
}
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添加了一个名为request_review
的公共方法,它会接收self的可变引用并调用state的request_review
方法。后面这个request_review
方法会消耗当前的状态并返回一个新的状态。
我们为State trait添加了一个request_review方法,所有实现了这个trait的类型都必须实现这个request_review方法。值得注意的是,我们选择了self: Box<Self>
来作为方法的第一个参数,而不是self
、&self
或&mut self
。这个语法意味着该方法只能被包裹着当前类型的Box实例调用,它会在调用过程中获取Box<Self>
的所有权并使旧的状态时效,从而将Post的状态值转换为一个新的状态。
为了消耗旧的状态,request_review
方法需要获取状态值的所有权。这也正式Post的state字段引入Option的原因:Rust不允许结构体中出现违背填充的值。我们可以通过Option<T>
的他可方法来取出state字段的Some值,并在原来的位置留下一个None
。这样我们能够将state
的值从Post中移出来,而不单单只是借用它。接着,我们又将这个方法的结果赋值给了文章的state
字段。
我们需要临时把state
设置为None来取得state值的所有权,而不能直接使用self.state = self.state.request_review();
这种代码。这可以确保Post无法在我们完成状态转换后再次使用旧的state值。
Draft实现的request_review方法需要在Box中包含一个新的PendingReview
结构体实例,这一状态意味着文章正在等待审批。PendingReview
结构体同样实现了request_review
方法,但它没有执行任何状态转移过程,仅仅是返回了自己。对于一篇已经处在PendingReview
状态下的文章,发起审批请求并不会改变该文章的当前状态。
此时,状态模式的优势开始显现了:无论state的值是什么,Post的request_review
方法都不需要发生改变,每个状态都会负责维护自己的运行规则。
5、添加approve方法来改变content的行为
approve
方法与request_review
方法类似:它会执行状态的审批流程,并将state设置为当前状态审批后返回的值。
//示例17-16:基于Post和State trait实现approve方法
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 {
""
}
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
}
}
这段代码为State trait
添加了一个名为approve的方法,接着,我们创建了新的Published结构体来添加已发布状态,并为它实现State trait
。
与request_review
类似,为Draft实例调用approve方法会简单地返回self而不会产生任何作用。PendingReview
实例会在调用approve时返回一个包裹在Box内地Published结构体的新实例。Published
结构体同样实现了State trait,它的requset_review和approve方法都只会返回它们本身,因为处于Published状态下的文章不应被这些操作改变状态。
//实例17-17: 更新Pist的content方法,在该方法中委托调用State的content方法
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())
}
}
}
因为我们希望使所偶的规则在State相关结构体的内部实现,所以我们会调用state值的conten方法,并将Post实例本身(也就是self)作为参数传入,最后将这个方法返回的值作为结果。
这段代码调用了Option的as_ref方法,因为我们需要的只是Option中值的引用,而不是它的所有权。由于state的类型使Option<Box<dyn State>>
,所以我们会在调用as_ref
时得到Option<&Box<dyn State>>
。如果这段代码中没有调用as_ref
,那么就会导致编译时错误,因为我们不能将state从函数参数的借用&self
中移出。
我们接着调用了unwrap方法。由于Post的具体实现保证了方法调用结束时的state总会是一个有效的Some值,所以我们可以确定调用unwrap
不会发生pinic.
随后,我们又调用了&Box<dyn State>
的content方法。由于解引用转换会依次作用于&
与Box
,所以我们最终调用的content方法来自实现了State trait的具体类型。这意味着我们需要在State trait的定义中添加content方法,并在这个方法的实现中基于当前状态来决定究竟返回哪些内容。
//示例17-18:在State trait中添加content方法
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 {
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 {
""
}
}
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
}
fn content<'a>(&self, post: &'a Post) -> &'a str {
&post.content
}
}
我们为content方法添加了默认的trait实现,它会返回一个空的字符串切片。这使得我们可以不必再Draft和PendingReview结构体中重复实现content。Published结构体会覆盖content方法并返回post.content
的值。
注意,我们在这个方法上添加相关的生命周期标注。这个方法额实现需要接收post的引用作为参数,并返回post中某一部分的引用作为结果,因此,该方法中返回值的生命周期应该与post参数的生命周期相关。
6、状态模式的权衡取舍
基于状态模式,我们可以免于在Post的方法或使用Post的代码中添加match表达式。当业务需要新增状态时,我也只需要创建一个新的结构体并为它实现trait的各种方法即可。
但是状态模式也不是完美无瑕的,其中一个缺点在于:因为状态实现了状态间的转移,所以某些状态之间是相互耦合的。如果我们希望在PendingReview和Published之间添加一个Scheduled状态,那么我们就需要修改PendingReview中的代码来转移到Scheduled状态。假如我们能够在新增状态时避免修改pendingReview,这样虽然会更加方便,但这意味着我们需要选用另一种设计模式。
状态模式的另一个缺点在于:我们需要重复实现一些代码逻辑。让State trait的requset_review和approve方法默认返回self;这样的代码违背了对象安全原则,因为trait无法确定self的具体类型究竟是什么。如果我们希望将state当作trait对象来使用,那么它的方法就必须全部是对象安全的。这种重复的代码逻辑我们可以在后期通过“宏”进行消除。
1)将状态和行为编码成类型
不同于完全封装状态和转移过程使得外部代码对其毫不知情,我们将状态编码为不同类型。如此,Rust的类型检查就会将任何只能使用已发布文章而使用草稿文章的地方产生编译错误。
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结构体地定义,以及它们的方法实现。
//示例17-19:带有content方法的Post和不带content方法的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和DrafPost结构体都有一个用来存储文本的私有字段content。由于我们将状态直接编码为了结构体类型,所以这两个结构体不再拥有之前的state字段。新的Post结构体将会代表一篇已发布的文章,它的content方法被用来返回内部content字段的值。
Post结构体仍然定义了自己的关联函数Post::new
,但它现在会返回一个DraftPost实例,而不再是Post实例。由于content字段是私有的,且没有任何直接返回Post的函数,所以我们暂时无法创建出Post实例。
因为DraftPost结构体具有一个add_text方法,所以我们可以像以前一样为content添加文本,但是,DraftPost根本就没有定义content方法。现在,程序能够保证所有文章都从草稿状态开始,并且没有处于草稿的文章无法对外展示自己的内容了。任何绕过这些限制的尝试都会导致编译时错误。
2)将状态转移实现为不同类型之间的转换
我们希望草稿状态的文章能够在得到审批后发布,而一篇处于待审批状态的文章则不应该对外显示任何内容。让我们添加新的结构体PendingReviewPost
来实现这一规则。新的代码还会再DraftPost
中定义返回PendingReviewPost
实例的request_review
方法,并在PendingReviewPost
中定义一个返回Post
实例的approve
方法,如下所示:
//示例17-20:可以通过调用DraftPost的request_review方法创建Pending ReviewPost,而PendingReviewPost的approve方法则能够把自己转换为已发布的Post
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);
}
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。通过这种写法,我们不可能再调用request_review
方法后遗漏任何DraftPost实例,调用approve方法与此同理。
尝试读取PendingReviewPost
的内容同样会导致编译错误,因为它没有定义content
方法。含有content
方法的Post实例只能够通过PendingReviewPost的approve方法来获得,而用户只能通过调用DraftPost的request_review方法来获得PendingReviewPost实例。我们已经成功地将发布博客地工作流程编码到了类型系统中。
但是我们需要修改一下main
函数,因为request_review
和approve
方法会返回新地实例,而不是修改调用方法的结构体本身,所以我们需要添加一些let post =
覆盖赋值来保护返回的实例。接着,我们还删除了那些用来进行检查的断言,因为处于草稿状态或待审批状态的文章一定会返回空字符不再有意义:任何试图非法读取内容的操作都会导致编译错误。
//示例17-21:使用新的发布博客的工作流程实现来修改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());
}
既然我们修改main函数来重新为post赋值,那么新的实现就不再是完全面向对象的状态模式了:状态之间的转换过程不再被完成地封装再Post实例中。