面向对象
Table of Contents
面向对象编程是一种模式化编程方式 对象来源于 20 世纪 60 年代的 Simula 编程语言。这些对象影响了 Alan Kay 的编程架构中对象之间的消息传递。他在 1967 年创造了 面向对象编程 这个术语来描述这种架构 关于 OOP 是什么有很多相互矛盾的定义;在一些定义下,Rust 是面向对象的;在其他定义下,Rust 不是
在本章中,会探索一些被普遍认为是面向对象的特性和这些特性是如何体现在 Rust 语言习惯中的。接着会展示如何在 Rust 中实现面向对象设计模式,并讨论这么做与利用 Rust 自身的一些优势实现的方案相比有什么取舍
面向对象语言的特征
关于一个语言被称为面向对象所需的功能,在编程社区内并未达成一致意见 Rust 被很多不同的编程范式影响,包括面向对象编程和函数式编程的特性 面向对象编程语言所共享的一些特性往往是对象、封装和继承 先看一下每一个概念的含义以及 Rust 是否支持他们
对象包含数据和行为
由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 编写的书 Design Patterns: Elements of Reusable Object-Oriented Software 被俗称为 The Gang of Four,它是面向对象编程模式的目录。它这样定义面向对象编程: 面向对象的程序是由对象组成的。一个“对象”包含数据和操作这些数据的过程。这些过程通常被称为“方法”或“操作”
在这个定义下,Rust 是面向对象的:结构体和枚举包含数据而 impl 块提供了在结构体和枚举之上的方法
虽然带有方法的结构体和枚举并不被 称为 对象,但是他们提供了与对象相同的功能
封装隐藏了实现细节
另一个通常与面向对象编程相关的方面是”封装“的思想:对象的实现细节不能被使用对象的代码获取到 所以唯一与对象交互的方式是通过对象提供的公有 API;使用对象的代码无法深入到对象内部并直接改变数据或者行为 封装使得改变和重构对象的内部时无需改变使用对象的代码
可以使用 pub 关键字来决定 模块 、 类型 、 函数 和 方法 是公有的,而默认情况下其他一切都是私有的。比如,可以定义一个包含一个 i32 类型 vector 的结构体 AveragedCollection。结构体也可以有一个字段,该字段保存了 vector 中所有值的平均值。这样,希望知道结构体中的 vector 的平均值的人可以随时获取它,而无需自己计算。换句话说,AveragedCollection 会缓存平均值结果:
pub struct AveragedCollection { list: Vec<i32>, average: f64, }
注意,结构体自身被标记为 pub,这样其他代码就可以使用这个结构体,但是在 结构体内部的字段 仍然是 私有 的。这是非常重要的,因为希望保证变量被增加到列表或者被从列表删除时,也会同时更新平均值。可以通过在结构体上实现 add、remove 和 average 方法来做到这一点:
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 方法来删除时,这些方法的实现同时会调用私有的 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 与否可以封装其实现细节
继承,作为类型系统与代码共享
继承是一个很多编程语言都提供的机制,一个对象可以定义为继承另一个对象的定义,这使其可以获得父对象的数据和行为,而无需重新定义
如果一个语言必须有继承才能被称为面向对象语言的话,那么 Rust 就不是面向对象的。无法定义一个结构体继承父结构体的成员和方法
近来继承作为一种语言设计的解决方案在很多语言中失宠了,因为其时常带有共享多于所需的代码的风险 子类不应总是共享其父类的所有特征,但是继承却始终如此。如此会使程序设计更为不灵活,并引入无意义的子类方法调用,或由于方法实际并不适用于子类而造成错误的可能性 某些语言还只允许子类继承一个父类,进一步限制了程序设计的灵活性
使用继承有两个主要的原因:
- 为了重用代码:一旦为一个类型实现了特定行为,继承可以对一个不同的类型重用这个实现。相反 Rust 代码可以使用默认 trait 方法实现来进行共享
- 表现为子类型可以用于父类型被使用的地方。这也被称为多态,这意味着如果多种对象共享特定的属性,则可以相互替代使用。很多人将多态描述为继承的同义词。不过它是一个有关可以用于多种类型的代码的更广泛的概念。对于继承来说,这些类型通常是子类。Rust 则通过泛型来对不同的可能类型进行抽象,并通过 trait bounds 对这些类型所必须提供的内容施加约束
为使用不同类型的值而设计的 trait 对象
前面曾经谈到了 vector 只能存储同种类型元素的局限,并且提供了一个定义 SpreadsheetCell 枚举来储存整型,浮点型和文本成员的替代方案 这意味着可以在每个单元中储存不同类型的数据,并仍能拥有一个代表一排单元的 vector,这在当编译代码时就知道希望可以交替使用的类型为固定集合的情况下是完全可行的
然而有时希望库用户在特定情况下能够扩展有效的类型集合。为了展示如何实现这一点,这里将创建一个图形用户接口工具的例子,它通过遍历列表并调用每一个项目的 draw 方法来将其绘制到屏幕上。为此将要创建一个叫做 gui 的库 crate,它含一个 GUI 库的结构。这个 GUI 库包含一些可供开发者使用的类型,比如 Button 或 TextField。在此之上,gui 的用户希望创建自定义的可以绘制于屏幕上的类型:比如,一个程序员可能会增加 Image,另一个可能会增加 SelectBox
这个例子中并不会实现一个功能完善的 GUI 库,不过会展示其中各个部分是如何结合在一起的 编写库的时候,不可能知晓并定义所有其他程序员希望创建的类型 所知晓的是 gui 需要记录一系列不同类型的值,并需要能够对其中每一个值调用 draw 方法。这里无需知道调用 draw 方法时具体会发生什么,只要该值会有那个方法可供调用
在拥有继承的语言中,可以定义一个名为 Component 的类,该类上有一个 draw 方法。其他的类比如 Button、Image 和 SelectBox 会从 Component 派生并因此继承 draw 方法。它们各自都可以覆盖 draw 方法来定义自己的行为,但是框架会把所有这些类型当作是 Component 的实例,并在其上调用 draw。不过 Rust 并没有继承,得另寻出路
定义通用行为的 trait
- 为了实现 gui 所期望的行为,定义一个 Draw trait,其中包含名为 draw 的方法
- 定义一个存放 trait 对象的 vector。trait 对象指向一个实现了指定 trait 的类型的实例,以及一个用于在 运行时查找该类型 的trait方法的表
- 通过指定某种指针来创建 trait 对象,例如 & 引用或 Box<T> 智能指针,还有 dyn keyword, 以及指定相关的 trait
以后会在 ““动态大小类型和 Sized trait” 部分介绍 trait 对象必须使用指针的原因
下面展示了如何定义一个带有 draw 方法的 trait Draw:
pub trait Draw { fn draw(&self); }
下面定义了一个存放了名叫 components 的 vector 的结构体 Screen。这个 vector 的类型是 Box<dyn Draw> ,此为一个 trait 对象 :它是 Box 中任何实现了 Draw trait 的类型的替身
pub struct Screen { pub components: Vec<Box<dyn Draw>>, }
之前提到过,Rust 刻意不将结构体与枚举称为 “对象”,以便与其他语言中的对象相区别 在结构体或枚举中,结构体字段中的数据和 impl 块中的行为是分开的,不同于其他语言中将数据和行为组合进一个称为对象的概念中 trait 对象将数据和行为两者相结合,从这种意义上说 则 其更类似其他语言中的对象 不过 trait 对象不同于传统的对象,因为不能向 trait 对象增加数据 trait 对象并不像其他语言中的对象那么通用:其具体的作用是允许对通用行为进行抽象
在 Screen 结构体上,将定义一个 run 方法,该方法会对其 components 上的每一个组件调用 draw 方法:
impl Screen { pub fn run(&self) { for component in self.components.iter() { component.draw(); } } }
也可以定义 Screen 结构体来使用泛型和 trait bound:
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(); } } }
但这与定义使用了带有 trait bound 的泛型类型参数的结构体不同:
- 泛型类型参数:一次只能替代 一个 具体类型
- trait 对象:允许在运行时替代 多种 具体类型
这限制了 Screen 实例必须拥有一个全是 Button 类型或者全是 TextField 类型的组件列表 如果只需要同质(相同类型)集合,则倾向于使用泛型和 trait bound,因为其定义会在编译时采用具体类型进行单态化 另一方面,通过使用 trait 对象的方法,一个 Screen 实例可以存放一个既能包含 Box<Button>,也能包含 Box<TextField> 的 Vec<T>
任何使用 trait 对象的位置,Rust 的类型系统会在编译时确保任何在此上下文中使用的值会实现其 trait 对象的 trait。如此便无需在编译时就知晓所有可能的类型
实现 trait
现在来增加一些实现了 Draw trait 的类型。将提供 Button 类型。一个 Button 结构体可能会拥有 width、height 和 label 字段:
pub struct Button { pub width: u32, pub height: u32, pub label: String, } impl Draw for Button { fn draw(&self) { // 实际绘制按钮的代码 } }
在 Button 上的 width、height 和 label 字段会和其他组件不同,比如 TextField 可能有 width、height、label 以及 placeholder 字段 每一个希望能在屏幕上绘制的类型都会使用不同的代码来实现 Draw trait 的 draw 方法来定义如何绘制特定的类型 除了实现 Draw trait 之外,比如 Button 还可能有另一个包含按钮点击如何响应的方法的 impl 块。这类方法并不适用于像 TextField 这样的类型
如果一些库的使用者决定实现一个包含 width、height 和 options 字段的结构体 SelectBox,并且也为其实现了 Draw trait:
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 } }
库使用者现在可以在他们的 main 函数中创建一个 Screen 实例。至此可以通过将 SelectBox 和 Button 放入 Box<T> 转变为 trait 对象来增加组件。接着可以调用 Screen 的 run 方法,它会调用每个组件的 draw 方法:
use gui::{Screen, Button}; 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 方法
这个概念:只关心值所反映的信息而不是其具体类型,类似于动态类型语言中称为”鸭子类型“的概念:如果它走起来像一只鸭子,叫起来像一只鸭子,那么它就是一只鸭子 在示例 Screen 上的 run 实现中,run 并不需要知道各个组件的具体类型是什么。它并不检查组件是 Button 或者 SelectBox 的实例,通过指定 Box<dyn Draw> 作为 components vector 中值的类型,就定义了 Screen 为需要可以在其上调用 draw 方法的值
使用 trait 对象和 Rust 类型系统来进行类似鸭子类型操作的优势是无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现 trait 对象所需的 trait 则 Rust 不会编译这些代码。例如,下面展示了当创建一个使用 String 做为其组件的 Screen 时发生的情况:
use gui::Screen; fn main() { let screen = Screen { components: vec![ Box::new(String::from("Hi")), ], }; screen.run(); }
这会导致编译报错,因为 String 没有实现 rust_gui::Draw trait :
error[E0277]: the trait bound `std::string::String: gui::Draw` is not satisfied --> src/main.rs:7:13 | 7 | Box::new(String::from("Hi")), | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait gui::Draw is not implemented for `std::string::String` | = note: required for the cast to the object type `gui::Draw`
要么是传递了并不希望传递给 Screen 的类型并应该提供其他类型,要么应该在 String 上实现 Draw 以便 Screen 可以调用其上的 draw
trait 对象执行动态分发
回忆一下“泛型代码的性能” 部分讨论过的,当对泛型使用 trait bound 时编译器所进行单态化处理:编译器为每一个被泛型类型参数代替的具体类型生成了非泛型的函数和方法实现 单态化所产生的代码进行 静态分发。静态分发发生于编译器在编译时就知晓调用了什么方法的时候 这与动态分发相对,这时编译器在编译时无法知晓调用了什么方法,在动态分发的情况下,编译器会生成在运行时确定调用了什么方法的代码
当使用 trait 对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于 trait 对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust 在运行时使用 trait 对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。尽管在编写代码的过程中确实获得了额外的灵活性,但仍然需要权衡取舍
Trait 对象要求对象安全
只有 对象安全 的 trait 才可以组成 trait 对象。围绕所有使得 trait 对象安全的属性存在一些复杂的规则,不过在实践中,只涉及到两条规则。如果一个 trait 中所有的方法有如下属性时,则该 trait 是对象安全的:
- 返回值类型 不为 Self
- 方法 没有任何 泛型类型 参数
对象安全对于 trait 对象是必须的,因为一旦有了 trait 对象,就不再知晓实现该 trait 的具体类型是什么了 Self 关键字是要实现 trait 或方法的类型的别名。如果 trait 方法返回具体的 Self 类型,但是 trait 对象忘记了其真正的类型,那么方法不可能使用已经忘却的原始具体类型 同理对于泛型类型参数来说,当使用 trait 时其会放入具体的类型参数:此具体类型变成了实现该 trait 的类型的一部分。当使用 trait 对象时其具体类型被抹去了,故无从得知放入泛型参数类型的类型是什么
一个 trait 的方法不是对象安全的例子是标准库中的 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 对象的对象安全规则的事情,编译器会提示你。例如,如果尝试实现 Screen 结构体来存放实现了 Clone trait 而不是 Draw trait 的类型,像这样:
pub struct Screen { pub components: Vec<Box<dyn Clone>>, }
将会得到如下错误:
error[E0038]: the trait `std::clone::Clone` cannot be made into an object --> src/lib.rs:44:5 | 44 | pub components: Vec<Box<dyn Clone>>, | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::clone::Clone` cannot be made into an object | = note: the trait cannot require that `Self : Sized`
面向对象设计模式的实现
状态模式是一个面向对象设计模式。该模式的关键在于一个值有某些内部状态,体现为一系列的 状态对象,同时值的行为随着其内部状态而改变。状态对象共享功能
使用状态模式意味着当程序的业务需求改变时,无需改变值持有状态或者使用值的代码,只需更新某个状态对象中的代码来改变其规则,或者是增加更多的状态对象 当然,在 Rust 中使用结构体和 trait 而不是对象和继承。每一个状态对象代表负责其自身的行为和当需要改变为另一个状态时的规则的状态。持有任何一个这种状态对象的值对于不同状态的行为以及何时状态转移毫不知情
为了探索这个概念,我们将实现一个增量式的发布博文的工作流。这个博客的最终功能看起来像这样:
- 博文从空白的草案开始
- 一旦草案完成,请求审核博文
- 一旦博文过审,它将被发表
- 只有被发表的博文的内容会被打印,这样就不会意外打印出没有被审核的博文的文本
- 任何其他对博文的修改尝试都是没有作用的。例如,如果尝试在请求审核之前通过一个草案博文,博文应该保持未发布的状态
下面展示这个工作流的代码形式:这是一个将要在一个叫做 blog 的库 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 创建一个新的博文草案 希望能在草案阶段为博文编写一些文本。如果尝试在审核之前立即打印出博文的内容,什么也不会发生因为博文仍然是草案。这里增加的 assert_eq! 出于演示目的 接下来,希望能够请求审核博文,而在等待审核的阶段 content 应该仍然返回空字符串 最后当博文审核通过,它应该被发表,这意味着当调用 content 时博文的文本将被返回
注意与 crate 交互的唯一的类型是 Post。这个类型会使用状态模式并会 存放 处于三种博文所可能的状态之一的值: 草案 , 等待审核 和 发布
- 状态上的改变由 Post 类型内部进行管理
- 状态依库用户对 Post 实例调用的方法而改变,但是不能直接管理状态变化。这也意味着用户不会在状态上犯错,比如在过审前发布博文
定义 Post 并新建一个草案状态的实例
- 定义一个结构体 Post 和一个创建实例的公有关联函数 new
- 定义一个私有 trait State。Post 将在私有字段 state 中存放一个 Option<T> 类型的 trait 对象 Box<dyn State>
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 并没有任何方法,同时开始将只定义 Draft 状态因为这是我们希望博文的初始状态
当创建新的 Post 时,将其 state 字段设置为一个存放了 Box 的 Some 值。这个 Box 指向一个 Draft 结构体新实例
这确保了无论何时新建一个 Post 实例,它都会从草案开始 因为 Post 的 state 字段是私有的,也就无法创建任何其他状态的 Post 了!
Post::new 函数中将 content 设置为新建的空 String
存放博文内容的文本
开头展示了希望能够调用一个叫做 add_text 的方法并向其传递一个 &str 来将文本增加到博文的内容中
选择实现为一个方法而不是将 content 字段暴露为 pub 这意味着之后可以实现一个方法来控制 content 字段如何被读取
add_text 方法是非常直观的:
impl Post { // --snip-- pub fn add_text(&mut self, text: &str) { self.content.push_str(text); } }
add_text 获取一个 self 的可变引用,因为需要改变调用 add_text 的 Post 实例。接着调用 content 中的 String 的 push_str 并传递 text 参数来保存到 content 中
这不是状态模式的一部分,因为它的行为并不依赖博文所处的状态 add_text 方法完全不与 state 状态交互,不过这是希望支持的行为的一部分
确保博文草案的内容是空的
即使调用 add_text 并向博文增加一些内容之后,仍然希望 content 方法返回一个空字符串 slice,因为博文仍然处于草案状态。现在用能满足要求的最简单的方式来实现 content 方法:总是返回一个空字符串 slice。当实现了将博文状态改为发布的能力之后将改变这一做法。但是目前博文只能是草案状态,这意味着其内容应该总是空的:
impl Post { // --snip-- pub fn content(&self) -> &str { "" } }
请求审核博文来改变其状态
增加请求审核博文的功能,这应当将其状态由 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:在 Post 的当前状态下调用内部的 request_review 方法,并且第二个 request_review 方法会消费当前的状态并返回一个新状态
- 给 State trait 增加了 request_review 方法
- 注意:不同于使用 self、 &self 或者 &mut self 作为方法的第一个参数,这里使用了 self: Box<Self> ,这个方法调用只对这个 类型的 Box 有效
- 这个语法获取了 Box<Self> 的 所有权 ,使老状态无效化以便 Post 的状态值可以将自身转换为新状态
- 注意:不同于使用 self、 &self 或者 &mut self 作为方法的第一个参数,这里使用了 self: Box<Self> ,这个方法调用只对这个 类型的 Box 有效
- 为了消费老状态,request_review 方法需要获取状态值的所有权。这也就是 Post 的 state 字段中 Option 的来历
- 调用 take 方法将 state 字段中的 Some 值取出并 留下 一个 None
- 因为 Rust 不允许在结构体中存在空的字段。这使得将 state 值移动出 Post 而不是借用它
- 接着将博文的 state 值设置为这个操作的结果
不同于像 self.state = self.state.request_review(); 这样的代码直接设置 state 字段,来获取 state 值的所有权 通过take,使得原来的State为Optional<None>,这确保了当 Post 被转换为新状态后其不再能使用老的 state 值
- Draft 的方法 request_review 的实现返回一个新的,装箱的 PendingReview 结构体的实例,其用来代表博文处于等待审核状态
- 结构体 PendingReview 同样也实现了 request_review 方法,不过它不进行任何状态转换。相反它返回自身,因为请求审核已经处于 PendingReview 状态的博文应该保持 PendingReview 状态
现在开始能够看出状态模式的优势了:Post 的 request_review 方法无论 state 是何值都是一样的,因为每个状态只负责它自己的规则
继续保持 Post 的 content 方法不变,返回一个空字符串 slice。现在可以拥有 PendingReview 状态而不仅仅是 Draft 状态的 Post 了,不过希望在 PendingReview 状态下其也有相同的行为
增加改变 content 行为的 approve 方法
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 的结构体,Published 状态。
类似于 request_review,如果对 Draft 调用 approve 方法,并没有任何效果,因为它会返回 self 当对 PendingReview 调用 approve 时,它返回一个新的、装箱的 Published 结构体的实例 Published 结构体实现了 State trait,同时对于 request_review 和 approve 两方法来说,它返回自身,因为在这两种情况博文应该保持 Published 状态
现在更新 Post 的 content 方法:如果状态为 Published 希望返回博文 content 字段的值;否则希望返回空字符串 slice:
impl Post { // --snip-- pub fn content(&self) -> &str { self.state.as_ref().unwrap().content(self) } // --snip-- }
因为目标是将所有像这样的规则保持在实现了 State 的结构体中,将调用 state 中的值的 content 方法并传递博文实例(也就是 self)作为参数。接着返回 state 值的 content 方法的返回值
- 这里调用 Option 的 as_ref 方法是因为需要 Option 中 值的引用 而不是 获取其所有权
因为 state 是一个 Option<Box<State>>,调用 as_ref 会返回一个 Option<&Box<State>> 如果不调用 as_ref,将会得到一个错误,因为不能将 state 移动出借用的 &self 函数参数
- 接着调用 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 } }
这里增加了一个 content 方法的默认实现来返回一个空字符串 slice。这意味着无需为 Draft 和 PendingReview 结构体实现 content 了。Published 结构体会覆盖 content 方法并会返回 post.content 的值
注意这个方法需要生命周期注解,这里获取 post 的引用作为参数,并返回 post 一部分的引用,所以返回的引用的生命周期与 post 参数相关
现在示例完成了,通过发布博文工作流的规则实现了状态模式。围绕这些规则的逻辑都存在于状态对象中而不是分散在 Post 之中
状态模式的权衡取舍
现在展示了 Rust 是能够实现面向对象的状态模式的,以便能根据博文所处的状态来封装不同类型的行为。Post 的方法并不知道这些不同类型的行为。通过这种组织代码的方式,要找到所有已发布博文的不同行为只需查看一处代码:Published 的 State trait 的实现
如果要创建一个不使用状态模式的替代实现,则可能会在 Post 的方法中,或者甚至于在 main 代码中用到 match 语句,来检查博文状态并在这里改变其行为 这意味着需要查看很多位置来理解处于发布状态的博文的所有逻辑!这在增加更多状态时会变得更糟:每一个 match 语句都会需要另一个分支
对于状态模式来说,Post 的方法和使用 Post 的位置无需 match 语句,同时增加新状态只涉及到增加一个新 struct 和为其实现 trait 的方法。
这个实现易于扩展增加更多功能。为了体会使用此模式维护代码的简洁性,请尝试如下一些修改: 1. 增加 reject 方法将博文的状态从 PendingReview 变回 Draft 2. 在将状态变为 Published 之前需要两次 approve 调用 3. 只允许博文处于 Draft 状态时增加文本内容
状态模式的缺点:
- 因为状态实现了状态之间的转换,一些状态会相互联系。如果在 PendingReview 和 Published 之间增加另一个状态,比如 Scheduled,则不得不修改 PendingReview 中的代码来转移到 Scheduled。如果 PendingReview 无需因为新增的状态而改变就更好了,不过这意味着切换到另一种设计模式
- 一些重复的逻辑
- 可以尝试为 State trait 中返回 self 的 request_review 和 approve 方法增加默认实现
不过这会违反对象安全性,因为 trait 不知道 self 具体是什么。希望能够将 State 作为一个 trait 对象,所以需要其方法是对象安全的
- Post 中 request_review 和 approve 这两个类似的实现。他们都委托调用了 state 字段中 Option 值的同一方法,并在结果中为 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 的 content 方法
仍然有一个 Post::new 函数,不过不同于返回 Post 实例,它返回 DraftPost 的实例。现在不可能创建一个 Post 实例,因为 content 是私有的同时没有任何函数返回 Post。
DraftPost 上定义了一个 add_text 方法,这样就可以像之前那样向 content 增加文本,不过注意 DraftPost 并没有定义 content 方法!如此现在程序确保了所有博文都从草案开始,同时草案博文没有任何可供展示的内容。任何绕过这些限制的尝试都会产生编译错误
实现状态转移为不同类型的转换
那么如何得到发布的博文呢?希望强制执行的规则是草案博文在可以发布之前必须被审核通过。等待审核状态的博文应该仍然不会显示任何内容。通过增加另一个结构体 PendingReviewPost 来实现这个限制,在 DraftPost 上定义 request_review 方法来返回 PendingReviewPost,并在 PendingReviewPost 上定义 approve 方法来返回 Post:
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。这样在调用 request_review 之后就不会遗留任何 DraftPost 实例,后者同理
PendingReviewPost 并没有定义 content 方法,所以尝试读取其内容会导致编译错误,DraftPost 同理。因为唯一得到定义了 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()); }
不得不修改 main 来重新赋值 post 使得这个实现不再完全遵守面向对象的状态模式:状态间的转换不再完全封装在 Post 实现中。然而,得益于类型系统和编译时类型检查,得到了的是无效状态是不可能的!这确保了某些特定的 bug,比如显示未发布博文的内容,将在部署到生产环境之前被发现
即便 Rust 能够实现面向对象设计模式,也有其他像将状态编码进类型这样的模式存在 这些模式有着不同的权衡取舍。虽然你可能非常熟悉面向对象模式,重新思考这些问题来利用 Rust 提供的像在编译时避免一些 bug 这样有益功能 在 Rust 中面向对象模式并不总是最好的解决方案,因为 Rust 拥有像所有权这样的面向对象语言所没有的功能
总结
- trait 对象是一个 Rust 中获取部分面向对象功能的方法
- 动态分发可以通过牺牲少量运行时性能来为代码提供一些灵活性。这些灵活性可以用来实现有助于代码可维护性的面向对象模式
- Rust 也有像所有权这样不同于面向对象语言的功能,面向对象模式并不总是利用 Rust 优势的最好方式,但也是可用的选项