Rust Dispatch
分发(Dispatch)
当代码涉及到多态(polymorphism)时,需要机制判断最终执行的代码到底是哪一个具体的实现版本。这个过程就叫分发。
首先定义一点基础代码,作为后续内容的代码示例的默认前提:
静态分发(Static Dispatch)
泛型
类似于 C++ 里的模板,Rust 里的泛型(generic)包括加上 traits bounds 的泛型都是静态分发。具体实现是单态化(monomorphization):
即代码在编译期间 多态方法 被多个单独的 单态方法 替换。
比如以下实现:
实际会被展开成类似:
通过编译期间的单态化,编译器去除了泛型的概念,优点是在运行期间无性能损耗,缺点是滥用泛型会导致生成的单态化代码变多,编译时间增加,生成的二进制文件体积变大。这和 C++ 模板是一致的。
impl Trait
除了泛型之外,impl Trait
也用作静态分发,impl Trait
可以用在参数类型和返回值类型里。比如上述的 fn do_print<T: Show>(x: T);
也可以用 impl Trait
的形式写成:
当 impl Trait
作为返回值类型时,需要注意编译器需要推导出返回值的具体类型来实现单态化,因此无法写出 if - else
等不同分支下返回不同类型(虽然它们都实现了这个 Trait )的代码。编译器无法推导出单态化的目标类型,一般会在后一种类型的返回处报错:expect A but get B...
实际编码中作为返回值写 impl Trait
的形式有两种用途:
- 让编译器推导类型,避免写特别长的类型
- 用在返回闭包的场景
第一个场景:看下面这个例子,实现了一个连接两个 Vector 的函数
泛型函数 combine_vec
的真实返回值是 std::iter::Cycle<std::iter::Chain<std::vec::IntoIter<T>, std::vec::IntoIter<T>>>
,这里编译的时候会推导出返回值的类型和泛型 T
的类型,实际上会生成类似如下代码:
很明显我们的这个函数的意图是利用迭代器的 chain
方法连接两个 Vec
,因此返回的类型一定还是原先泛型 T
的迭代器,因此简单的写上 impl Iterator<...>
,剩下的工作让编译器去做就好了。
因为即使我们写成确切的类型,也不会给对读代码的人提供更多有帮助的信息,徒增阅读负担而已。
第二个场景:Rust 里闭包类型是匿名的,无法显示的写出来,这种情况下我们只能只用 impl Trait
的形式来写:
动态分发(Dynamic Dispatch)
静态分发的缺点:无法返回多种类型 正是动态分发要解决的问题。使用 Trait Object 表达 实现了某种 Trait 的类型(的集合) 这种类型,这种类型有点像 OOP 语言里的基类/抽象类,本身无法实例化出对象。
Trait Object
Trait Object 本身可以理解为有固定大小的类型,其包含两个指针,一个指向其实际的类型,一个指向实现了 TraitObject 的这个 Trait 的虚表,可以理解为如下表达:
虽然 Trait Object 大小是确定,但是并不能写出形如 fn x() -> Trait Object
的代码,你会得到编译器的警告:
嗯?这是因为 Trait Object 所表达的实现了该 Trait 的类型的集合,其中的元素的大小是不确定的。Trait Object 本身是无法实例化的,作为返回值自然是没有意义的,真正要作为返回值的是某个实现了该 Trait 的类型的实例。因此要明确并不是因为 Trait Object 本身大小不固定,大小就是宽指针是固定的。
dyn Trait
明白了 Trait Object 的概念,代码中要表示一个 Trait Object,需要使用 dyn
关键字:dyn SomeTrait
表示 SomeTrait
的 Trait Object
这里有些历史,在 2016 年之前还没有 dyn
这个关键字,RFC-2113 中引入了这个关键字语法,并在 rust 1.26/2021 edition 后必须使用 dyn
才能表示 Trait Object
要求使用 dyn
就是为了清晰表达含义,区分 Trait 和 TraitObject。提案里给出了几个实例:比如下面代码:
这在引入 dyn 关键字之前都是合法的代码(现在会提示了),能分清楚这里的几个 xxxTrait,哪个是 Trait ,哪个是 Trait Object 么 :)
第一条按照 impl trait for type
的语法,SomeTrait
是 Trait, AnotherTrait
应该是 Trait Object;
第二条很容易理解为给 SomeTrait
实现一些默认方法,但是应该在定义处 trait SomeTrait {...}
里实现默认方法,这里其实是表达给 SomeTrait
的 Trait Object 实现方法。
当然如今这样写已经会被编译器警告了,正确的写法是:
vtable in Rust
首先先看一下 C++ 里的虚表实现,对于一个子类对象,其内存布局包括:(注:这里只考虑继承包含虚函数父类的子类的情况,仅为和 Rust 虚表对比,不能完全代表真实内存布局实现)
子类内存布局 | vtable | |
---|---|---|
虚表指针 | —> | 析构方法指针 |
子类成员/数据 | 成员方法指针 |
指向虚表的指针和自身成员数据。
对于多继承的情况,会有多个虚表指针:
vtable | 子类内存布局 | vtable | ||
---|---|---|---|---|
虚表指针 | —> | 析构方法指针 | ||
析构方法指针 | <— | 虚表指针 | 成员方法指针 | |
成员方法指针 | 子类成员/数据 |
而 Rust 里,如上 TraitObject 的 raw code:很容易得出单 Trait 下的 TraitObject 布局:
子类布局 | Trait Object | vtable | ||
---|---|---|---|---|
成员数据 | <— | 数据指针 | ||
虚表指针 | —> | 析构方法指针 | ||
成员方法指针 |
那么如果我想写出多个 Trait Bounds 的 Trait Object 呢?如下代码:
会在 + SecondTrait
处得到如上提示,以及如下建议:
(rustc 真贴心地教你写代码)按照提示需要这样写:
很容易猜测到这是因为 TraitObject 的实现方式:仅包含两个指针,无法增加更多的 vtable 指针来达成类似 C++ : public A, public B
多继承的效果。
拓展知识:目前 supertrait 下还不支持 upcasting coercion: issues
目前编译器还无法识别出应该放入哪个 FirstTrait
的 Trait Object 的虚表,可以用下面的方式手动补充实现: