探索 Rust 中的动态分发 (Dynamic Dispatch)
Adam Schwalm | 2017年3月7日 星期二
首先声明,我还是个 Rust
新手(虽然目前为止感觉还不错!),如果我犯了技术错误,请告诉我,我会尽量纠正。 让我们开始吧。
我深入研究动态分发 (Dynamic Dispatch) 的真正动机可以在下面的代码片段中看到。 假设我想创建一个包含 trait 对象向量的 CloningLab
结构体(在本例中,是 Mammal
):
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
向量并调用 run
或 walk
。 但是,当您尝试向 trait 对象边界添加额外的 trait 时,事情就会崩溃,例如:
struct CloningLab {
subjects: Vec<Box<Mammal + Clone>>,
}
impl CloningLab {
fn clone_subjects(&self) -> Vec<Box<Mammal + Clone>> {
self.subjects.clone()
}
}
这会失败并出现以下错误:
error[E0225]:onlythebuiltintraitscanbeusedasclosureorobjectbounds
-->test1.rs:3:32
|
3|subjects:Vec<Box<Mammal+Clone>>,
|^^^^^non-builtintraitusedasbounds
我对此感到惊讶。 在我看来,具有多个边界的 trait 对象类似于 C++
中的多重继承。 我希望该对象为每个“基类”都有多个 vpointer
,并通过适当的一个进行分发。 鉴于 Rust
仍然是一种相对年轻的语言,我可以理解为什么开发人员可能不想立即引入这种复杂性(永远坚持一个糟糕的设计,代价太高,收益太小),但我想要弄清楚这样的系统究竟是如何工作的(或者说不起作用)。
Rust 中的 Vtable
像 C++
一样,Rust
中的动态分发是通过函数指针表实现的(在 Rust
文档中有描述)。 根据该文档,由 Cat
创建的 Mammal
trait 对象的内存布局将由两个指针组成,排列如下:
我惊讶地发现对象的数据成员还有一个额外的间接层。 这与(典型的)C++
表示不同,后者如下所示:
vtable
指针在最前面,数据成员紧随其后。 Rust
的方法很有趣。 与 C++
中转换为基类指针是免费的(或者只是多重继承的一些加法)不同,它在“构造”trait 对象时会产生开销。 但是这个成本非常小。 Rust
方法的好处是,如果对象永远不用在多态上下文中,则不必存储 vtable
指针。 我认为可以公平地说,Rust
鼓励使用单态化 (monomorphism),因此这可能是一个很好的权衡。
具有多个边界的 Trait 对象
回到最初的问题,让我们考虑一下它在 C++
中是如何解决的。 如果我们有多个 traits(纯粹的抽象类),我们为某些结构体实现这些 traits,那么该结构体的实例将具有以下布局(例如,Mammal
和 Clone
):
请注意,我们现在有多个 vtable
指针,每个基类 Cat
都从中继承(包含虚函数)。 要将 Cat*
转换为 Mammal*
,我们不需要做任何事情,但是要将 Cat*
转换为 Clone*
,编译器会将 8 个字节(假设 sizeof(void*) == 8
)添加到 this
指针。
很容易想象 Rust
也有类似的东西:
所以 trait 对象现在有两个 vtable
指针。 如果编译器需要在 Mammal + Clone
trait 对象上执行动态分发,它可以访问适当的 vtable
中的相应条目并执行调用。 因为 Rust
还不(支持)结构体继承,所以确定要传递哪个正确的子对象作为 self
的问题不存在。 self
将始终是由 data
指针指向的任何内容。
这似乎可以很好地工作,但是这种方法也有一些冗余。 我们有多个类型的大小、对齐方式和 drop
指针的副本。 我们可以通过组合 vtable
来消除这种冗余。 本质上,当您执行 trait 继承时会发生这种情况,例如:
trait CloneMammal: Clone + Mammal{}
impl<T> CloneMammal for T where T: Clone + Mammal{}
以这种方式使用 trait 继承是一种常见的技巧,可以绕过 trait 对象的正常限制。 trait 继承的使用产生单个 vtable
,没有任何冗余。 所以内存布局如下所示:
简单多了! 而且你现在可以这样做! 也许我们真正想要的是,当我们尝试创建一个具有多个边界的 trait 对象时,编译器为我们生成一个这样的 trait 。 但是请稍等,有一些重要的限制。 也就是说,您无法将 CloneMammal
的 trait 对象转换为 Clone
的 trait 对象。 这似乎是非常奇怪的行为,但是不难看出为什么这样的转换不起作用。
假设您尝试编写如下内容:
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 对象中的适当的 vtable
。 它只知道被引用的对象实现了 CloneMammal
,但它不知道是哪一个。 当然,我们可以看出它一定是 Cat
,但是如果代码是这样的呢:
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 对象中的 vtable
? 如果 clone_mammal
引用的是 Cat
,那么它应该是 Cat
的 Clone
的 vtable
。 如果它引用的是 Dog
,那么它应该是 Dog
的 Clone
的 vtable
。
因此,trait 继承方法有这个限制。 您不能将 trait 对象转换为任何其他类型的 trait 对象,即使您想要的 trait 对象比您已经拥有的 trait 对象_更具体_。
多 vtable
指针方法似乎是允许具有多个边界的 trait 对象的一个很好的前进方向。 使用该设置可以很容易地转换为边界较少的 trait 对象。 编译器应使用的 vtable
仅仅是已经存在的 Clone
vtable
指针槽(图 4 中的第二个指针)。
结论
我希望通读这篇文章对一些读者来说是一个有用的练习。 它当然帮助我整理了我对 trait 对象的看法。 在实践中,我认为这并不是一个紧迫的问题,这种限制只是让我感到惊讶。