在下面的代码片段中可以看到我深入研究动态分派的真正动机。假设我想创建一个包含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") } }
这段代码工作得很好。你能遍历对象集合并根据需要调用 run 或 walk 函数。然而,当你想再增加一个特性(trait)到特性对象绑定中时,事情开始变得麻烦了:
struct CloningLab { subjects: Vec<Box<Mammal + Clone>>, } impl CloningLab { fn clone_subjects(&self) -> Vec<Box<Mammal + Clone>> { self.subjects.clone() } }
错误消息如下:
error[E0225]: only the builtin traits can be used as closure or object bounds --> test1.rs:3:32 | 3 | subjects: Vec<Box<Mammal + Clone>>, |
Rust中的虚函数表
像C++一样,Rust的动态分派也是通过一个函数指针表实现的(Rust文档中有说明)。根据那个文档,Cat中的Mammal特性对象(trait object)包含两个指针,内存布局如下:
非常奇怪,从中可以看出,对象的数据成员也是通过一个指针来访问的(相当于多出一个间接层),并不像典型的C++内存布局那样。典型的C++内存布局如下:
C++内存布局中,数据成员是紧随vtable指针之后的(没有间接层)。Rust的实现方法比较有意思,当构造特性对象(trait object)时,它产生了一些额外的开销,这不同于C++的实现,C++的实现的好处是对象到基类指针的转换是无开销的(对于多重继承,仅有一点,后面有讲)。不过,Rust实现方式的开销毕竟很小,也确实带来一些好处:如果vtable在多态上下文中实际没有被使用,那它就不必存储它。我想这也可以被看作Rust不鼓励用多态的一个例证,因此这可能是个折衷。
这里注意我们现在有多个vtable指针。每一个都指向Cat继承的一个对应的基类(Cat包含的那些虚函数)。将一个 Cat* 指针转换为 Mammal* 指针,没有任何开销,但是如果将一个 Cat* 指针转换为 Clone* 指针,编译器将给 this 指针增加一个8字节长度的偏移(这里假设 sizeof(void*) == 8)。(这里解释一下,看上图,Mammal*指针在Cat对象内存中排在第一位,这时Cat*与Mammal*其实是一个东西,内存地址是一样的,所以转换没有开销。后面Cat* 转 Clone*时,Clone* 指针排在 Mammal* 指针后面,所以this指针这时要加上一个偏移量才正确,偏移量就是 Mammal* 指针的大小)
可以想象一下Rust如何做:
这样好像工作的还不错,可是这种方法也存在一些冗余开销。我们持有多个 size/align/drop指针 的拷贝。我们可以通过合并 vtables(虚函数表) 来消除这种冗余。这本质上跟你进行特性继承时编译器所做的一样:
trait CloneMammal: Clone + Mammal{} impl<T> CloneMammal for T where T: Clone + Mammal{}
这种形式的特性继承(trait inheritance)也是规避特性对象(trait object)限制的一种推荐方法。使用特性继承生成一个没有任何冗余的单个vtable。内存布局如下:
评论删除后,数据将无法恢复
评论(0)