动态分派和静态分派
Rust 可以同时支持“静态分派”(static dispatch)和 "动态分配"(dynamic dispatch)
所谓“静态分派”,是指具体调用哪个函数,在编译阶段就确定下来了。Rust 中的“静态分派”靠泛型以及 impl trait 来完成。
对于不同的泛型类型参数,编译器会生成不同版本的函数,在编译阶段几句确定好了应该调用哪个函数。
所谓“动态派发”,是指具体调用哪个函数,在执行阶段才能确定。
Rust 中的“动态分派”靠 Trait Object 来完成。
Trait Object 本质上是指针,它可以指向不同的类型;指向的具体类型不同,调用的方法也就不同。
我们用一个示例来说明。
假设我们有一个 trait Bird,有另外两个类型都实现了这个 trait,我们要设计一个函数,既可以接受 Duck 作为参数,也可以接受 Swan 作为参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
首先,需要牢牢记住的一件事情是,trait 是一种 DST(Dynamically Sized Type, 动态大小类型) 类型,它的大小在编译阶段是不固定的。这意味着下面这样的代码是无法编译通过的:
1 2 |
|
因为 Bird 是一个 trait,而不是具体类型,它的 size 无法在编译阶段确定,所以编译器是不允许直接使用 trait 作为参数类型和返回类型的。
这也是 trait 跟许多语言中的 "interface" 的一个区别。
这种时候我们有两种选择。一种是利用泛型:
1 2 3 |
|
这样,test 函数的参数既可以是 Duck 类型,也可以是 Swan 类型。
实际上,编译器会根据实际调用参数的类型不同,直接生成不同的函数版本,类似 C++ 中的 template:
1 2 3 4 5 6 7 8 |
|
所以,通过泛型函数实现的“多态”,是在编译阶段就已经确定好了调用哪个版本的函数,因此被称为“静态分派”。
除了泛型之外,Rust 还提供了一种 impl Trait 语法,也能实现静态分派。
我们还有另外一种办法来实现“多态”,那就是通过指针。虽然 trait 是 DST 类型,但是指向 trait 的指针不是 DST。
如果我们把 trait 隐藏到指针的后面,那它就是一个 trait object,而它是可以作为参数和返回类型的。
1 2 3 4 |
|
在这种方式下,test 函数的参数既可以是 Box<Duck>
类型,也可以是 Box<Swan>
类型,一样实现了“多态”。
但在参数类型这里已经将“具体类型”信息抹掉了,我们只知道它可以调用 Bird trait 的方法。
而具体调用的是哪个版本的方法,实际上是由这个指针的值来决定的。这就是“动态分派”。
trait object
什么是 trait object 呢?指向 trait 的指针就是 trait object。
假如 Bird 是一个 trait 的名称,那么 dyn Bird
就是一个 DST 动态大小类型。
&dyn Bird、&mut dyn Bird、Box<dyn Bird>、*const dyn Bird、*mut dyn Bird
以及 Rc<dyn Bird>
等等都是 Trait Object。
当指针指向 trait 的时候,这个指针就不是一个普通的指针了,变成一个“胖指针”。
数组类型 [T]
是一个 DST 类型,因为它的大小在编译阶段是不确定的,相对应的,&[T]
类型是一个“胖指针”,它不仅包含了指向数组的其中一个元素,同时包含一个长度信息。它的内部表示实质上是 Slice 类型。
同理,Bird 只是一个 trait 的名字,符合这个 trait 的具体类型可能有多种,这些类型并不具备同样的大小,因此使用 dyn Bird
来表示满足 Bird 约束的 DST 类型。
指向 DST 的指针理所当然也应该是一个“胖指针”,它的名字就叫 trait object。
比如 Box<dyn Bird>
,它的内部表示可以理解成下面这样:
1 2 3 4 |
|
它里面包含了两个成员,都是指向单元类型的裸指针。
在这里声明的指针指向的类型并不重要,我们只需知道它里面包含了两个裸指针即可。
由上可见,和 Slice 一样,Trait Object 除了包含一个指针之外,还带有另外一个“元数据”,它就是指向“虚函数表”的指针。
这里用的是裸指针,指向 unit 类型的指针 *mut ()
实际上类似于 C 语言中的 void*
。
我们来尝试一下使用 unsafe 代码,如果把它里面的数值当成整数拿出来会是什么结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
|
执行结果:
1 2 3 4 5 6 7 |
|
我们可以看到,直接针对对象取指针,得到的是普通指针,它占据 64 bit 的空间。
如果我们把这个指针使用 as 运算符转换为 trait object,它就成了胖指针,携带了额外的信息。
这个额外信息很重要,因为我们还需要使用这个指针调用函数。
如果指向 trait 的指针只包含了对象的地址,那么它就没办法实现针对不同的具体类型调用不同的函数了。
所以,它不仅要包含一个指向真实对象的指针,还要有一个指向所谓的“虚函数表” 的指针。
我们把虚函数表里面的内容打印出来可以看到,里面有我们需要被调用的具体函数的地址。
从这里的分析结果可以看到,rust 的动态分派和 C++ 的动态分派,内存布局有所不同。
在 C++ 里,如果一个类型里面有虚函数,那么每一个这种类型的变量内部都包含一个指向虚函数表的地址。
而在 Rust 里面,这个指针是存在于 trait object 指针里面的。
如果一个类型实现了多个 trait,那么不同的 trait object 指向的虚函数表也不一样。