跳到主要内容

方法

Rust 的对象定义和方法定义是分离的

struct Circle{
x:f64,
y:f64,
}
impl Circle {
fn area(&self)->f64{
self.x* self.y
}
}
fn main() {
let c = Circle{x:22.0,y:10.0};
let a = c.area();
println!("a={}",a)
}

self,&self&mut self

self 表示 Rectangle 的所有权转移到该方法中,这种形式用的较少 &self 表示该方法对 Rectangle 的不可变借用 &mut self 表示可变借用,如果要修改对象属性,用这个

关联函数

这种定义在 impl中且没有 self 的函数被称之为关联函数: 因为它没有 self,不能用 f.read() 的形式调用,因此它是一个函数而不是方法,它又在 impl 中,与结构体紧密关联,因此称为关联函数,只能通过 T::fn() 来调用

struct Circle{
x:f64,
y:f64,
}
impl Circle {
fn new(x:f64,y:f64)->Circle{
Circle { x: x, y: y }
}
}
fn main() {
let c = Circle::new(1.0,2.0); // 调用关联函数
println!("x={},y={}",c.x, c.y)
}

关于一个语言被称为面向对象所需的功能,在编程社区内并未达成一致意见。Rust 被很多不同的编程范式影响,包括面向对象编程;比如迭代器和闭包提到了来自函数式编程的特性。面向对象编程语言所共享的一些特性往往是对象、封装和继承。让我们看一下这每一个概念的含义以及 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,它是面向对象编程模式的目录。它这样定义面向对象编程:

Object-oriented programs are made up of objects. An object packages both data and the procedures that operate on that data. The procedures are typically called methods or operations.

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

在这个定义下,Rust 是面向对象的:结构体和枚举包含数据而 impl 块提供了在结构体和枚举之上的方法。虽然带有方法的结构体和枚举并不被 称为 对象,但是他们提供了与对象相同的功能,参考 Gang of Four 中对象的定义。

封装隐藏了实现细节

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

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

文件名: src/lib.rs


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

示例 17-1: AveragedCollection 结构体维护了一个整型列表和集合中所有元素的平均值。

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

文件名: src/lib.rs


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;
}
}

示例 17-2: 在AveragedCollection 结构体上实现了addremoveaverage 公有方法

公有方法 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 与否可以封装其实现细节。

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

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

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

选择继承有两个主要的原因。第一个是为了重用代码:一旦为一个类型实现了特定行为,继承可以对一个不同的类型重用这个实现。相反 Rust 代码可以使用默认 trait 方法实现来进行共享,在示例 10-14 中我们见过在 Summary trait 上增加的 summarize 方法的默认实现。任何实现了 Summary trait 的类型都可以使用 summarize 方法而无须进一步实现。这类似于父类有一个方法的实现,而通过继承子类也拥有这个方法的实现。当实现 Summary trait 时也可以选择覆盖 summarize 的默认实现,这类似于子类覆盖从父类继承的方法实现。

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

多态(Polymorphism)

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

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

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

在第八章中,我们谈到了 vector 只能存储同种类型元素的局限。示例 8-10 中提供了一个定义 SpreadsheetCell 枚举来储存整型,浮点型和文本成员的替代方案。这意味着可以在每个单元中储存不同类型的数据,并仍能拥有一个代表一排单元的 vector。这在当编译代码时就知道希望可以交替使用的类型为固定集合的情况下是完全可行的。

然而有时我们希望库用户在特定情况下能够扩展有效的类型集合。为了展示如何实现这一点,这里将创建一个图形用户接口(Graphical User Interface, GUI)工具的例子,它通过遍历列表并调用每一个项目的 draw 方法来将其绘制到屏幕上 —— 此乃一个 GUI 工具的常见技术。我们将要创建一个叫做 gui 的库 crate,它含一个 GUI 库的结构。这个 GUI 库包含一些可供开发者使用的类型,比如 ButtonTextField。在此之上,gui 的用户希望创建自定义的可以绘制于屏幕上的类型:比如,一个程序员可能会增加 Image,另一个可能会增加 SelectBox

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

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

定义通用行为的 trait

