在下面的代码片段中可以看到我深入研究动态分派的真正动机。假设我想创建一个包含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>>, |
这种情况出乎我的意料。我一直认为,多重绑定的特性对象(trait object)类似于C++中的多重继承,因此对象应该有多个虚指针,每个都指向对应的继承类,然后再根据匹配情况做分派。考虑到Rust毕竟还是一个非常年轻的语言,语言开发者们可能还不想过早地引入这种复杂性(为了小小的便利而仓促实现的糟糕设计将是沉重的负担),不过我想进一步研究一下这套系统是如何工作的(或者说不能工作的原因)。
Rust中的虚函数表
像C++一样,Rust的动态分派也是通过一个函数指针表实现的(Rust文档中有说明)。根据那个文档,Cat中的Mammal特性对象(trait object)包含两个指针,内存布局如下:
非常奇怪,从中可以看出,对象的数据成员也是通过一个指针来访问的(相当于多出一个间接层),并不像典型的C++内存布局那样。典型的C++内存布局如下:
C++内存布局中,数据成员是紧随vtable指针之后的(没有间接层)。Rust的实现方法比较有意思,当构造特性对象(trait object)时,它产生了一些额外的开销,这不同于C++的实现,C++的实现的好处是对象到基类指针的转换是无开销的(对于多重继承,仅有一点,后面有讲)。不过,Rust实现方式的开销毕竟很小,也确实带来一些好处:如果vtable在多态上下文中实际没有被使用,那它就不必存储它。我想这也可以被看作Rust不鼓励用多态的一个例证,因此这可能是个折衷。
多重绑定的特性对象(trait object)
回到原来的问题,我们来考虑一下如何使用C++来解决。如果我们为一些结构实现了多个特性(就是抽象类),那么结构实例就会有如下的内存布局(以 Mammal和Clone为例):
这里注意我们现在有多个vtable指针。每一个都指向Cat继承的一个对应的基类(Cat包含的那些虚函数)。将一个 Cat* 指针转换为 Mammal* 指针,没有任何开销,但是如果将一个 Cat* 指针转换为 Clone* 指针,编译器将给 this 指针增加一个8字节长度的偏移(这里假设 sizeof(void*) == 8)。(这里解释一下,看上图,Mammal*指针在Cat对象内存中排在第一位,这时Cat*与Mammal*其实是一个东西,内存地址是一样的,所以转换没有开销。后面Cat* 转 Clone*时,Clone* 指针排在 Mammal* 指针后面,所以this指针这时要加上一个偏移量才正确,偏移量就是 Mammal* 指针的大小)
可以想象一下Rust如何做:
因此,现在特性对象包含两个vtable指针了。如果编译器需要实现对 Mammal + Clone 多重特性对象的动态分派,它能够在vtable中选择合适的入口并实现函数调用。可是因为 Rust 现在还不支持结构继承,那么如何决定正确的子对象作为 self 被使用,这个问题就不复存在了。self 将总是指向 data 指针所指向的东西。
这样好像工作的还不错,可是这种方法也存在一些冗余开销。我们持有多个 size/align/drop指针 的拷贝。我们可以通过合并 vtables(虚函数表) 来消除这种冗余。这本质上跟你进行特性继承时编译器所做的一样:
trait CloneMammal: Clone + Mammal{} impl<T> CloneMammal for T where T: Clone + Mammal{}
这种形式的特性继承(trait inheritance)也是规避特性对象(trait object)限制的一种推荐方法。使用特性继承生成一个没有任何冗余的单个vtable。内存布局如下:
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接。 2KB翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。2KB项目(www.2kb.com,源码交易平台),提供担保交易、源码交易、虚拟商品、在家创业、在线创业、任务交易、网站设计、软件设计、网络兼职、站长交易、域名交易、链接买卖、网站交易、广告买卖、站长培训、建站美工等服务