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 向量并调用 runwalk。 但是,当您尝试向 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,那么该结构体的实例将具有以下布局(例如,MammalClone):

请注意,我们现在有多个 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,那么它应该是 CatClonevtable。 如果它引用的是 Dog,那么它应该是 DogClonevtable

因此,trait 继承方法有这个限制。 您不能将 trait 对象转换为任何其他类型的 trait 对象,即使您想要的 trait 对象比您已经拥有的 trait 对象_更具体_。

vtable 指针方法似乎是允许具有多个边界的 trait 对象的一个很好的前进方向。 使用该设置可以很容易地转换为边界较少的 trait 对象。 编译器应使用的 vtable 仅仅是已经存在的 Clone vtable 指针槽(图 4 中的第二个指针)。

结论

我希望通读这篇文章对一些读者来说是一个有用的练习。 它当然帮助我整理了我对 trait 对象的看法。 在实践中,我认为这并不是一个紧迫的问题,这种限制只是让我感到惊讶。