快速探索 Rust 中的 trait 对象
我在网上看到过不少关于Rust中trait对象的文章或教程,但要么云里雾里没讲清楚,要么东拉西扯难以理解,这篇文章虽然是写于2019年,但作者以静态分派和动态分派作为切入点,介绍了trait对象的作用以及实现方式,看完之后个人感觉有所收获,稍微翻译一下,希望对其他人也有所帮助。
前言
Rust 是一门很有趣的编程语言:它有许多在主流语言中前所未有的功能,这些功能可以用令人惊讶和有趣的方式组合。在许多情况下,它可以成为C的替代品:它的代码运行速度非常快,因为它没有垃圾回收器,所以它的内存使用更可预测, 这对某些程序很有用。在过去3到4年中,我越来越多的使用Rust编程 ,其中最重要的是 grmtools 库集(浏览快速入门指南了解它)。然而在我看来,毫无疑问,Rust不是最容易学习的语言。这在一定程度上是因为它是一种相当庞大的语言:语言复杂性随着语言规模的增加呈指数级增长。但这在一定程度上也是因为很多 Rust 的东西对我们来说都很陌生。学习 Rust 让我回想起了早些年学习编程时磕磕绊绊摸索的经历,花了很多时间,却只得到了有限的成果。然而,这都是值得的, 事实证明,Rust 非常适合我想做的许多事情。
很长一段时间以来,让我感到困惑的一件事是 Rust 的"trait对象":它感觉像是语言中一个奇怪的部分,我甚至都不确定我是否在使用它,即使我很想使用它。 由于我最近有理由更详细地研究它,我想写下一些东西,希望可能会对其他人有所帮助。这篇博文的第一部分介绍基础知识,第二部分介绍 Trait 对象对性能的影响以及在 Rust 中的实现方式。
基础知识
总的来说,Rust 对静态分派 函数调用有非常强烈的偏好,即在编译时就确定调用所匹配的函数。换句话说,当我写一个函数调用f()
时, 编译器静态地计算出我指的是哪个函数f
,并使我的函数调用直接指向该f
函数。 这与动态分派形成鲜明对比,在动态分派 中, 与调用匹配的函数只能在运行时确定。动态分派在 Java 等语言中很常见。例如,考虑一个类C
,定义了一个m
方法,该方法或许有多个子类重写。如果我们有一个C
类的对象o
和方法o.m()
调用,那么我们必须等到运行时才能确定我们是应该调用C
类的实现还是其子类之一的实现。简单来说, 静态分派可提高性能 ,而动态分派在构建程序时提供了更多的灵活性。二者可以通过多种方式组合:有些语言仅提供静态分派,有些语言只提供动态分派,有些语言则要求用户选择是否加入动态分派。
人们很容易以为 Rust 的trait本身就意味着动态分派,但事实并非如此。请考虑以下代码:
rust
trait T {
fn m(&self) -> u64;
}
struct S {
i: u64
}
impl T for S {
fn m(&self) -> u64 { self.i }
}
fn main() {
let s = S{i : 100};
println!("{}", s.m());
}
这里的Trait T
看起来有点像 Java 的接口,它要求一个任意的 class/struct 实现m
方法并返回一个整数:实际上,第15行中的s.m()
调用语法貌似是在一个对象上调用方法,我们很可能会将其理解为动态分派。 但是,Rust 编译器会静态地将m()
调用编译为T::m
。这是可行的,因为第14行的变量s
被静态的确定为类型S
,并且实现了具有m
方法的trait,因此该函数是唯一可能的匹配项。正如这句话所暗示的那样,Rust 的 traits 并不像 Java 接口,而 structs 也不像类。
然而,Rust 确实允许动态分派,尽管一开始感觉像魔法,让我们一步步将它变为现实。假设我们想写一个函数f
,它可以接受任何实现了 trait T
的结构体,并调用其m
函数。我们可以尝试这样写:
rust
fn f(x: T) {
println!("{}", x.m())
}
但是,编译会导致以下错误:
rust
error[E0277]: the size for values of type `(dyn T + 'static)` cannot be known at compilation time
--> src/main.rs:21:6
|
21 | fn f(x: T) {
| ^ doesn't have a size known at compile-time
|
= help: the trait `std::marker::Sized` is not implemented for `(dyn T + 'static)`
= note: to learn more, visit <https://doc.rust-lang.org/book/second-edition/ch19-04-advanced-types.html#dynamically-sized-types-and-the-sized-trait>
= note: all local variables must have a statically known size
= help: unsized locals are gated as an unstable feature
虽然错误长度有点吓人,但它实际上是用三种不同的方式说了同样的事情:我们将一个运行时的值移动 到了f
的参数中。然而------我更愿意从运行代码的方式来考虑这个问题------因为我们不知道结构体T
会有多大, 因此我们无法生成单一机器码来处理它(机器码需要结构体是 8 字节、还是 128 字节、还是......函数需要的堆栈大小是多少?)
该问题的一种解决方案是将f
的后面改为:
rust
fn f<X: T>(x: X) {
println!("{}", x.m())
}
这段代码现在可以编译,但它依旧是静态分派------通过复制代码的方式来实现。尖括号<X: T>
之间的代码定义了一个类型参数,X
为该参数传递的类型 (可能是隐式的) 必须实现 trait T
。这看起来有点像fn f(x: T)
的冗余写法,但类型参数意味着单态化机制 会生效:对于我们给函数传递的每个不同类型X
,都会生成一个专门的f
版本, 从而让 Rust 将所有函数都静态分派。有时这就是我们想要的,但它并不总是明智的:单态化意味着代码膨胀会成为一个真切的关注点(我们最终会得到许多f
函数独立的机器码版本),这可能会限制我们合理构建程序的能力。
幸运的是,有一个替代解决方案可以实现动态分派,如以下代码所示:
rust
trait T {
fn m(&self) -> u64;
}
struct S1 {
i: u64
}
impl T for S1 {
fn m(&self) -> u64 { self.i * 2 }
}
struct S2 {
j: u64
}
impl T for S2 {
fn m(&self) -> u64 { self.j * 4 }
}
fn f(x: &T) {
println!("{}", x.m())
}
fn main() {
let s1 = S1{i : 100};
f(&s1);
let s2 = S2{j : 100};
f(&s2);
}
译注:Rust 1.26及以下的版本可以如上所示直接用&T表示trait对象的引用,新版本的 Rust 必须用dyn关键字显式标记trait对象,上述代码将无法编译。
运行此程序会打印出 200 和 400 ,这是通过动态分派实现的!太棒了!但为什么它起作用了呢?
与以前的版本唯一真正的区别是,我们将f
函数更改为T
类型对象的引用(第 21 行),虽然我们不知道结构体T
有多大,但对每个实现T
的对象的引用都会有相同的大小,所以我们只需要生成一个机器码版本即可。在这里,一个神奇的事情发生了:在第27行中,我们传递了一个S1
类型对象的引用给f
,但f
函数需要的是一个T
类型对象的引用。为什么这样是有效的呢?这是因为在这种情况下,编译器会隐式的将&S1
转换为&T
,因为它知道结构体S
实现了trait T
,更重要的是,这种转换还会附加一些额外的信息,以便运行时系统知道它需要在该对象上调用S1
的方法(而不是S2
)。
这种强制隐式转换是可能的,根据我的经验,这会让那些刚接触 Rust 的人感到惊讶。要说有什么值得欣慰的,那就是甚至经验丰富 Rust 程序员可能无法发现这些转换:函数的签名中没有任何信息会告诉你这种转会发生,除非你发现了T
是一个trait而不是结构体。为此,新的 Rust 的版本允许你添加一个语法标记来使其更清晰:
rust
fn f(x: &dyn T) {
println!("{}", x.m())
}
额外的dyn
关键字没有语义效果,但你可能会感觉到trait对象的转换更明显。不幸的是,因为目前大家对该关键字的使用有点随意,你不能因为没有看到它就断定没有发生动态分派。
译注:正如上面所说,新版本 Rust 强制要求添加该关键字,一般不存在上述这个问题了。
有趣的是,还有另一种方法可以执行同样的转换,而不需要使用引用:我们可以将trait对象放在Box中(即把它放在堆上)。这样一来,无论Box里的结构体有多大,Box的大小都是一样的。下面是一个简单的示例:
rust
fn f2(x: Box<T>) {
println!("{}", x.m())
}
fn main() {
let b: Box<S1> = Box::new(S1{i: 100});
f2(b);
}
在第6行,我们有一个Box<S1>
类型的变量,但传递给f2
时他会自动强制转换为Box<T>
。从某种意义上说,这只是引用转换的一种变体:在这两种情况下,我们都将一个大小未知的事物(trait对象)转换为大小已知的事物(引用或Box)。
胖指针与内部虚指针
在我之前的解释中,我故意忽略了一点:虽然所有指向trait T
对象的引用具有相同的大小,但指向不同类型对象的引用,却不一定具有相同的大小。你很容易在下面的代码中发现这一点,它的执行不会有错误:
rust
use std::mem::size_of;
trait T { }
fn main() {
assert_eq!(size_of::<&bool>(), size_of::<&u128>());
assert_eq!(size_of::<&bool>(), size_of::<usize>());
assert_eq!(size_of::<&dyn T>(), size_of::<usize>() * 2);
}
这说明一个bool
值的引用大小与一个的u128
值的引用大小相同(第 6 行),并且两者都是一个机器字长(第 7 行)。这并不奇怪:引用会被编码为指针。但令人惊讶的是,Trait 对象的引用却是两个机器字长(第 8 行)。这是怎么回事?
Rust 广泛使用胖指针 ,其中就包括使用 trait 对象。一个胖指针是一个指针加上一些额外的东西,所以至少有两个机器字长。对于trait对象的引用,第一个机器字长是指向内存中对象的指针,第二个机器字长是指向该对象的虚表(vtable ,就是指向自身结构体内部动态分派函数的指针条目)。让我们将指向虚表的指针称为虚指针(vpointer)
,虽然这不是一个普遍使用的术语。现在我们可以理解我在前面部分提到的将结构体对象强制转换为trait对象的"魔法":将结构体的虚指针添加到作为结果的指针上,也就将指针强制转换为了胖指针。换句话说,从任意S1
结构体转换而来的trait对象都将拥有一个包含虚指针v1 的胖指针,而从任意S2
结构体转换而来的trait对象也都将拥有虚指针v2 。从概念上讲,v1 的指针条目中有一个指向S1::m
的指针,而 v2 的指针条目中有一个指向S2::m
的指针。如果有需要,使用不安全(unsafe)代码,你可以轻松地将对象指针和虚指针分离。
如果你是 Haskell 或 Go 程序员,这种胖指针的使用方式可能就是你期望的样子。就我个人而言,我习惯于虚指针与对象并列存在,而不是与对象的指针并列存在:据我所知,前一种技术没有一个统一的名字,所以让我们称它为内部虚指针(inner vpointers)。例如,在典型的面向对象的语言里,每个对象都是动态分派的,所以每个对象随身携带自己的虚指针。换句话说,就是要在两种情况中做出选择:要么是向指针添加额外的机器字长(胖指针),要么向对象自身添加机器字长(内部虚指针)。
为什么 Rust 会喜欢胖指针而不是内部虚指针? 如果将性能作为唯一参考标准:内部虚指针的缺点是,每个对象的大小会不断增长。如果每个函数调用都使用对象的虚指针,那问题不大,但正如我之前所展示的,Rust 强烈鼓励你使用静态分派:如果它使用内部虚指针,那虚指针可能在大部分时间里都会被闲置。 因此,胖指针的优点是只会在要使用动态分派的特定程序位置增加额外成本。
译注:原文其实还有一节内容,主要是测试对比胖指针和内部虚指针的性能,结论是通常情况下胖指针性能更好,Rust选择胖指针是正确的,个人感觉相对不那么重要,所以就没有翻译,感兴趣的建议去阅读原文。