之前的几个帖子讨论了Rust设计的两大支柱特性:
- 无垃圾回收的安全内存管理
- 无数据竞争(Data Race)风险的并发
现在我们来讨论第三个重要特性:零开销的抽象
C++之所以适合系统编程,就是因为它提供了神奇的“零开销抽象方式”:
C++的实现遵循“零开销原则”:如果你不使用某个抽象,就不用为它付出开销[Stroustrup,1994]。而如果你确实需要使用该抽象,可以保证这是开销最小的使用方式。
— Stroustrup
以前版本的Rust由于内置垃圾回收,所以并不能实现该优点。但是现在,零开销原则已经成为了Rust的核心原则之一。
实现这一原则的基石是Rust的特型(trait)机制:
特型是Rust唯一的接口抽象方式。 一方面,不同种类型可以实现同一特型,也可以为已有的类型添加新的特型。另一方面,当你想要对某未知类型进行抽象的时候,特型可以帮助你确定该类型可以进行的操作。
特型可以静态生成。 考虑C++的模板,你可以为不同种类型的同一抽象静态生成不同的代码,而静态生成是使用抽象的最佳方式,因为只存在实例化后的代码,而抽象本身已经被完全抹去,因而也不会带来任何运行开销。
特型可以动态调用 有时你确实需要在运行时调用某种间接抽象,这时就不能静态实例化该抽象了,因此trait同样提供了动态调用(Dynamic Dispatch)的机制。
特型这种简单抽象能够解决大量的额外问题 特型可以作为类型的“标签”使用,在这篇文章中有一个
Sender
作为例子。特型可以被用于定义“扩展方法”(对已有类型添加其他方法)来使用,因此传统的方法重载不再必要。特型机制也使得运算符重载更加简单。
总而言之,特型是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的类型通过impl 关键字后面的类型确定)
- 不需要考虑该方法的定义是否在作用域内:只要调用该方法的变量在域内,方法就在域内。而自由函数则无法做到这点。
一个“内含方法”的第一个参数名永远是“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
要求同时实现了Hash
和Eq
两个特型:
<pre>struct HashMap<Key: Hash + Eq, Value> { ... }</pre>
这样的泛型静态编译模型有几个好处:
每个不同的<Key,Value>类型对将生成不同的具体的HashMap的类型,因此HashMap类型实现的代码可以把Key值和Value值按序排列,这样有助于节省空间,提高缓存本地性。
对不同类型的<Key,Value>类型对,
HashMap
的每个方法会生成特殊化代码(specialized code)。这意味着不会有额外的对hash和eq方法的调用,优化器可以针对具体类型进行优化。也就是说对编译优化器而言,该抽象实际并不存在,因为在那个阶段泛型已经被行内展开了。
总之,和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的其他地方扮演重要角色,例如:
闭包。 类似
ClickFallBack
特型,Rust中的闭包只是一些特别的特型,这里 是Huon Wilson描述的闭包具体实现。-
条件API 泛型让我们可以实现某些带条件的特型:
<pre>
struct Pair<A, B> { first: A, second: B }
impl<A: Hash, B: Hash> Hash for Pair<A, B> {
fn hash(&self) -> u64 {
self.first.hash() ^ self.second.hash()
}
}
</pre>
这里对某个特定的Pair
类型实现了Hash
特型,这样针对不同类型的Pair
可以有不同的API。这里Hash的例子很常见,因此Rust提供了内置的默认实现。
<pre>[derive(Hash)]
struct Pair<A, B> { .. }
</pre> 扩展方法 类似C#中的扩展方法,我们可以对已有类型添加新的特型的方式来提供扩展。
标记 Rust提供了一些有用的“标记(Marker)”来区分类型:
Send
,Sync
,Copy
,Sized
。这些标记仅仅是一些空的Traits,可以被用于泛型或者Trait对象之上。标记可以在库函数中进行定义,并且通过#[derive]
风格的记号来提供默认实现:如果一个类型的所有成员都是Send
,那么这个类型自己也是Send
。这些标记很有用,它们帮助确保了Rust的线程安全。重载 Rust并不支持传统重载方式(相同函数名根据函数签名的不同有不同实现)。不过Traits提供了重载所能提供的大部分好处:如果某个方法是通过特型来定义的,那么任意实现了该特型的类型都可以调用这个方法。相比传统重载有两个优势:首先重载的实现显得不那么
临时化(ad hoc)
。你一旦理解了某个特型你就理解了任意使用该特型对某方法进行重载的API。其次是可扩展性:你可以通过提供新的特型实现的方式来在下游重载某个已有的方法。操作符 Rust允许你重载类似
+
的操作符。每个操作符都对应了标准库中的某个特型,每个实现了该Trait的类型也可以自动支持该操作符。
要点:尽管Traits很简单,但是它为大量的应用场景和模式提供了一个同一的抽象概念,使得我们可以在保持语言特性简单的基础上实现诸多功能。
未来目标
Rust语言的目标是对抽象工具进行不断的进化。在1.0以后的目标中我们有如下的计划:
- 静态生成输出。
- 特殊化。
-
高阶类型。 目前的特型只能被定义在类型上,而不能定义在类型生成器上,也就是说我们只能在
Vec<u8>
上面添加新特型,而不能在Vec
本身上定义新Trait。这一特性的添加将是对Rust抽象能力的极大提升。 - 高效重用。
注:
有趣的地方主要是Rust通过静态编译的方式实现特型的静态分发,通过特型对象(包括一个指针和一个vtable)的方式实现Trait的动态调用。