面向对象语言的特性
在编程社区中,关于一门语言必须具备哪些特性才能被视为面向对象, 并没有共识。Rust 受到许多编程范式的影响,包括 OOP;例如, 我们在第 13 章探讨了来自函数式编程的特性。可以说,OOP 语言 具有某些共同特征——即对象(objects)、封装(encapsulation) 和继承(inheritance)。让我们看看这些特征各自的含义,以及 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)将数据 以及操作这些数据的过程打包在一起。这些过程通常 被称为方法(methods)或操作(operations)。
按照这个定义,Rust 是面向对象的:结构体和枚举包含数据,
而 impl 块为结构体和枚举提供方法。尽管带有方法的结构体和
枚举不被称为对象,但根据四人组对对象的定义,它们提供了
相同的功能。
隐藏实现细节的封装
另一个通常与 OOP 相关联的方面是封装(encapsulation)的思想, 即使用对象的代码无法访问该对象的实现细节。因此, 与对象交互的唯一方式是通过其公有 API;使用对象的代码 不应能深入对象内部直接修改数据或行为。这使得程序员 可以修改和重构对象的内部实现,而无需更改使用该对象的 代码。
我们在第 7 章讨论过如何控制封装:可以使用 pub
关键字来决定代码中哪些模块、类型、函数和方法应该是公有的,
默认情况下其他一切都是私有的。例如,我们可以定义一个
结构体 AveragedCollection,它有一个存放 i32 值
vector 的字段。该结构体还可以有一个字段存放 vector 中值的
平均值,这样平均值就不需要在每次有人需要时再按需计算。
换句话说,AveragedCollection 会为我们缓存计算出的平均值。
示例 18-1 给出了 AveragedCollection 结构体的定义。
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
AveragedCollection 结构体该结构体被标记为 pub,以便其他代码可以使用它,但结构体内的
字段保持私有。这在该场景中很重要,因为我们希望确保每当向列表中
添加或移除值时,平均值也会随之更新。我们通过在结构体上实现
add、remove 和 average 方法来实现这一点,如示例 18-2
所示。
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;
}
}
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 代码中可以使用默认 trait 方法实现来有限地做到这一点,
正如在示例 10-14 中我们给 Summary trait 添加 summarize
方法的默认实现那样。任何实现 Summary trait 的类型都会
自动拥有可用的 summarize 方法,无需编写任何额外代码。
这类似于父类拥有某个方法的实现,继承的子类也拥有该方法的
实现。在实现 Summary trait 时,我们也可以覆盖
summarize 方法的默认实现,这类似于子类覆盖从父类
继承的方法实现。
使用继承的另一个原因与类型系统有关:使子类型能够用在 与父类型相同的地方。这也被称为多态(polymorphism), 即如果多个对象共享某些特征,就可以在运行时将它们 相互替换使用。
多态
对许多人来说,多态与继承是同义词。但它实际上是一个 更宽泛的概念,指可以处理多种类型数据的代码。对于继承 而言,这些类型通常是子类。
Rust 则使用泛型对不同的可能类型进行抽象,并使用 trait 边界对这些类型必须提供的内容施加约束。这有时被称为 有界参数化多态(bounded parametric polymorphism)。
Rust 选择不提供继承,采取了不同的取舍。继承常常面临 共享代码超出必要范围的风险。子类并不总应共享父类的所有 特征,但使用继承时会被迫如此,这会降低程序设计的灵活性。 它还引入了在子类上调用无意义或会引发错误的方法的可能性, 因为这些方法并不适用于该子类。此外,一些语言只允许 单继承(single inheritance,即子类只能继承自一个类), 进一步限制了程序设计的灵活性。
出于这些原因,Rust 采用了不同的方案:使用 trait 对象 而非继承来实现运行时多态。接下来我们看看 trait 对象是 如何工作的。