关于Rust中trait object和虚表

原文链接:Exploring Dynamic Dispatch in Rust

时间:2017-3-07

本文发表时间在2018edtion之前,观点可能不代表最新版本Rust(比如现在有了dyn关键词)

首先我要说的是我是Rust的新手(尽管到目前为止我很喜欢这门语言!),因此,如果我出现了技术错误,请告知我,我会试着纠正。所以,开始吧。
在下面代码片段中可以看到我研究动态分发的真正原因。假设我要创建一个CloningLab结构体,其中包含一个由trait object(在本例中为Mammal)构成的vector

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct CloningLab {
subjects: Vec<Box<Mammal>>,
}

trait Mammal {
fn walk(&self);
fn run(&self);
}

#[derive(Clone)]
struct Cat {
meow_factor: u8,
purr_factor: u8
}

impl Mammal for Cat {
fn walk(&self) {
println!("Cat::walk");
}
fn run(&self) {
println!("Cat::run")
}
}

正常运行。你可以可以遍历subjects向量(vector),并可以调用runwalk方法。但是,当你想对一个trait object添加一个额外的trait约束的话便会报错:

1
2
3
4
5
6
7
8
9
struct CloningLab {
subjects: Vec<Box<Mammal + Clone>>,
}

impl CloningLab {
fn clone_subjects(&self) -> Vec<Box<Mammal + Clone>> {
self.subjects.clone()
}
}

报错信息如下:

1
2
3
4
5
error[E0225]: only the builtin traits can be used as closure or object bounds
--> test1.rs:3:32
|
3 | subjects: Vec<Box<Mammal + Clone>>,
| ^^^^^ non-builtin trait used as bounds

这令我非常的惊奇。在我看来,一个具有多个约束的trait object大抵可以类比于C++中的多继承。我以为其中实例都拥有多个虚函数表指针(vpointer)对应每一个基类,并且能正确分发。鉴于Rust仍还是一门年轻的语言,我很理解为什么开发人员可能不希望引入这种复杂性大大提升的特性(一直坚持糟糕的设计则会事倍功半),但是我想弄清楚这样的系统究竟是如何运作的(或无法运作)。

Rust中的虚表

C++那样,动态分发在Rust中是通过函数指针表实现的(在Rust文档中有描述)。根据文档,构成CatMammal这个trait object的内存布局由两个指针组成,如下所示:

cat_layout-2
cat_layout-2

令我惊讶的是,对象的数据成员还有一个中间层。这看起来和C++表示形式有所不同:

cat_layout_cpp
cat_layout_cpp

先是虚表(vtable)指针,随后则是数据成员。Rust方法很有趣。 “构造”trait object时会产生成本,这与C++的方法不同,在C++中,强制转换为基类指针是零成本的(或者对于多重继承来说只是一些附加成本)。但这成本很小。 Rust方法的好处是,如果对象从未在多态上下文中使用过,则该对象不必存储虚表指针。我认为Rust励使用单态性这种说法比较好一些,所以这可能是一个不错的权衡方案。

多约束trait object

让我们回到最开始的那个问题,让我们思考一下这个问题如何在C++中解决。如果我们有为某个结构体实现的多个trait(纯虚类),那么我们的结构体实例内存布局将如下(例:MammalClone):

cat_and_clone_cpp-1
cat_and_clone_cpp-1

可以看到我们现在有多个虚表指针,每个指针对应于Cat继承的一个基类(包含虚函数)。为了把一个Cat*转为Mammal*,我们不需要做任何事,但是要把Cat*转为一个Clone*,编译器将会为this指针增加 8 字节(用来跳到下一个指针,假定 sizeof(void*) == 8 )。

不难想象在Rust中类似的情形:

cat_clone_rust_candidate_1-1
cat_clone_rust_candidate_1-1

所以现在在这个trait object里面有两个虚表指针了。如果编译器需要对于Mammal + Clone这个trait object履行动态分发的原则的话,它可以访问对应虚表中的对应项并执行调用。然而Rust(还)并不支持结构体继承,所以并不存在把正确的子对象作为self传递的问题。self永远指向的是data指针。

