面向对象语言的特性
在编程社区中,对于一门语言必须具备哪些特性才能被视为面向对象语言,并没有达成共识。Rust 受到了许多编程范式的启发,包括 OOP;例如,我们在第 13 章中探讨了函数式编程的特性。可以说,OOP 语言具有一些共同的特征,即对象、封装和继承。让我们看看这些特征分别意味着什么,以及 Rust 是否支持它们。
对象包含数据和行为
Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 合著的《设计模式:可复用面向对象软件的要素》(Addison-Wesley Professional,1994 年),俗称“四人帮”书籍,是一本面向对象设计模式的目录。它对 OOP 的定义如下:
面向对象程序由对象组成。_对象_将数据和操作数据的过程打包在一起。这些过程通常称为_方法_或_操作_。
根据这个定义,Rust 是面向对象的:结构体和枚举具有数据,而 `impl` 块为结构体和枚举提供了方法。尽管带有方法的结构体和枚举不被称为_对象_,但根据“四人帮”对对象的定义,它们提供了相同的功能。
隐藏实现细节的封装
与 OOP 相关的另一个方面是_封装_的概念,这意味着对象的实现细节对使用该对象的代码不可访问。因此,与对象交互的唯一方法是通过其公共 API;使用对象的代码不应该能够访问对象的内部并直接更改数据或行为。这使得程序员能够在不需要更改使用对象的代码的情况下更改和重构对象的内部结构。
我们在第 7 章中讨论了如何控制封装:我们可以使用 `pub` 关键字来决定代码中的哪些模块、类型、函数和方法应该是公共的,默认情况下其他所有内容都是私有的。例如,我们可以定义一个结构体 `AveragedCollection`,它包含一个字段,该字段包含一个 `i32` 值的向量。该结构体还可以包含一个字段,该字段包含向量中值的平均值,这意味着不需要在每次需要时都按需计算平均值。换句话说,`AveragedCollection` 将为我们缓存计算出的平均值。清单 17-1 显示了 `AveragedCollection` 结构体的定义。
文件名:src/lib.rs
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
该结构体被标记为 `pub`,以便其他代码可以使用它,但结构体内的字段保持私有。这在这种情况下很重要,因为我们希望确保每当向列表中添加或删除值时,平均值也会更新。我们通过在结构体上实现 `add`、`remove` 和 `average` 方法来做到这一点,如清单 17-2 所示。
文件名:src/lib.rs
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` 方法删除项目时,每个方法的实现都会调用私有方法 `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 中使用其他解决方案,这取决于您最初使用继承的原因。
选择继承主要有两个原因。一个是代码重用:您可以为一种类型实现特定的行为,而继承使您能够为另一种类型重用该实现。您可以在 Rust 代码中使用默认的 trait 方法实现来有限地做到这一点,您在代码清单 10-14 中看到了这一点,当时我们在 Summary
trait 上添加了 summarize
方法的默认实现。任何实现了 Summary
trait 的类型都可以在其上使用 summarize
方法,而无需任何额外的代码。这类似于父类有一个方法的实现,而继承的子类也有该方法的实现。当我们实现 Summary
trait 时,我们也可以覆盖 summarize
方法的默认实现,这类似于子类覆盖从父类继承的方法的实现。
使用继承的另一个原因与类型系统有关:使子类型能够在与父类型相同的地方使用。这也称为多态性,这意味着如果多个对象在运行时共享某些特征,则可以相互替换。
多态性
对许多人来说,多态性是继承的同义词。但它实际上是一个更一般的概念,指的是可以处理多种类型数据的代码。对于继承,这些类型通常是子类。
Rust 使用泛型来抽象不同的可能类型,并使用 trait 边界来对这些类型必须提供的功能施加约束。这有时被称为有界参数多态性。
继承最近在许多编程语言中不再受青睐,因为它经常有可能共享比必要更多的代码。子类不应该总是共享其父类的所有特征,但在继承的情况下会这样做。这会降低程序设计的灵活性。它还引入了调用子类上没有意义或导致错误的方法的可能性,因为这些方法不适用于子类。此外,某些语言只允许单继承(意味着一个子类只能继承自一个类),这进一步限制了程序设计的灵活性。
由于这些原因,Rust 采取了使用 trait 对象而不是继承的不同方法。让我们看看 trait 对象是如何在 Rust 中实现多态性的。