面向对象语言的特性

在编程社区中,对于一种语言必须具备哪些特性才能被视为面向对象的,还没有达成共识。Rust 受到了许多编程范式的影响,包括面向对象编程;例如,我们在第 13 章中探讨了来自函数式编程的特性。可以说,面向对象语言具有一些共同的特征,即对象、封装和继承。让我们看看每个特征的含义以及 Rust 是否支持它们。

对象包含数据和行为

由 Erich Gamma, Richard Helm, Ralph Johnson, 和 John Vlissides (Addison-Wesley Professional, 1994) 撰写的书籍 Design Patterns: Elements of Reusable Object-Oriented Software,俗称 The Gang of Four 书,是一本关于面向对象设计模式的目录。它这样定义面向对象编程:

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

使用这个定义,Rust 是面向对象的:结构体和枚举有数据,impl 块为结构体和枚举提供方法。即使带有方法的结构体和枚举不称为对象,它们根据四人组对对象的定义提供了相同的功能。

隐藏实现细节的封装

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

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

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}
Listing 18-1: An AveragedCollection struct that maintains a list of integers and the average of the items in the collection

该结构体被标记为pub,以便其他代码可以使用它,但结构体内的字段仍然保持私有。这在本例中很重要,因为我们要确保每当向列表中添加或删除值时,平均值也会被更新。我们通过在结构体上实现addremoveaverage方法来实现这一点,如清单18-2所示:

Filename: 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;
    }
}
Listing 18-2: Implementations of the public methods add, remove, and average on AveragedCollection

公共方法 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 或不使用 pub 为代码的不同部分启用实现细节的封装。

继承作为类型系统和代码共享

继承是一种机制,通过这种机制,一个对象可以从另一个对象的定义中继承元素,从而获得父对象的数据和行为,而无需再次定义它们。

如果一种语言必须具有继承才能成为面向对象的语言,那么 Rust 就不是一种面向对象的语言。没有办法定义一个结构体来继承父结构体的字段和方法实现,除非使用宏。

但是,如果您习惯于在编程工具箱中使用继承,您可以根据最初使用继承的原因在 Rust 中使用其他解决方案。

你会出于两个主要原因选择继承。一个是代码重用: 你可以在一个类型上实现特定的行为,继承使你能够 在不同的类型上重用该实现。你可以在 Rust 代码中以有限的方式使用默认特征方法实现来实现这一点,我们在 清单 10-14 中添加了 Summary 特征的 summarize 方法的默认实现时已经看到了这一点。任何实现了 Summary 特征的类型都会 在没有进一步代码的情况下拥有 summarize 方法。这类似于父类有一个方法的实现,继承的子类也有该方法的实现。当我们在实现 Summary 特征时,还可以覆盖 summarize 方法的默认实现,这类似于子类覆盖从父类继承的方法的实现。

使用继承的另一个原因与类型系统有关:使子类型能够在与父类型相同的地方使用。这也可以称为多态性,这意味着如果多个对象共享某些特性,你可以在运行时用一个对象替代另一个对象。

多态性

对许多人来说,多态性等同于继承。但实际上,它是一个更广泛的概念,指的是可以处理多种类型数据的代码。对于继承,这些类型通常是子类。

Rust 相反使用泛型来抽象出不同的可能类型,并使用特质边界来对这些类型必须提供的内容施加约束。这有时被称为有界参数多态

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

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