为了实现 gui 所期望的行为,让我们定义一个 Draw trait,其中包含名为 draw 的方法。接着可以定义一个存放 trait 对象trait object) 的 vector。trait 对象指向一个实现了我们指定 trait 的类型的实例,以及一个用于在运行时查找该类型的trait方法的表。我们通过指定某种指针来创建 trait 对象,例如 & 引用或 Box<T> 智能指针,还有 dyn keyword, 以及指定相关的 trait(第十九章 ““动态大小类型和 [Sized](https://rust.bootcss.com/ch19-04-advanced-types.html#dynamically-sized-types-and-the-sized-trait) trait” 部分会介绍 trait 对象必须使用指针的原因)。我们可以使用 trait 对象代替泛型或具体类型。任何使用 trait 对象的位置,Rust 的类型系统会在编译时确保任何在此上下文中使用的值会实现其 trait 对象的 trait。如此便无需在编译时就知晓所有可能的类型。

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

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

文件名: src/lib.rs


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

示例 17-3:Draw trait 的定义

因为第十章已经讨论过如何定义 trait,其语法看起来应该比较眼熟。接下来就是新内容了:实例 17-4 定义了一个存放了名叫 components 的 vector 的结构体 Screen。这个 vector 的类型是 Box<dyn Draw>,此为一个 trait 对象:它是 Box 中任何实现了 Draw trait 的类型的替身。

文件名: src/lib.rs


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

示例 17-4: 一个 Screen 结构体的定义,它带有一个字段 components,其包含实现了 Draw trait 的 trait 对象的 vector

Screen 结构体上,我们将定义一个 run 方法,该方法会对其 components 上的每一个组件调用 draw 方法,如示例 17-5 所示:

文件名: src/lib.rs


impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}

示例 17-5:在 Screen 上实现一个 run 方法,该方法在每个 component 上调用 draw 方法

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

文件名: src/lib.rs


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();
}
}
}

示例 17-6: 一种 Screen 结构体的替代实现,其 run 方法使用泛型和 trait bound(绑定)

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

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

实现 trait

现在来增加一些实现了 Draw trait 的类型。我们将提供 Button 类型。再一次重申,真正实现 GUI 库超出了本书的范畴,所以 draw 方法体中不会有任何有意义的实现。为了想象一下这个实现看起来像什么,一个 Button 结构体可能会拥有 widthheightlabel 字段,如示例 17-7 所示:

文件名: src/lib.rs


pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}

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

示例 17-7: 一个实现了 Draw trait 的 Button 结构体

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

如果一些库的使用者决定实现一个包含 widthheightoptions 字段的结构体 SelectBox,并且也为其实现了 Draw trait,如示例 17-8 所示:

文件名: src/main.rs

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
}
}

示例 17-8: 另一个使用 gui 的 crate 中,在 SelectBox 结构体上实现 Draw trait

库使用者现在可以在他们的 main 函数中创建一个 Screen 实例。至此可以通过将 SelectBoxButton 放入 Box<T> 转变为 trait 对象来增加组件。接着可以调用 Screenrun 方法,它会调用每个组件的 draw 方法。示例 17-9 展示了这个实现:

文件名: src/main.rs

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();
}

示例 17-9: 使用 trait 对象来存储实现了相同 trait 的不同类型的值

当编写库的时候,我们不知道何人会在何时增加 SelectBox 类型,不过 Screen 的实现能够操作并绘制这个新类型,因为 SelectBox 实现了 Draw trait,这意味着它实现了 draw 方法。

这个概念 —— 只关心值所反映的信息而不是其具体类型 —— 类似于动态类型语言中称为 鸭子类型duck typing)的概念:如果它走起来像一只鸭子,叫起来像一只鸭子,那么它就是一只鸭子!在示例 17-5 中 Screen 上的 run 实现中,run 并不需要知道各个组件的具体类型是什么。它并不检查组件是 Button 或者 SelectBox 的实例。通过指定 Box<dyn Draw> 作为 components vector 中值的类型,我们就定义了 Screen 为需要可以在其上调用 draw 方法的值。

