阅读 1572
【译】探讨Rust中的动态分发(dynamic dispatch)

【译】探讨Rust中的动态分发(dynamic dispatch)

原文链接:https://alschwalm.com/blog/static/2017/03/07/exploring-dynamic-dispatch-in-rust/

原文标题:Exploring Dynamic Dispatch in Rust

公众号:Rust 碎碎念

概述

在此我先声明,我是一名 Rust 新手(尽管目前为止我还挺喜欢它的),因此,如果我犯了技术上的错误,请告知我,我会努力修正它们。说完这些,让我们开始吧。

我想要仔细研究动态分发(dynamic dispatch)可以从下面的代码片段中看出。假定我想创建一个结构体CloningLab,该结构体包含一个 trait 对象的 vector(在这个示例中,是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 这个 vector 并按照你所预期的那样调用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]: 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
复制代码

我发现这很令人惊奇。在我的脑海中,一个带有多个约束(bound)的 trait 对象类似于 C++中的多继承。我期望这个对象拥有多个虚表指针分别指向每一个“基类”,并且通过对应的虚表指针进行分发。考虑到 Rust 仍然是一门比较年轻的语言,我可以理解为什么 Rust 的开发者不想立刻引入这种复杂性(被一个槽糕的设计永久地困住将得不偿失),但是我想明确搞清楚,这样一个系统怎么可以工作(或不工作)。

Rust 中的虚表(Vtables in Rust)

像 C++一样,动态分发(dynamic dispatch)在 Rust 中通过一张函数指针的表来实现(Rust 文档中的这里有描述)。根据文档,由一个Cat产生的Mammal trait 对象由两个指针组成,布局如下:

我很惊讶地看到,这个对象的数据成员多了一层间接性。这一点和(经典的)C++表示不太像,C++表示如下:

在上图中,虚表指针放在首位,数据成员紧随其后。Rust 的方式比较有趣。它带了“构造(constructing)”一个 trait 对象的开销,不同于 C++的方式,在 C++的方式中,转换为一个基类指针是零开销(或者仅在多继承情况下有一些开销)。但是这种开销非常小。Rust 的这种方式有一个好处,即一个对象如果从未在一个多态上下文环境中( polymorphic context)被使用则无须存储虚表指针。我认为 Rust 鼓励使用单态(monomorphism)的说法比较公正一些,所以这可能是一个比较好的取舍(trade-off)。

多约束的 trait 对象(Trait Objects with Multiple Bounds)

回到最开始的问题,让我们考虑它在 C++中是如何被解决的。如果我们有多个为某个结构体实现的 traits(纯抽象类),这个结构体的一个实例将会有下面的布局(例如,Mammal 和 Clone):

注意,我们现在有多个虚表指针,每个指针对应于Cat继承的一个基类(包含虚函数)。为了把一个Cat*转为Mammal*,我们不需要做任何事,但是要把Cat*转为一个Clone*,编译器将会为this指针增加 8 字节(假定 sizeof(void*) == 8 译者注:这里指地址山从 this 指针偏移 8 字节)。

不难想象类似的东西在 Rust 中的布局:

在这个 trait 对象中有两个虚表指针。如果编译器需要在一个Mammal + Clone trait 对象上执行动态分发,它可以访问对应虚表中的对应条目并执行调用。因为 Rust 不(尚未)支持结构体继承,所以不存在把正确的子对象作为self进行传递的问题。self永远都是data指针指向的位置。

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

trait CloneMammal: Clone + Mammal{}

impl<T> CloneMammal for T where T: Clone + Mammal{}
复制代码

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

更加简单!并且你现在就可以这么做!或许我们真正想要的是,当我们写出一个多约束的 trait 对象时,让编译器为我们生成一个这样的 trait(译者注:指仅含有单个虚表的 trait)。但是等一下,这里存在一些重要的限制。即,你不能把一个Clone + Mammal的 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 对象。它只知道这个被引用的对象实现了Clone + Mammal,但是它无法区分这二者。当然,我们可以区分它一定是个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 对象放入什么样的虚表呢?如果clone_mammal指向一个Cat,那么它应该是CloneCat虚表,如果它指向一个Dog,那么它应该是CloneDog虚表。
所以 trait 继承这种方式有这种限制。你无法把一个 trait 对象转成 trait 对象的其他类型,即使当这个你想要的 trait 对象比你已经拥有的更加具体。

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

总结(Conclusions)

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

本文首发于个人公众号:Rust碎碎念,禁止转载。欢迎扫描下方二维码获取最新Rust文章。

文章分类
阅读
文章标签