键盘快捷键

按 ← 或 → 在章节之间导航

按 S 或 / 在书中搜索

按 ? 显示此帮助

按 Esc 隐藏此帮助

面向对象语言的特性

在编程社区中,关于一门语言必须具备哪些特性才能被视为面向对象, 并没有共识。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 结构体的定义。

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

该结构体被标记为 pub,以便其他代码可以使用它,但结构体内的 字段保持私有。这在该场景中很重要,因为我们希望确保每当向列表中 添加或移除值时,平均值也会随之更新。我们通过在结构体上实现 add、remove 和 average 方法来实现这一点,如示例 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 上实现公有方法 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 对象是 如何工作的。