面对对象编程(Object-Oriented Programming,OOP)是一种程序建模的方法。这篇文章会介绍一些形成了普遍共识的面对对象特性,并学习如何在 Rust 语言的习惯下实现这些特性。
面向对象的特性
Rust 在开发过程中受到众多编程范式的影响,这其中就包含了面向对象编程。面向对象的语言通常包含以下特性:命名对象、封装和继承。
对象包含数据和行为
面向对象的程序由对象组成。对象包装了数据和操作这些数据的流程。这些流程通常被称作方法或操作。基于这个定义,Rust 是面向对象的:结构体和枚举包含数据,而 impl
块则提供了可用于结构体和枚举的方法。
封装实现细节
另外一个常常伴随着面向对象编程的思想便是封装(encapsulation):调用对象的外部代码无法直接访问对象内部的实现细节,而唯一可以与对象进行交互的方法便是通过它公开的接口。封装使得开发者在修改或重构对象的内部实现时无须改变调用这个对象的外部代码。
在 Rust 中,使用 pub 关键字来决定代码中的哪些模块、类型、函数和方法是公开的,而默认情况下其他所有内容都是私有的。在不同的代码区域选择是否添加 pub 关键字可以实现对细节的封装。
作为类型系统和代码共享机制的继承
继承(inheritance)机制使得对象可以沿用另一个对象的数据与行为,而无须重复定义代码。虽然 Rust 中我们无法定义一个 继承父结构体字段和方法实现
的子结构体,但是我们可以根据使用继承时希望达成的效果来选择其他的 Rust 解决方案。
继承主要有两个目的:
- 代码复用:可以为某个类型实现某种行为,并接着通过继承来让另一个类型直接复用这一实现。作为替代方案,可以使用 Rust 中的默认 trait 方法实现代码共享,同时也支持覆盖实现
- 多态:希望子类型能够被应用在一个需要父类型的地方,如果一些对象具有某些共同的特性,那么这些对象就是可以在运行时相互替换使用。在 Rust 中可以使用泛型来构建不同类型的抽象,并使用 trait 约束来决定类型必须提供的具体特性。这有时也被称为
限定参数化多态
Rust 选择了 trait 对象来替代继承,接下来将详细展示 trait 对象如何在 Rust 中实现多态。
使用 trait 对象来存储不同类型的值
trait 对象能够指向实现了指定 trait 的类型实例,以及一个用于在运行时查找 trait 方法的表。我们可以通过选用一种指针,例如 & 引用或者 Box
trait 对象可以被用在泛型或具体类型所处的位置。无论我们在哪里使用 trait 对象,Rust 类型系统都会在编译时确保出现在相应位置上的值实现 trait 对象指定的 trait。因此无需在编译时知晓所有可能的具体类型。
Rust 有意避免将结构体和枚举称为对象,以便与其他语言中的对象概念区分开来。对于结构体与枚举而言,它们的字段数据与 impl 块中的行为是分开的,这与 C++ 等语言的对象是不同的(数据和行为组合在对象的概念中)。而 trait 对象则有点类似于 C++ 语言中的对象,因为它也在某种程度上组合了数据与行为。但是 trait 对象与传统对象不同的地方在于,我们无法为 trait 对象添加数据。由于 trait 对象被专门用于抽象某些共有行为,所以它没有其他语言中的对象那么通用。
如下定义了一个 Draw trait,然后 Screen 结构体中的 components 字段则是一个动态数组,该数组中保存了 实现 Draw trait
的 trait 对象。Screen 结构体定义了一个 run 方法,其会逐个调用 components 中每个元素的 draw 方法。
1 | pub trait Draw { |
上述代码还有一种实现方式,即使用带有 trait 约束的泛型参数来定义结构体,但工作机制截然不同。泛型参数一次只能替代为一个具体的类型,而 trait 对象则允许你在运行时填入多种不同的具体类型。
1 | pub trait Draw { |
此时 Vec
中只能存储由某一相同类型组成的列表,如果需要的仅仅是同质集合,那么使用泛型和 trait 约束就行,因为这段定义会在编译时被多态化为具体使用的类型。而借助 trait 对象,则可以让 Vec 同时包含不同类型的实例。
run 方法中的代码只关心 值
的行为,而不在意值的具体类型,这一概念与动态类型语言中的 鸭子类型
十分相似:如果某个东西走起来像鸭子、叫起来也像鸭子,那么它就是一只鸭子。通过在定义动态数组 components 时指定元素类型为 Box<dyn Draw>
,Screen 实例只会接收那些能够调用 draw 方法的值。
使用 trait 对象与类型系统来实现 鸭子类型
有一个明显的优势:我们永远不需要在运行时检查某个值是否实现了指定的方法,或者担心出现 调用未定义方法
等运行时错误。Rust 根本就不会允许这样的代码通过编译。
trait 对象会执行动态派发
我们之前介绍过,编译器会被每一个具体类型生成对应的泛型函数和泛型方法的非泛型实现,并使用这些具体的类型来替换泛型参数。通过单态化生成的代码会执行静态派发,这意味着编译器能够在编译过程中确定你调用的具体方法。这个概念与动态派发相对应,动态派发下编译器无法在编译过程中确定你调用的究竟是哪一个方法。在进行动态派发场景中,编译器会生成一些额外的代码以便在运行时找出我们希望调用的方法。
Rust 会在我们使用 trait 对象时执行动态派发,因为编译器无法知晓所有能够用于 trait 对象的具体类型,所以无法在编译时确定需要调用哪个类型的具体方法。Rust 会在运行时通过 trait 对象的内部指针去定位具体调用哪个方法。该定位过程会产生一些运行时开销(静态派发没有这种开销)。动态派发还会组织编译器内联代码,进而使得部分优化操作无法进行。但动态派发也的确可以为代码带来灵活性,所以需要综合考虑是否需要在项目中使用 trait 对象。
trait 对象必须保证对象安全
我们只能把满足对象安全(object-safe)的 trait 转换为 trait 对象。如果一个 trait 中的定义的方法满足下面两条规则,那么这个 trait 就是对象安全的:
- 方法的返回类型不是 self。关键字 self 是一个别名,它指向了实现当前 trait 或方法的具体类型
- 方法中不包含任何泛型参数
编译器会在你使用 trait 对象时指出违反了 对象安全
规则的地方。
实现一种面向对象的设计模式
状态模式是一种面向对象的设计模式,它的关键特点是:一个值拥有的内部状态由数个状态对象表达而成,而值的行为则随着内部状态的改变而改变。这里我们将使用 Rust 来实现该模式,如下是一个示例:
1 | pub struct Post { |
Box<Self>
参数(而不是 self、&self、&mut self)意味着该方法只能被包裹着当前类型的 Box 实例调用,它会在调用过程中获取Box<Self>
的所有权并使旧的状态失效,从而将 Post 的状态转换为一个新的状态- 为了消耗旧的状态,request_review 方法需要获取状态值的所有权,这也正是 state 字段引入 Option 的原因:Rust 不允许结构体中出现未被填充的值。通过
Option<T>
的 take 方法来取出 state 字段的 Some 值,并在原来的位置留下一个 None - 由于 state 的类型是
Option<Box<dyn State>>
,所以调用as_ref
时得到Option<&Box<dyn state>>
。as_ref
用于获取 Option 中值的引用。如果没有as_ref
,那么会出现编译错误,因为我们的 content 方法参数类型是&self
,无法得到 state 字段的所有权,所以不能直接调用unwrap()
- content 方法添加了默认的 trait 实现,它返回空字符串。Published 结构体覆盖 content 方法并返回
post.content
状态模式的权衡取舍
在这个例子中,基于状态模式,我们就可以免于在 Post 的方法或者使用 Post 的代码中添加 match 表达式。当业务需要新增状态时,也只需要创建一个新的结构体并为它实现 trait 的各种方法即可。但是状态模式也有缺点:例如状态之间是相互耦合的、重复实现了一些代码逻辑(不能将这些重复逻辑移动到 trait 的默认实现中,但是这样会违反对象安全原则)。
严格按照面向对象语言的定义来实现一套状态模式自然是可行的,但是这并不能完全发挥 Rust 的全部威力。可以对上面的代码进行修改,使得无效的状态和状态转移暴露为编译时错误。主要修改方法则是 将状态和行为编码成类型,将状态转移实现为不同类型之间的转换
,最终实现核心目标:借助类型系统和编译时类型检查彻底地杜绝无效状态。
Rust 不仅可以实现面向对象的设计模式,还可以支持其他模式,例如将状态编码到类型系统中等。不同的模式有不同的取舍。充分利用 Rust 的特性来重新思考问题会带来不少好处,例如将部分错误暴露在编译期。面向对象的经典模式并不总是 Rust 编程实践中的最佳选择,因为 Rust 具有所有权等其他面向对象语言所没有的特性
。