bufRust语言

[译文]Rust Traits:零开销的抽象方式

2015-05-12  本文已影响2220人  Nuk

之前的几个帖子讨论了Rust设计的两大支柱特性:

  1. 无垃圾回收的安全内存管理
  2. 无数据竞争(Data Race)风险的并发

现在我们来讨论第三个重要特性:零开销的抽象

C++之所以适合系统编程,就是因为它提供了神奇的“零开销抽象方式”:

C++的实现遵循“零开销原则”:如果你不使用某个抽象,就不用为它付出开销[Stroustrup,1994]。而如果你确实需要使用该抽象,可以保证这是开销最小的使用方式。
— Stroustrup

以前版本的Rust由于内置垃圾回收,所以并不能实现该优点。但是现在,零开销原则已经成为了Rust的核心原则之一。

实现这一原则的基石是Rust的特型(trait)机制:

总而言之,特型是Rust能够经济高效地在高阶语言的语法下实现对底层代码执行和数据表达进行精密控制的秘诀。

这篇文章我们会逐一描述以上优点,在不引进大量细节的前提下描述Rust是如何实现的。

背景知识:Rust中的成员方法(member function)

进入细节之前让我们先来看看Rust中“函数”和“成员方法”之间的差别

Rust同时提供了成员方法和自由函数,两者其实很相似:

// 定义Point类型
struct Point {
    x: f64,
    y: f64,
}

// 一个自由函数,将一个Point类型变量转换成String
fn point_to_string(point: &Point) -> String { ... }

// 一个接口,定义了在一个Point类型变量上可以直接进行的操作
impl Point {
    // Point类型的成员方法,自动借用了Point的值
    fn to_string(&self) -> String { ... }
}

类似以上的to_string方法被称作“内含方法”,因为:

一个“内含方法”的第一个参数名永远是“self”,具体是“self”,“&mut self”还是“&self”则取决于该方法所需的“变量所有权级别”。调用内含方法的方式是使用“.”运算符,可以实现隐式借用,例子如下:

<pre>
let p = Point { x: 1.2, y: -3.7 };
// 调用一个自由函数需要用&运算符显式借用
let s1 = point_to_string(&p);
// 调用一个方法, 隐式借用所以不需要&运算符
let s2 = p.to_string();
</pre>

方法对变量的隐式借用这一点非常重要,它使得我们可以写出如下的“链式API调用”:

let child = Command::new("/bin/cat")
    .arg("rusty-ideas.txt")
    .current_dir("/Users/aturon")
    .stdout(Stdio::piped())
    .spawn();

用特型表达接口

接口(interface)指定了一段代码使用另外一段代码的方式,使得替换其中一段并不需要修改另外一段代码。对于特型,这一特性围绕着成员方法来展开。

例如如下的哈希trait:

<pre>
trait Hash {
fn hash(&self) -> u64;
}
</pre>

为了对某一类型实现该特型,你需要提供hash函数的具体实现,例如:

<pre>
impl Hash for bool {
fn hash(&self) -> u64 {
if *self { 0 } else { 1 }
}
}
impl Hash for i64 {
fn hash(&self) -> u64 {
*self as u64
}
}
</pre>

和C#,Java,Scala不同的是,Rust的Traits能够添加到已经存在的类型之上,可以看到上面的Hash就定义在了bool和i64两个已经存在的类型上面。这意味着新的抽象可以定义在已有类型上面,包括任意的库函数。

和上面提到的类型内含方法不同,特型中的方法只在该特型的定义域内有效。在Hash这个特型的定义域内,你可以写类似true.hash()这样的代码,为已有类型添加新的特型,可以扩展该类型的使用方式。

这就是定义和使用特型的方式,特型其实很简单,就是对多个类型上某些共同操作的一个抽象。

静态生成

另一方面是如何调用一个特型?最常用的一种方式是通过泛型

<pre>
fn print_hash<T: Hash>(t: &T) {
println!("The hash is {}", t.hash())
}
</pre>

print_hash函数是一个定义在未知类型T上的泛型,它要求T必须实现Hash这个特型,这意味着我们可以如此调用该函数:
<pre>
print_hash(&true); // 实例化T = bool
print_hash(&12_i64); // 实例化T = i64
</pre>

泛型是通过静态生成的方法实例化的。这与C++的模板一致,我们针对这两次调用生成了两份print_hash的代码,也就是说内部对t.hash()的调用是零开销的:它被编译到了一个对相关实现函数的直接,静态的调用:

