The Rust programming language 读书笔记——面向对象编程

面向对象编程(OOP)是一种程序建模的方法。通常认为面向对象的语言需要包含命名对象、封装、继承等特性。

对象包含数据和行为

面向对象的程序由对象构成。对象包装了数据和操作这些数据的流程(称作方法)。
基于这个定义,Rust 是面向对象的。比如结构体和枚举都可以包含数据,而 impl 块则提供了可用于结构体和枚举的方法。

封装实现细节

封装使得调用对象的外部代码无法直接访问对象内部的实现细节,而唯一可以与对象进行交互的方法便是通过它公开的接口。
使用对象的代码不应当深入对象的内部去改变数据或行为,封装使得开发者在修改或重构对象的内部实现时无需改变调用这个对象的外部代码

在 Rust 中,我们可以使用 pub 关键字来决定代码中哪些模块、类型、函数和方法是公开的,而默认情况下所有内容都是私有的。

如下面计算移动平均值的代码:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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;
}
}

fn main() {
let mut collection = AveragedCollection {
list: vec![],
average: 0.0,
};

collection.add(1);
collection.add(2);
collection.add(3);

println!("The average of the collection is: {}", collection.average());
}

先是定义了一个名为 AveragedCollection 的结构体,其 list 字段包含了一个存储 i32 元素的动态数组;为了避免每次取元素平均值的时候重复计算,又添加了一个用于存储平均值的 average 字段。
结构体本身被标记为 pub 使得其他代码可以使用它,但其内部字段仍然保持私有。
公共方法 addremoveaverage 是仅有的几个可以访问或修改 AveragedCollection 实例中数据的方法。当用户调用 add 方法向 list 中添加元素,或者调用 removelist 中删除元素时,方法内部的实现都会再调用私有方法 update_average 来更新 average 字段。

由于 listaverage 字段是私有的,外部代码无法直接读取 list 字段来增加或删除其中的元素。
一旦缺少了这样的封装,average 字段便无法在用户私自更新 list 字段时同步保持更新。

因为结构体 AveragedCollection 封装了内部的实现细节,我们能够在未来轻松地改变数据结构等内部实现。比如可以在 list 字段上使用 HashSet<i32> 替代 Vec<i32>
只要 addremoveaverage 这几个公共方法的签名保持不变,正在使用 AveragedCollection 的外部代码就无需进行任何修改。
假如将 list 字段声明为 pub,就必然会失去上面这一优势。HashSet<i32>Vec<i32> 在增加或删除元素时使用的具体方法是不同的,因此若直接修改 list,外部代码将不得不随之发生变化。

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

继承机制使得对象可以沿用另一个对象的数据与行为,而无需重复定义代码。
Rust 中无法定义一个继承父结构体字段和方法的子结构体。

选择继承主要有两个原因。其一是代码复用,作为替代方案,可以使用 Rust 中的默认 trait 方法来进行代码共享。
它与继承十分相似,父类中实现的方法可以被继承它的子类所拥有;子类也可以选择覆盖父类中的方法。

另一个使用继承的原因与类型系统有关,希望子类型能够被应用到一个需要父类型的地方。即多态如果一些对象具有某些共同的特征,则这些对象就可以在运行时相互替换使用
可以在 Rust 中使用泛型来构建不同类型的抽象,并使用 trait 约束来决定类型必须提供的具体特性。这一技术被称为限定参数化多态

许多较为新潮的语言已经不太喜欢将继承作为内置的程序设计方案,因为使用继承意味着你会无意间共享出比所需内容更多的代码
子类并不应该总是共享父类的所有特性,但使用继承机制却会始终产生这样的结果,进而使程序设计缺乏灵活性。而某些语言强制要求子类只能继承自单个父类,进一步限制了程序设计的灵活性。

使用 trait 对象来存储不同类型的值

动态数组有一个限制,即只能存储同一类型的元素。有些时候的变通方案可以使用枚举。
比如定义一个 SpreadsheetCell 枚举同时包含了可以持有整数、浮点数和文本的变体。这样我们就可以在每个表格中存储不同的数据类型,且依然能够用一个动态数组来表示一整行单元格。
但是总有某些时候,我们希望用户能够在特定的场景下为类型的集合进行扩展。

