Skip to content

面向对象

面向对象语言的特点

对象包含数据和行为

由 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 块提供了在结构体和枚举之上的方法。
虽然带有方法的结构体和枚举并不被 称为 对象,但是他们提供了与对象相同的功能,参考 Gang of Four 中对象的定义。

封装隐藏了实现细节

另一个通常与面向对象编程相关的方面是 封装(encapsulation)的思想:对象的实现细节不能被使用对象的代码获取到。
所以唯一与对象交互的方式是通过对象提供的公有 API;使用对象的代码无法深入到对象内部并直接改变数据或者行为。
封装使得改变和重构对象的内部时无需改变使用对象的代码。

可以使用 pub 关键字来决定模块、类型、函数和方法是公有的,而默认情况下其他一切都是私有的。
比如,我们可以定义一个包含一个 i32 类型 vector 的结构体 AveragedCollection。
结构体也可以有一个字段,该字段保存了 vector 中所有值的平均值。
这样,希望知道结构体中的 vector 的平均值的人可以随时获取它,而无需自己计算。
换句话说,AveragedCollection 会为我们缓存平均值结果。
示例有 AveragedCollection 结构体的定义:

1
2
3
4
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

注意,结构体自身被标记为 pub,这样其他代码就可以使用这个结构体,但是在结构体内部的字段仍然是私有的。
这是非常重要的,因为我们希望保证变量被增加到列表或者被从列表删除时,也会同时更新平均值。
可以通过在结构体上实现 add、remove 和 average 方法来做到这一点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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;
    }
}

公有方法 add、remove 和 average 是修改 AveragedCollection 实例的唯一方式。
当使用 add 方法把一个元素加入到 list 或者使用 remove 方法来删除时,这些方法的实现同时会调用私有的 update_average 方法来更新 average 字段。

list 和 average 是私有的,所以没有其他方式来使得外部的代码直接向 list 增加或者删除元素,否则 list 改变时可能会导致 average 字段不同步。
average 方法返回 average 字段的值,这使得外部的代码只能读取 average 而不能修改它。

因为我们已经封装好了 AveragedCollection 的实现细节,将来可以轻松改变类似数据结构这些方面的内容。
例如,可以使用 HashSet 代替 Vec 作为 list 字段的类型。
只要 add、remove 和 average 公有函数的签名保持不变,使用 AveragedCollection 的代码就无需改变。
相反如果使得 list 为公有,就未必都会如此了: HashSet 和 Vec 使用不同的方法增加或移除项,所以如果要想直接修改 list 的话,外部的代码可能不得不做出修改。

如果封装是一个语言被认为是面向对象语言所必要的方面的话,那么 Rust 满足这个要求。
在代码中不同的部分使用 pub 与否可以封装其实现细节。

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

继承(Inheritance)是一个很多编程语言都提供的机制,一个对象可以定义为继承另一个对象的定义,这使其可以获得父对象的数据和行为,而无需重新定义。

如果一个语言必须有继承才能被称为面向对象语言的话,那么 Rust 就不是面向对象的。
无法定义一个结构体继承父结构体的成员和方法。
然而,如果你过去常常在你的编程工具箱使用继承,根据你最初考虑继承的原因,Rust 也提供了其他的解决方案。

选择继承有两个主要的原因。第一个是为了重用代码:一旦为一个类型实现了特定行为,继承可以对一个不同的类型重用这个实现。
相反 Rust 代码可以使用默认 trait 方法实现来进行共享

第二个使用继承的原因与类型系统有关:表现为子类型可以用于父类型被使用的地方。
这也被称为 多态(polymorphism),这意味着如果多种对象共享特定的属性,则可以相互替代使用。

多态(Polymorphism):
很多人将多态描述为继承的同义词。
不过它是一个有关可以用于多种类型的代码的更广泛的概念。
对于继承来说,这些类型通常是子类。 Rust 则通过泛型来使得对多个不同类型的抽象成为可能,并通过 trait bounds 加强对这些类型所必须提供的内容的限制。
这有时被称为 bounded parametric polymorphism。

近来继承作为一种语言设计的解决方案在很多语言中失宠了,因为其时常带有共享多于所需的代码的风险。
子类不应总是共享其父类的多有特征,但是继承却始终如此。如此会使程序设计更为不灵活,并引入无意义的子类方法调用,或由于方法实际并不适用于子类而造成错误的可能性。
某些语言还只允许子类继承一个父类,进一步限制了程序设计的灵活性。

因为这些原因,Rust 选择了一个不同的途径,使用 trait 对象替代继承。让我们看一下 Rust 中的 trait 对象是如何实现多态的。

为使用不同类型的值而设计的 trait 对象

这里将创建一个图形用户接口(Graphical User Interface, GUI)工具的例子,它通过遍历列表并调用每一个项目的 draw 方法来将其绘制到屏幕上 —— 此乃一个 GUI 工具的常见技术。
我们将要创建一个叫做 gui 的库 crate,它含一个 GUI 库的结构。
这个 GUI 库包含一些可供开发者使用的类型,比如 Button 或 TextField。
在此之上,gui 的用户希望创建自定义的可以绘制于屏幕上的类型:比如,一个程序员可能会增加 Image,另一个可能会增加 SelectBox。