<pre>
// 编译后的代码:
__print_hash_bool(&true); // 直接调用bool类型的版本
__print_hash_i64(&12_i64); // 直接调用i64类型的版本
</pre>

这种编译模型对print_hash这样的简单函数用处不大,不过对实际情况中的哈希处理非常有用,例如我们有一个等价比较的trait:

<pre>
trait Eq {
fn eq(&self, other: &Self) -> bool;
// 这里Self类型就指代实现该特型的类型,例如impl Eq for bool的时候Self的类型就是bool
}
</pre>

我们这时就可以在类型T上面定义一个HashMap,这里的T要求同时实现了HashEq两个特型:

<pre>struct HashMap<Key: Hash + Eq, Value> { ... }</pre>

这样的泛型静态编译模型有几个好处:

总之,和C++模板一样,这样实现的泛型可以帮助你在写高阶抽象的同时保证能够编译到具体类型的代码,“这已经是处理这种类型代码的最佳方式”。

然而与C++模板不同,trait的实现函数需要提前进行完全的静态类型检查。也就是说你单独编译HashMap的时候,它会针对Hash和Eq两种特型来做类型正确性检查,而不是对泛型展开之后才进行检查。这意味着对库函数的作者有更早,更清晰的编译错误提示,以及更低的类型检查开销(编译时间更短)。

动态调用

我们之前展示的特型的静态编译模型,但是有时我们使用抽象不仅仅是为了模块化或者重用——有时抽象在运行时扮演了必要的角色,不能在编译时刻被静态处理掉

例如,GUI框架中经常包含了响应事件的回调函数,例如鼠标点击:

<pre>trait ClickCallback {
fn on_click(&self, x: i64, y: i64);
}</pre>

GUI元素需要允许不同的回调函数注册到同一事件。使用泛型的话你可以这么写:

<pre>
struct Button<T: ClickCallback> {
listeners: Vec<T>,
...
}
</pre>

但是这样写有个显而易见的问题,那就是每个Button的类型只能和一个ClickCallback的实现相对应,这并不是我们想要的。我们想要的是单个的Button类型,它和一个包含很多实现了ClickCallback 特型的监听器相绑定。

我们现在面临的问题是,如果一个Button类型中有一个向量数组包含了很多ClickCallback的实现实例,这些实例各自的大小又各不相同,那么我们该怎么在内存中摆放这些实例呢?解决方案是加入指针,我们在向量数组中存放指向回调函数的指针:
<pre>
struct Button {
listeners: Vec<Box<ClickCallback>>,
...
}
</pre>

这里我们把ClickCallback当作一个类型来使用,在Rust中,特型是一个类型,但是它们的大小是不定的(unsized),因此这意味着它们通常需要指针进行引用。可以用Box指针(指向堆内存)和&指针(可以指向任意内存)。

在Rust中,类似&ClickCallback或者Box<ClickCallback>的变量被称作特型对象,它实际包含了一个指针,指向了一个实现了ClickCallback Trait的类型的实例。它还包含了一个vtable,这个vtable里面包含了特型中定义的每个方法的函数指针,这些信息就足够在运行时正确的调用Trait的实现方法,也能保证对每个T都有统一的表示方式。因此Button可以只被编译一次,这个抽象在运行时的表示方式就是特型对象

静态和动态的特型分发方式是互补的工具,可以用于不同的情况,Rust的特型为不同风格和需求的接口提供了统一简洁的表示方式,并且它的开销是最小化,可预测的。 特型对象的实现满足Stroustrup的“需要多少才花销多少”原则:当你需要运行时特型的时候才分配vtable,而同样的特型在静态分发的时候只编译需要的部分。

特型的其他用途

我们已经见过了特型的基本机制和使用方法,它其实也在Rust的其他地方扮演重要角色,例如:

要点:尽管Traits很简单,但是它为大量的应用场景和模式提供了一个同一的抽象概念,使得我们可以在保持语言特性简单的基础上实现诸多功能。

未来目标

Rust语言的目标是对抽象工具进行不断的进化。在1.0以后的目标中我们有如下的计划:

  1. 静态生成输出。
  2. 特殊化。
  3. 高阶类型。 目前的特型只能被定义在类型上,而不能定义在类型生成器上,也就是说我们只能在Vec<u8>上面添加新特型,而不能在Vec本身上定义新Trait。这一特性的添加将是对Rust抽象能力的极大提升。
  4. 高效重用。

注:
有趣的地方主要是Rust通过静态编译的方式实现特型的静态分发,通过特型对象(包括一个指针和一个vtable)的方式实现Trait的动态调用。

上一篇 下一篇

猜你喜欢

热点阅读