之前的文章已经介绍了 Rust 设计的两个支柱
- 没有垃圾回收的内存安全
- 没有数据竞争的并发
这篇文章开始探讨第三个支柱
- 无开销的抽象
C++ 的一个信条,也是使其适合系统编程的品质之一,是其零成本抽象的原则
C++ 的实现遵循零开销原则:你不需要使用的,你就不必为此付出代价 [Stroustrup, 1994]。并且:你使用的,你不可能手写得更好。
-- Stroustrup
这个信条并不总是适用于 Rust,例如,Rust 曾经有强制性的垃圾回收。但随着时间的推移,Rust 的目标越来越低级,零成本抽象现在是一个核心原则。
Rust 中抽象的基石是 trait
-
Trait 是 Rust 唯一的接口概念。一个 trait 可以被多种类型实现,事实上,新的 trait 可以为现有类型提供实现。另一方面,当你想抽象一个未知类型时,trait 是你指定关于该类型需要知道的少量具体事物的方式。
-
Trait 可以静态分发。像 C++ 模板一样,你可以让编译器为抽象的每种实例化方式生成一个单独的副本。这又回到了 C++ 的信条 “你使用的,你不可能手写得更好” —— 抽象最终被完全擦除。
-
Trait 可以动态分发。有时你确实需要间接,因此在运行时“擦除”抽象是没有意义的。当你想在运行时分发时,也可以使用相同的接口概念——trait。
-
Trait 除了简单的抽象之外,还解决了一系列其他问题。它们被用作类型的“标记”,就像之前的文章中描述的
Send
标记。它们可以用来定义“扩展方法”——即向外部定义的类型添加方法。它们在很大程度上避免了对传统方法重载的需求。它们还为运算符重载提供了一个简单的方案。
总而言之,trait 系统是 Rust 具有高级语言的人体工程学和表达能力,同时保留对代码执行和数据表示的低级控制的秘诀。
这篇文章将从高层次上介绍以上各点,让你了解该设计如何实现这些目标,而不会过于陷入细节。
背景:Rust 中的方法
在深入研究 trait 之前,我们需要了解该语言的一个小的但重要的细节:方法和函数之间的区别。
Rust 同时提供方法和独立的函数,它们非常密切相关
struct Point {
x: f64,
y: f64,
}
// a free-standing function that converts a (borrowed) point to a string
fn point_to_string(point: &Point) -> String { ... }
// an "inherent impl" block defines the methods available directly on a type
impl Point {
// this method is available on any Point, and automatically borrows the
// Point value
fn to_string(&self) -> String { ... }
}
像上面的 to_string
这样的方法被称为“固有”方法,因为它们
- 与单个具体的“self”类型相关联(通过
impl
块头指定)。 - 在任何该类型的值上都是自动可用的——也就是说,与函数不同,固有方法始终“在作用域内”。
方法的第一个参数始终是显式的“self”,它根据所需的 所有权级别可以是 self
、&mut self
或 &self
。方法使用面向对象编程中熟悉的 .
符号调用,并且 self 参数根据方法中使用的 self
形式隐式借用
let p = Point { x: 1.2, y: -3.7 };
let s1 = point_to_string(&p); // calling a free function, explicit borrow
let s2 = p.to_string(); // calling a method, implicit borrow as &p
方法及其自动借用是 Rust 人体工程学的一个重要方面,支持像生成进程的 API 这样的 “流畅” API
let child = Command::new("/bin/cat")
.arg("rusty-ideas.txt")
.current_dir("/Users/aturon")
.stdout(Stdio::piped())
.spawn();
Trait 是接口
接口指定了一段代码对另一段代码的期望,允许彼此独立地切换。对于 trait,这种规范主要围绕方法展开。
例如,采用以下简单的哈希 trait
trait Hash {
fn hash(&self) -> u64;
}
为了为给定的类型实现此 trait,你必须提供具有匹配签名的 hash
方法
impl Hash for bool {
fn hash(&self) -> u64 {
if *self { 0 } else { 1 }
}
}
impl Hash for i64 {
fn hash(&self) -> u64 {
*self as u64
}
}
与 Java、C# 或 Scala 等语言的接口不同,可以为现有类型实现新的 trait(如上面的 Hash
)。这意味着可以在事后创建抽象,并将其应用于现有库。
与固有方法不同,trait 方法仅在其 trait 存在时才在作用域内。但是,假设 Hash
在作用域内,你可以编写 true.hash()
,因此实现一个 trait 会扩展类型上可用的一组方法。
而且...就是这样!定义和实现 trait 实际上只不过是抽象出由多个类型满足的通用接口。
静态分发
另一方面,使用 trait 会变得更有趣。最常见的方法是通过泛型
fn print_hash<T: Hash>(t: &T) {
println!("The hash is {}", t.hash())
}
print_hash
函数是通用的,因为它使用了未知类型 T
,但要求 T
实现 Hash
trait。这意味着我们可以将其与 bool
和 i64
值一起使用
print_hash(&true); // instantiates T = bool
print_hash(&12_i64); // instantiates T = i64
泛型会被编译掉,从而产生静态分发。也就是说,与 C++ 模板一样,编译器将生成 print_hash
方法的两个副本来处理上面的代码,每个具体参数类型一个。反过来,这意味着对 t.hash()
的内部调用 —— 实际使用抽象的地方 —— 是零成本的:它将被编译为对相关实现的直接静态调用
// The compiled code:
__print_hash_bool(&true); // invoke specialized bool version directly
__print_hash_i64(&12_i64); // invoke specialized i64 version directly
这种编译模型对于像 print_hash
这样的函数不是很有用,但对于哈希的更实际用途非常有用。假设我们还引入了用于相等比较的 trait
trait Eq {
fn eq(&self, other: &Self) -> bool;
}
(此处对 Self
的引用将解析为我们实现 trait 的任何类型;在 impl Eq for bool
中,它将引用 bool
。)
然后我们可以定义一个哈希映射,该映射对于实现 Hash
和 Eq
的类型 T
是通用的
struct HashMap<Key: Hash + Eq, Value> { ... }
泛型的静态编译模型将产生几个好处
-
每次使用带有具体
Key
和Value
类型的HashMap
都会产生一个不同的具体HashMap
类型,这意味着HashMap
可以将其 bucket 中的键和值内联布局(无需间接)。这节省了空间和间接,并提高了缓存局部性。 -
HashMap
上的每个方法也将生成专门的代码。这意味着分发对hash
和eq
的调用没有额外成本,如上所述。这也意味着优化器可以使用完全具体的代码 —— 也就是说,从优化器的角度来看,没有抽象。特别是,静态分发允许在泛型的使用中进行内联。
总而言之,就像在 C++ 模板中一样,泛型的这些方面意味着你可以编写非常高级的抽象,这些抽象保证可以编译为完全具体的代码,而 “你不可能手写得更好”。
但是,与 C++ 模板不同,trait 的客户端会提前进行完全的类型检查。也就是说,当你在隔离状态下编译 HashMap
时,其代码会根据抽象的 Hash
和 Eq
trait 一次检查类型正确性,而不是在应用于具体类型时重复检查。这意味着对于库作者来说更早、更清晰的编译错误,以及对于客户端来说更少的类型检查开销(即更快的编译)。
动态分发
我们已经看到了 trait 的一种编译模型,其中所有抽象都以静态方式编译掉。但有时抽象不仅仅是为了重用或模块化 —— 有时抽象在运行时起着至关重要的作用,无法编译掉。
例如,GUI 框架通常涉及用于响应事件的回调,例如鼠标单击
trait ClickCallback {
fn on_click(&self, x: i64, y: i64);
}
GUI 元素还通常允许为单个事件注册多个回调。使用泛型,你可能会想象编写
struct Button<T: ClickCallback> {
listeners: Vec<T>,
...
}
但问题立即显现:这意味着每个按钮都专门针对 ClickCallback
的一个实现者,并且按钮的类型反映了该类型。这不是我们想要的!相反,我们想要一个带有异构侦听器的集合的单个 Button
类型,每个侦听器都可能是一个不同的具体类型,但每个都实现了 ClickCallback
。
这里的一个直接困难是,如果我们谈论的是异构类型组,那么每个类型的大小都将不同 —— 那么我们如何布局内部向量呢?答案是通常的答案:间接。我们将在向量中存储指向回调的指针
struct Button {
listeners: Vec<Box<ClickCallback>>,
...
}
在这里,我们好像将 ClickCallback
trait 用作类型。实际上,在 Rust 中,trait 是类型,但它们是“未指定大小的”,这大致意味着它们只能出现在像 Box
(指向堆)或 &
(可以指向任何地方)这样的指针后面。
在 Rust 中,像 &ClickCallback
或 Box<ClickCallback>
这样的类型被称为 “trait 对象”,并且包含指向实现 ClickCallback
的类型 T
的实例的指针,以及 vtable:指向 T
的 trait 中每个方法实现的指针(此处,只是 on_click
)。这些信息足以在运行时正确分发对方法的调用,并确保所有 T
的统一表示。因此,Button
只编译一次,并且抽象在运行时继续存在。
静态分发和动态分发是互补的工具,每种都适合不同的场景。Rust 的 trait 提供了一个简单、统一的接口概念,可以在两种样式中使用,且成本最低、可预测。Trait 对象满足 Stroustrup 的“按需付费”原则:当你需要 vtable 时,就会有 vtable,但当不需要时,相同的 trait 可以静态编译掉。
Trait 的多种用途
我们已经在上面看到了 trait 的许多机制和基本用法,但它们在 Rust 中也发挥着其他一些重要作用。这里是一些示例
-
闭包。与
ClickCallback
trait 有些相似,Rust 中的闭包只是特定的 trait。你可以在 Huon Wilson 关于此主题的深入文章中阅读更多关于它是如何工作的。 -
条件 API。泛型使得可以有条件地实现 trait
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() } }
在这里,如果且仅当其组件这样做时,
Pair
类型才会实现Hash
—— 允许单个Pair
类型在不同的上下文中使用,同时支持每个上下文可用的最大 API。这是 Rust 中如此常见的模式,以至于内置了对自动生成某些类型的“机械”实现的支持#[derive(Hash)] struct Pair<A, B> { .. }
-
扩展方法。Trait 可以用来为现有类型(在其他地方定义)扩展新的方法,方便使用,类似于 C# 的扩展方法。这直接来自于 trait 的作用域规则:你只需在一个 trait 中定义新的方法,为相关类型提供实现,瞧,该方法就可以使用了。
-
标记。Rust 有一些用于分类类型的“标记”:
Send
、Sync
、Copy
、Sized
。这些标记只是带有空主体的trait,然后可以在泛型和 trait 对象中使用。标记可以在库中定义,并且它们会自动提供#[derive]
样式的实现:例如,如果一个类型的所有组件都是Send
,那么该类型也是Send
的。正如我们之前看到的,这些标记可能非常强大:Send
标记是 Rust 如何保证线程安全的方式。 -
重载。Rust 不支持传统的重载,即用多个签名定义同一个方法。但是 trait 提供了重载的大部分好处:如果一个方法是基于 trait 通用定义的,那么它可以被任何实现该 trait 的类型调用。与传统的重载相比,这有两个优点。首先,这意味着重载的随意性较低:一旦你理解了一个 trait,你立即就能理解使用它的任何 API 的重载模式。其次,它是可扩展的:你可以通过提供新的 trait 实现来有效地提供方法下游的新重载。
-
运算符。Rust 允许你重载自己类型上的运算符,例如
+
。每个运算符都由相应的标准库 trait 定义,任何实现该 trait 的类型也会自动提供该运算符。
重点是:尽管 trait 看似简单,但它是一个统一的概念,支持广泛的用例和模式,而无需堆砌额外的语言特性。
未来
语言发展的主要方式之一是其抽象设施,Rust 也不例外:我们的许多 1.0 版本后的优先事项都是 trait 系统在某个方向上的扩展。以下是一些重点。
-
静态分发的输出。目前,函数可以使用泛型来定义参数,但其返回值没有对应的机制:你不能说“此函数返回一个实现了
Iterator
trait 的类型的的值”,并让该抽象被编译掉。当你想返回一个你希望被静态分发的闭包时,这尤其成问题——在今天的 Rust 中,你根本做不到。我们希望让这成为可能,并且已经有一些想法。 -
特化。Rust 不允许 trait 实现之间重叠,因此永远不会出现关于运行哪个代码的歧义。另一方面,在某些情况下,你可以为各种类型提供“全局”实现,但之后又想为少数情况提供更专门的实现,通常是为了性能原因。我们希望在不久的将来提出一个设计方案。
-
高阶类型(HKT)。今天的 trait 只能应用于类型,而不是类型构造器——也就是说,应用于像
Vec<u8>
这样的东西,而不是Vec
本身。这个限制使得提供一套良好的容器 trait 变得很困难,因此当前的标准库中没有包含它们。HKT 是一个主要的、跨领域的功能,它将代表 Rust 抽象能力的一大进步。 -
高效重用。最后,虽然 trait 提供了一些重用代码的机制(我们上面没有介绍),但今天仍然有一些重用模式不太适合该语言——特别是,在诸如 DOM、GUI 框架和许多游戏中发现的面向对象层次结构。在不增加太多重叠或复杂性的情况下适应这些用例是一个非常有趣的设计问题,Niko Matsakis 已经开始了一个单独的博客系列来讨论这个问题。目前还不清楚是否所有这些都可以用 trait 完成,或者是否需要其他成分。
当然,我们正处于 1.0 版本发布的前夕,需要一些时间来尘埃落定,并让社区有足够的经验开始落地这些扩展。但这使得参与进来成为一个令人兴奋的时刻:从在这个早期阶段影响设计,到致力于实现,再到在你自己的代码中尝试不同的用例——我们很乐意得到你的帮助!