使用 trait 对象和 Rust 类型系统来进行类似鸭子类型操作的优势是无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现 trait 对象所需的 trait 则 Rust 不会编译这些代码。

例如,示例 17-10 展示了当创建一个使用 String 做为其组件的 Screen 时发生的情况:

文件名: src/main.rs

use gui::Screen;

fn main() {
let screen = Screen {
components: vec![
Box::new(String::from("Hi")),
],
};

screen.run();
}

示例 17-10: 尝试使用一种没有实现 trait 对象的 trait 的类型

我们会遇到这个错误,因为 String 没有实现 rust_gui::Draw trait:

error[E0277]: the trait bound `std::string::String: gui::Draw` is not satisfied
--> src/main.rs:7:13
|
7 | Box::new(String::from("Hi")),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait gui::Draw is not
implemented for `std::string::String`
|
= note: required for the cast to the object type `gui::Draw`

这告诉了我们,要么是我们传递了并不希望传递给 Screen 的类型并应该提供其他类型,要么应该在 String 上实现 Draw 以便 Screen 可以调用其上的 draw

trait 对象执行动态分发

回忆一下第十章 “泛型代码的性能” 部分讨论过的,当对泛型使用 trait bound 时编译器所进行单态化处理:编译器为每一个被泛型类型参数代替的具体类型生成了非泛型的函数和方法实现。单态化所产生的代码进行 静态分发static dispatch)。静态分发发生于编译器在编译时就知晓调用了什么方法的时候。这与 动态分发dynamic dispatch)相对,这时编译器在编译时无法知晓调用了什么方法。在动态分发的情况下,编译器会生成在运行时确定调用了什么方法的代码。

当使用 trait 对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于 trait 对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust 在运行时使用 trait 对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。尽管在编写示例 17-5 和可以支持示例 17-9 中的代码的过程中确实获得了额外的灵活性,但仍然需要权衡取舍。

Trait 对象要求对象安全

只有 对象安全object safe)的 trait 才可以组成 trait 对象。围绕所有使得 trait 对象安全的属性存在一些复杂的规则,不过在实践中,只涉及到两条规则。如果一个 trait 中所有的方法有如下属性时,则该 trait 是对象安全的:

  • 返回值类型不为 Self

  • 方法没有任何泛型类型参数

Self 关键字是我们要实现 trait 或方法的类型的别名。对象安全对于 trait 对象是必须的,因为一旦有了 trait 对象,就不再知晓实现该 trait 的具体类型是什么了。如果 trait 方法返回具体的 Self 类型,但是 trait 对象忘记了其真正的类型,那么方法不可能使用已经忘却的原始具体类型。同理对于泛型类型参数来说,当使用 trait 时其会放入具体的类型参数:此具体类型变成了实现该 trait 的类型的一部分。当使用 trait 对象时其具体类型被抹去了,故无从得知放入泛型参数类型的类型是什么。

一个 trait 的方法不是对象安全的例子是标准库中的 Clone trait。Clone trait 的 clone 方法的参数签名看起来像这样:


pub trait Clone {
fn clone(&self) -> Self;
}

String 实现了 Clone trait,当在 String 实例上调用 clone 方法时会得到一个 String 实例。类似的,当调用 Vec<T> 实例的 clone 方法会得到一个 Vec<T> 实例。clone 的签名需要知道什么类型会代替 Self,因为这是它的返回值。

如果尝试做一些违反有关 trait 对象的对象安全规则的事情,编译器会提示你。例如,如果尝试实现示例 17-4 中的 Screen 结构体来存放实现了 Clone trait 而不是 Draw trait 的类型,像这样:

pub struct Screen {
pub components: Vec<Box<dyn Clone>>,
}

将会得到如下错误:

error[E0038]: the trait `std::clone::Clone` cannot be made into an object
--> src/lib.rs:2:5
|
2 | pub components: Vec<Box<dyn Clone>>,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::clone::Clone`
cannot be made into an object
|
= note: the trait cannot require that `Self : Sized`

这意味着不能以这种方式使用此 trait 作为 trait 对象。如果你对对象安全的更多细节感兴趣,请查看 Rust RFC 255

Loading Comments...