面向对象语言的特性

在编程社区中,关于一门语言必须具备哪些特性才能被视为面向对象,并没有达成共识。Rust 受到了许多编程范式的影响,包括面向对象编程(OOP);例如,我们在第13章探讨了来自函数式编程的特性。可以说,OOP 语言共享某些共同的特性,即对象、封装和继承。让我们来看看这些特性各自意味着什么,以及 Rust 是否支持它们。

对象包含数据和行为

Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 所著的《设计模式:可复用面向对象软件的元素》(Addison-Wesley,1994)一书,俗称《四人帮》书,是一本面向对象设计模式的目录。它以这种方式定义了 OOP:

面向对象程序由对象组成。对象 封装了数据以及操作这些数据的程序。这些程序通常被称为 方法操作

根据这个定义,Rust 是面向对象的:结构体和枚举包含数据,而 impl 块为结构体和枚举提供了方法。尽管带有方法的结构体和枚举不被称为对象,但根据四人帮对对象的定义,它们提供了相同的功能。

封装隐藏实现细节

另一个通常与 OOP 相关的概念是 封装,这意味着使用对象的代码无法访问对象的实现细节。因此,与对象交互的唯一方式是通过其公共 API;使用对象的代码不应该能够直接访问对象的内部并更改数据或行为。这使得程序员可以在不需要更改使用对象的代码的情况下,更改和重构对象的内部实现。

我们在第7章讨论了如何控制封装:我们可以使用 pub 关键字来决定代码中的哪些模块、类型、函数和方法应该是公共的,而默认情况下其他所有内容都是私有的。例如,我们可以定义一个结构体 AveragedCollection,它有一个包含 i32 值向量的字段。该结构体还可以有一个包含向量中值的平均值的字段,这意味着不需要在每次需要时计算平均值。换句话说,AveragedCollection 会为我们缓存计算出的平均值。清单18-1展示了 AveragedCollection 结构体的定义:

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

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

公共方法 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 约束来对这些类型必须提供的内容施加限制。这有时被称为 有界参数多态性

作为一种编程设计解决方案,继承最近在许多编程语言中不再受欢迎,因为它经常面临共享过多代码的风险。子类不应该总是共享其父类的所有特征,但在继承中会这样做。这可能会使程序的设计变得不那么灵活。它还引入了在子类上调用没有意义或导致错误的方法的可能性,因为这些方法不适用于子类。此外,某些语言只允许单一继承(意味着子类只能继承一个类),进一步限制了程序设计的灵活性。

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