这个例子中并不会实现一个功能完善的 GUI 库,不过会展示其中各个部分是如何结合在一起的。
编写库的时候,我们不可能知晓并定义所有其他程序员希望创建的类型。
我们所知晓的是 gui 需要记录一系列不同类型的值,并需要能够对其中每一个值调用 draw 方法。
这里无需知道调用 draw 方法时具体会发生什么,只需提供可供这些值调用的方法即可。

在拥有继承的语言中,可以定义一个名为 Component 的类,该类上有一个 draw 方法。
其他的类比如 Button、Image 和 SelectBox 会从 Component 派生并因此继承 draw 方法。
它们各自都可以覆盖 draw 方法来定义自己的行为,但是框架会把所有这些类型当作是 Component 的实例,并在其上调用 draw。
不过 Rust 并没有继承,我们得另寻出路。

定义通用行为的 trait

为了实现 gui 所期望的行为,让我们定义一个 Draw trait,其中包含名为 draw 的方法。
接着可以定义一个存放 trait 对象(trait object) 的 vector。
trait 对象指向一个实现了我们指定 trait 的类型的实例,以及一个用于在运行时查找该类型的trait方法的表。
我们通过指定某种指针来创建 trait 对象,例如 & 引用或 Box<T> 智能指针,还有 dyn keyword,以及指定相关的 trait。
我们可以使用 trait 对象代替泛型或具体类型。
任何使用 trait 对象的位置,Rust 的类型系统会在编译时确保任何在此上下文中使用的值会实现其 trait 对象的 trait。如此便无需在编译时就知晓所有可能的类型。

之前提到过,Rust 刻意不将结构体与枚举称为 “对象”,以便与其他语言中的对象相区别。
在结构体或枚举中,结构体字段中的数据和 impl 块中的行为是分开的,不同于其他语言中将数据和行为组合进一个称为对象的概念中。
trait 对象将数据和行为两者相结合,从这种意义上说 则 其更类似其他语言中的对象。
不过 trait 对象不同于传统的对象,因为不能向 trait 对象增加数据。
trait 对象并不像其他语言中的对象那么通用:其(trait 对象)具体的作用是允许对通用行为进行抽象。

示例展示了如何定义一个带有 draw方法的 trait Draw

文件名: src/lib.rs:

Draw trait 的定义:

1
2
3
pub trait Draw {
    fn draw(&self);
}

定义一个存放了名叫 components 的 vector 的结构体 Screen
这个 vector 的类型是 Box<dyn Draw>,此为一个 trait 对象:它是 Box 中任何实现了 Draw trait 的类型的替身。

文件名: src/lib.rs
一个 Screen 结构体的定义,它带有一个字段 components,其包含实现了 Draw trait 的 trait 对象的 vector

1
2
3
pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

在 Screen 结构体上,我们将定义一个 run 方法,该方法会对其 components 上的每一个组件调用 draw 方法

1
2
3
4
5
6
7
impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这与定义使用了带有 trait bound 的泛型类型参数的结构体不同。
泛型类型参数一次只能替代一个具体类型,而 trait 对象则允许在运行时替代多种具体类型。
例如,可以定义 Screen 结构体来使用泛型和 trait bound

文件名: src/lib.rs

一种 Screen 结构体的替代实现,其 run 方法使用泛型和 trait bound:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
    where T: Draw {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这限制了 Screen 实例必须拥有一个全是 Button 类型或者全是 TextField 类型的组件列表。
如果只需要同质(相同类型)集合,则倾向于使用泛型和 trait bound,因为其定义会在编译时采用具体类型进行单态化。

另一方面,通过使用 trait 对象的方法,一个 Screen 实例可以存放一个既能包含 Box<Button>,也能包含 Box<TextField>Vec<T>
让我们看看它是如何工作的,接着会讲到其运行时性能影响。

实现 trait

现在来增加一些实现了 Draw trait 的类型。我们将提供 Button 类型。
为了想象一下这个实现看起来像什么,一个 Button 结构体可能会拥有 width、height 和 label 字段

文件名: src/lib.rs

一个实现了 Draw trait 的 Button 结构体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // 实际绘制按钮的代码
    }
}

在 Button 上的 width、height 和 label 字段会和其他组件不同,比如 TextField 可能有 width、height、label 以及 placeholder 字段。
每一个我们希望能在屏幕上绘制的类型都会使用不同的代码来实现 Draw trait 的 draw 方法来定义如何绘制特定的类型,像这里的 Button 类型。
除了实现 Draw trait 之外,比如 Button 还可能有另一个包含按钮点击如何响应的方法的 impl 块。
这类方法并不适用于像 TextField 这样的类型。

如果一些库的使用者决定实现一个包含 width、height 和 options 字段的结构体 SelectBox,并且也为其实现了 Draw trait

文件名: src/main.rs

另一个使用 gui 的 crate 中,在 SelectBox 结构体上实现 Draw trait

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

库使用者现在可以在他们的 main 函数中创建一个 Screen 实例。
至此可以通过将 SelectBox 和 Button 放入 Box<T> 转变为 trait 对象来增加组件。
接着可以调用 Screen 的 run 方法,它会调用每个组件的 draw 方法。

文件名: src/main.rs

使用 trait 对象来存储实现了相同 trait 的不同类型的值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use gui::{Screen, Button};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No")
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}