比如需要创建一个含有 GUI 库架构的 gui 包,并在包中提供一些可供用户使用的具体类型,如 ButtonTextField 等,这些类型都实现了 draw 方法用于支持将其绘制到屏幕中。
此外,gui 的用户也应当能够创建支持绘制的自定义类型,如某些开发者可能会添加 Image,另一些可能会添加 SelectBox 等。
在那些支持继承的语言中,我们可以定义出一个拥有 draw 方法的 Component 类。其他如 ButtonImageSelectBox 等则都需要继承 Component 类来获得 draw 方法。
当然也可以选择覆盖 draw 方法来实现自定义行为,但框架会在处理过程中将它们全部视作 Component 类型的实例,并以此调用 draw 方法。

为共有行为定义一个 trait

Rust 没有继承功能。
为了在 gui 中实现预期的功能,需要定义一个拥有 draw 方法的 Draw trait。trait 对象可以被用在泛型或具体类型所处的位置,无论我们在哪里使用 trait 对象,Rust 类型系统都会在编译时确保出现在相应位置上的值实现了 trait 对象中的指定方法。

Rust 有意避免将结构体和枚举称为对象,以便于与其他语言中的对象区别开。对于结构体和枚举而言,其字段中的数据与 impl 块中的行为是分开的;而在其他语言中,数据和行为往往被组合在名为对象的概念中
trait 对象则有些类似于其他语言中的对象,它也在某种程度上组合了数据和行为。但 trait 对象被专门用于抽象某些共有行为,没有其他语言中的对象那么通用。

创建一个名为 gui 的 Rust 项目:
cargo new gui

lib.rs 中定义一个拥有 draw 方法的 Draw trait:

1
2
3
4
// src/lib.rs
pub trait Draw {
fn draw(&self);
}

定义一个持有 components 动态数组的 Screen 结构体,代码中的 Box<dyn Draw> 代表所有被放置在 Box 中且实现了 Draw trait 的具体类型。

1
2
3
4
// src/lib.rs
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}

Screen 结构体定义了一个名为 run 的方法,会逐一调用 components 中每个元素的 draw 方法:

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

实现 trait

在代码中添加一些实现了 Draw trait 的具体类型。需要注意的是,draw 方法不会包含任何有意义的内容,仅作为演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/lib.rs
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}

impl Draw for Button {
fn draw(&self) {
println!("Drawing a button, the button's label is {}", self.label);
// 实际绘制一个按钮的代码
}
}

Button 中持有的 widthheightlabel 字段也许会不同于其他组件中的字段,比如 TextField 类型就可能在这些字段外额外持有一个 placeholder 字段。
每一个希望绘制在屏幕上的类型都应当实现 Draw trait,并在 draw 方法中使用不同的代码来自定义具体的绘制行为。
除了实现 Draw trait,Button 类型也许会在另外的 impl 块中实现响应用户点击按钮时的行为,这些方法并不适用于 TextField 等其他类型。

用户也可以在 main.rs 中为SelectBox 这种自定义类型实现 Draw trait:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/main.rs
use gui::Draw;

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

impl Draw for SelectBox {
fn draw(&self) {
println!("Drawing a selectbox, the options are {:?}", self.options);
// 实际绘制一个选择框的代码
}
}

此时就可以在编写 main 函数的时候创建 Screen 实例了。使用 Box<T> 生成 SelectBoxButton 的 trait 对象,再将它们添加到 Screen 实例中。便可以运行 Screen 实例的 run 方法来依次调用所有组件的 draw 实现:

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
// src/main.rs
use gui::{Screen, Button};

fn main() {
let scrren = 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"),
}),
],
};
scrren.run()
}
// => Drawing a selectbox, the options are ["Yes", "Maybe", "No"]
// => Drawing a button, the button's label is OK

我们在编写库的时候无法得知用户是否会添加自定义的 SelectBox 类型,但我们的 Screen 实现依然能够接收新的类型并完成绘制工作。因为 SelectBox 实现了 Draw trait 及其 draw 方法。

run 方法只关心值对行为的响应,而不在意值的具体类型。这一概念与动态类型中的 duck typing 十分相似。
通过在定义动态数组 components 时指定 Box<dyn Draw> 元素类型,Screen 实例只会接收那些能够调用 draw 方法的值,而不会去检查该值究竟是 Button 实例还是 SelectBox 实例。

使用 trait 对象与类型系统实现 duck typing 的优势在于,不需要在运行时检查某个值是否实现了指定的方法,或者担心出现调用未定义方法等运行时错误。Rust 会在编译时发现这类错误。

参考资料

The Rust Programming Language