Skip to content

动态分派和静态分派

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 Bird {
    fn fly(&self);
}

struct Duck;
struct Swan;

impl Bird for Duck {
    fn fly(&self) {
        println!("duck duck");
    }
}

impl Bird for Swan {
    fn fly(&self) {
        println!("swan swan");
    }
}

首先,需要牢牢记住的一件事情是,trait 是一种 DST(Dynamically Sized Type, 动态大小类型) 类型,它的大小在编译阶段是不固定的。这意味着下面这样的代码是无法编译通过的:

1
2
fn test(arg: Bird) {}
fn test() -> Bird {}

因为 Bird 是一个 trait,而不是具体类型,它的 size 无法在编译阶段确定,所以编译器是不允许直接使用 trait 作为参数类型和返回类型的。
这也是 trait 跟许多语言中的 "interface" 的一个区别。

这种时候我们有两种选择。一种是利用泛型:

1
2
3
fn test<T: Bird>(arg: T) {
    arg.fly();
}

这样,test 函数的参数既可以是 Duck 类型,也可以是 Swan 类型。
实际上,编译器会根据实际调用参数的类型不同,直接生成不同的函数版本,类似 C++ 中的 template:

1
2
3
4
5
6
7
8
// 伪代码示意
fn test_Duck(arg: Duck) {
    arg.fly();
}

fn test_Swan(arg: Swan) {
    arg.fly();
}

所以,通过泛型函数实现的“多态”,是在编译阶段就已经确定好了调用哪个版本的函数,因此被称为“静态分派”。
除了泛型之外,Rust 还提供了一种 impl Trait 语法,也能实现静态分派。

我们还有另外一种办法来实现“多态”,那就是通过指针。虽然 trait 是 DST 类型,但是指向 trait 的指针不是 DST。
如果我们把 trait 隐藏到指针的后面,那它就是一个 trait object,而它是可以作为参数和返回类型的。

1
2
3
4
// 根据不同需求,可以用不同的指针类型,如 Box/&/&mut 等
fn test(arg: Box<dyn Bird>) {
    arg.fly();
}

在这种方式下,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
pub struct TraitObject {
    pub data: *mut (),
    pub vatble: *mut (),
}

它里面包含了两个成员,都是指向单元类型的裸指针。
在这里声明的指针指向的类型并不重要,我们只需知道它里面包含了两个裸指针即可。
由上可见,和 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
use std::mem;

trait Bird {
    fn fly(&self);
}

struct Duck;
struct Swan;

impl Bird for Duck {
    fn fly(&self) { println!("duck duck"); }
}

impl Bird for Swan {
    fn fly(&self) { println!("swan swan");}
}
// 参数是 trait object 类型,p 是一个胖指针
fn print_traitobject(p: &dyn Bird) {

    // 使用transmute执行强制类型转换,把变量p的内部数据取出来
    let (data, vtable) : (usize, * const usize) = unsafe {mem::transmute(p)};
    println!("TraitObject    [data:{}, vtable:{:p}]", data, vtable);
    unsafe {
        // 打印出指针 v 指向的内存区间的值
        println!("data in vtable [{}, {}, {}, {}]",
            *vtable, *vtable.offset(1), *vtable.offset(2), *vtable.offset(3));
    }
}

fn main() {
    let duck = Duck;
    let p_duck = &duck;
    let p_bird = p_duck as &dyn Bird;
    println!("Size of p_duck {}, Size of p_bird {}", mem::size_of_val(&p_duck), mem::size_of_val(&p_bird));

    let duck_fly : usize = Duck::fly as usize;
    let swan_fly : usize = Swan::fly as usize;
    println!("Duck::fly {}", duck_fly);
    println!("Swan::fly {}", swan_fly);

    print_traitobject(p_bird);
    let swan = Swan;
    print_traitobject(&swan as &dyn Bird);
}

执行结果:

1
2
3
4
5
6
7
Size of p_duck 8, Size of p_bird 16
Duck::fly 94168464827808
Swan::fly 94168464827888
TraitObject    [data:140723970908688, vtable:0x55a54de8c4b8]
data in vtable [94168464827792, 0, 1, 94168464827808]
TraitObject    [data:140723970908688, vtable:0x55a54de8c548]
data in vtable [94168464827792, 0, 1, 94168464827888]

我们可以看到,直接针对对象取指针,得到的是普通指针,它占据 64 bit 的空间。
如果我们把这个指针使用 as 运算符转换为 trait object,它就成了胖指针,携带了额外的信息。
这个额外信息很重要,因为我们还需要使用这个指针调用函数。
如果指向 trait 的指针只包含了对象的地址,那么它就没办法实现针对不同的具体类型调用不同的函数了。
所以,它不仅要包含一个指向真实对象的指针,还要有一个指向所谓的“虚函数表” 的指针。
我们把虚函数表里面的内容打印出来可以看到,里面有我们需要被调用的具体函数的地址。

从这里的分析结果可以看到,rust 的动态分派和 C++ 的动态分派,内存布局有所不同。
在 C++ 里,如果一个类型里面有虚函数,那么每一个这种类型的变量内部都包含一个指向虚函数表的地址。
而在 Rust 里面,这个指针是存在于 trait object 指针里面的。
如果一个类型实现了多个 trait,那么不同的 trait object 指向的虚函数表也不一样。