简介
欢迎来到「面向对象语言的特性」实验。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将探索面向对象语言的特性,包括对象、封装和继承,并研究 Rust 是否支持这些特性。
欢迎来到「面向对象语言的特性」实验。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将探索面向对象语言的特性,包括对象、封装和继承,并研究 Rust 是否支持这些特性。
对于一门语言要具备哪些特性才能被视为面向对象,编程社区尚无共识。Rust 受到包括面向对象编程(OOP)在内的多种编程范式的影响;例如,我们在第 13 章探讨了来自函数式编程的特性。可以说,面向对象语言具有某些共同特征,即对象、封装和继承。让我们看看这些特征各自的含义以及 Rust 是否支持它们。
由艾瑞克·伽马(Erich Gamma)、理查德·海尔姆(Richard Helm)、拉尔夫·约翰森(Ralph Johnson)和约翰·维利斯 ides(John Vlissides)所著的《设计模式:可复用的面向对象软件元素》(Design Patterns: Elements of Reusable Object-Oriented Software,艾迪生 - 韦斯利出版社,1994 年),通俗地被称为「四人帮」的书,是一本面向对象设计模式的目录。它以这种方式定义了面向对象编程:
面向对象程序由对象组成。一个对象封装了数据以及对该数据进行操作的过程。这些过程通常被称为方法或操作。
根据这个定义,Rust 是面向对象的:结构体(structs)和枚举(enums)有数据,并且 impl 块为结构体和枚举提供方法。尽管带有方法的结构体和枚举不被称为对象,但根据「四人帮」对对象的定义,它们提供了相同的功能。
另一个通常与面向对象编程相关联的方面是「封装」的概念,这意味着使用对象的代码无法访问该对象的实现细节。因此,与对象进行交互的唯一方式是通过其公共 API;使用该对象的代码不应能够深入到对象的内部并直接更改数据或行为。这使程序员能够在无需更改使用该对象的代码的情况下,更改和重构对象的内部实现。
我们在第 7 章讨论了如何控制封装:我们可以使用 pub 关键字来决定代码中的哪些模块、类型、函数和方法应该是公共的,默认情况下其他所有内容都是私有的。例如,我们可以定义一个结构体 AveragedCollection,它有一个字段包含 i32 值的向量。该结构体还可以有一个字段包含向量中值的平均值,这意味着不必在每次有人需要时按需计算平均值。换句话说,AveragedCollection 将为我们缓存计算出的平均值。清单 17-1 展示了 AveragedCollection 结构体的定义。
文件名:src/lib.rs
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
清单 17-1:一个 AveragedCollection 结构体,它维护一个整数列表以及该列表中元素的平均值
该结构体被标记为 pub,以便其他代码可以使用它,但结构体中的字段仍然是私有的。在这种情况下这很重要,因为我们希望确保每当向列表中添加或删除一个值时,平均值也会更新。我们通过在结构体上实现 add、remove 和 average 方法来做到这一点,如清单 17-2 所示。
文件名:src/lib.rs
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;
}
}
清单 17-2:AveragedCollection 上公共方法 add、remove 和 average 的实现
公共方法 add、remove 和 average 是访问或修改 AveragedCollection 实例中数据的唯一方式。当使用 add 方法向 list 中添加一个元素或使用 remove 方法删除一个元素时,每个方法的实现都会调用私有的 update_average 方法,该方法负责处理更新 average 字段。
我们将 list 和 average 字段设为私有,这样外部代码就无法直接向 list 字段中添加或删除元素;否则,当 list 发生变化时,average 字段可能会不同步。average 方法返回 average 字段中的值,允许外部代码读取 average 但不能修改它。
因为我们已经封装了 AveragedCollection 结构体的实现细节,所以将来我们可以轻松地更改一些方面,比如数据结构。例如,我们可以将 list 字段的 Vec<i32> 换成 HashSet<i32>。只要 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 中实现多态性。
恭喜你!你已经完成了「面向对象语言的特性」实验。你可以在 LabEx 中练习更多实验来提升你的技能。