面向对象语言的特性

在编程社区中,对于一种语言必须具备哪些特性才能被认为是面向对象的,并没有达成共识。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 将为我们缓存计算出的平均值。列表 18-1 包含 AveragedCollection 结构体的定义

文件名:src/lib.rs
pub struct AveragedCollection { list: Vec<i32>, average: f64, }
列表 18-1:AveragedCollection 结构体,它维护一个整数列表和集合中项目的平均值

该结构体被标记为 pub,以便其他代码可以使用它,但结构体内的字段仍然是私有的。在这种情况下,这很重要,因为我们要确保每当从列表中添加或删除值时,平均值也会更新。我们通过在结构体上实现 addremoveaverage 方法来做到这一点,如列表 18-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; } }
列表 18-2:在 AveragedCollection 上实现公共方法 addremoveaverage

公共方法 addremoveaverage 是访问或修改 AveragedCollection 实例中数据的唯一方法。当使用 add 方法将项目添加到 list 或使用 remove 方法删除项目时,每个方法的实现都会调用私有的 update_average 方法,该方法也会处理更新 average 字段。

我们将 listaverage 字段保留为私有的,因此外部代码无法直接向 list 字段添加或从中删除项目;否则,当 list 更改时,average 字段可能会变得不同步。average 方法返回 average 字段中的值,允许外部代码读取 average 但不能修改它。

由于我们封装了 AveragedCollection 结构体的实现细节,因此我们将来可以轻松更改方面,例如数据结构。例如,我们可以使用 HashSet<i32> 而不是 Vec<i32> 作为 list 字段。只要 addremoveaverage 公共方法的签名保持不变,使用 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 bounds 来对这些类型必须提供的功能施加约束。这有时被称为有界参数多态

继承最近在许多编程语言中作为一种编程设计解决方案而失宠,因为它通常有共享超过必要代码的风险。子类不应始终共享其父类的所有特征,但会通过继承来做到这一点。这会使程序的设计灵活性降低。它还引入了在子类上调用没有意义或导致错误的方法的可能性,因为这些方法不适用于子类。此外,某些语言只允许单继承(意味着子类只能从一个类继承),这进一步限制了程序设计的灵活性。

由于这些原因,Rust 采用了使用 trait 对象而不是继承的不同方法。让我们看看 trait 对象如何在 Rust 中实现多态。