这看上去好像可以很好的运行,但是这种方案也带来了一些冗余。对于这个类型的大小(size)、对齐(alignment)以及drop指针使我们有了多份拷贝。我们可以通过组合虚表来消除这些冗余。这基本上就是当你执行 trait 继承时会发生的事情:

1
2
3
trait CloneMammal: Clone + Mammal{}

impl<T> CloneMammal for T where T: Clone + Mammal{}

以这种方式使用 trait 继承是一个通常建议的技巧,以绕过 trait 对象的正常限制。trait 继承的使用产生了一个单独的虚表,没有任何冗余。所以内存布局如下:

clone_mammal_rust-1
clone_mammal_rust-1

译者:翻译到一半结果发现有人已经翻译完了😅️(人麻了)

那就直接贴网址吧 下面的基本是复制的翻译:点击跳转

更加简单!并且你现在就可以这么做!或许我们真正想要的是,当我们写出一个多约束的 trait 对象时,让编译器为我们生成一个这样的 trait(译者注:指仅含有单个虚表的 trait)。但是等一下,这里存在一些重要的限制。即,你不能把一个Clone + Mammal的 trait 对象转为一个Clone的 trait 对象。这似乎是很奇怪的行为,但是不难看到为什么这样的转换行不通。

假定你尝试写出下面的代码:

1
2
3
4
5
6
7
8
9
10
let cat = Cat {
meow_factor: 7
purr_factor: 8
};

// No problem, a CloneMammal is impl for Cat
let clone_mammal: &CloneMammal = cat;

// Error!
let clone: &Clone = &clone_mammal;

第 10 行一定无法编译,因为编译器不可能找到对应的虚表来放入这个 trait 对象。它只知道这个被引用的对象实现了Clone + Mammal,但是它无法区分这二者。当然,我们可以区分它一定是个Cat,但是如果代码像下面这样呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let cat = Cat {
meow_factor: 7
purr_factor: 8
};

let dog = Dog { ... };

let clone_mammal: &CloneMammal;
if get_random_bool() == true {
clone_mammal = &cat;
} else {
clone_mammal = &dog;
}

// Error! How can the compiler know what vtable to
// point to?
let clone: &Clone = &clone_mammal;

这里的问题就更加清晰了。编译器怎么知道要对 17 行正在构造的 trait 对象放入什么样的虚表呢?如果clone_mammal指向一个Cat,那么它应该是CloneCat虚表,如果它指向一个Dog,那么它应该是CloneDog虚表。
所以 trait 继承这种方式有这种限制。你无法把一个 trait 对象转成 trait 对象的其他类型,即使当这个你想要的 trait 对象比你已经拥有的更加具体。

多个虚表指针的方式对于具有多约束的trait对象来说,看起来是一种好的方式。通过它,转换为一个低约束的trait对象就不是问题了。编译器应该使用的虚表就是Clone虚表指针指向的位置。

下面就不是我复制的了

结论

我希望完成这些能对一些读者带来收获。它肯定帮助我整理了对trait object的思考方式。在实践中,我认为这并不是一个真正紧迫的问题,这个限制只是让我感到惊讶罢了。

最终代码(自己实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
trait NewTrait: Mammal + std::clone::Clone {}

struct CloningLab<T: NewTrait> {
subjects: Vec<Box<T>>,
}

impl<T: NewTrait> CloningLab<T> {
fn clone_subjects(&self) -> Vec<Box<T>> {
self.subjects.clone()
}
}

trait Mammal {
fn walk(&self);
fn run(&self);
}

#[derive(Clone)]
struct Cat {
meow_factor: u8,
purr_factor: u8,
}

impl Mammal for Cat {
fn walk(&self) {
println!("Cat::walk");
}
fn run(&self) {
println!("Cat::run")
}
}

总的来说,解决思路便是以泛型限制trait object,可能是Rust团队希望单态和多态分别用不同的语法来实现吧。