Skip to content

23.1 trait object

什么是 trait object 呢?指向 trait 的指针就是 trait object。假如 Bird 是一个 trait 的名称,那么 dyn Bird 就是一个 DST 动态大小类型。&dyn Bird&mut dyn BirdBox<dyn Bird>*const dyn Bird*mut dyn Bird以及Rc<dyn Bird>等等都是 Trait Object。

示例:

rust
use std::fmt::Debug;

fn dyn_trait(n: u8) -> Box<dyn Debug> {
    if n % 2 == 0 {
        Box::new(n)
    } else {
        Box::new(format!("odd {}", n))
    }
}

fn impl_trait(n: u8) -> impl Debug {
    if n % 2 == 0 {
        n.to_string()
    } else {
        format!("odd {}", n)
    }
}

fn main() {
    let x: Box<dyn Debug> = dyn_trait(4);
    println!("{:?}", x);

    let y = impl_trait(5);
    println!("{:?}", y);
}

示例代码中,dyn_traitimpl_trait 是两个函数,它们都返回实现了 Debug trait 的类型。

dyn_trait 返回一个 Box 指针,指向动态分发的类型,而 impl_trait 直接返回一个实现了 Debug 的类型(在这里是 u8 和字符串类型)。 这意味着 dyn_trait 可以存储不同的类型,并在运行时动态调用其 Debug 实现,而 impl_trait 只能返回固定的类型(在这里是字符串类型)。


当指针指向 trait 的时候,这个指针就不是一个普通的指针了,变成一个“胖指针”。请大家回忆一下前文所讲解的 DST 类型:数组类型[T]是一个 DST 类型,因为它的大小在编译阶段是不确定的,相对应的,&[T]类型就是一个“胖指针”,它不仅包含了指针指向数组的其中一个元素,同时包含一个长度信息。它的内部表示实质上是 Slice 类型。

同理,Bird 只是一个 trait 的名字,符合这个 trait 的具体类型可能有多种,这些类型并不具备同样的大小,因此使用 dyn Bird 来表示满足 Bird 约束的 DST 类型。指向 DST 的指针理所当然也应该是一个“胖指针”,它的名字就叫 trait object。比如Box<dyn Bird>,它的内部表示可以理解成下面这样:

rust
pub struct TraitObject {
    pub data: *mut (),
    pub vtable: *mut (),
}

它里面包含了两个成员,都是指向单元类型的裸指针。在这里声明的指针指向的类型并不重要,我们只需知道它里面包含了两个裸指针即可。由上可见,和 Slice 一样,Trait Object 除了包含一个指针之外,还带有另外一个“元数据”,它就是指向“虚函数表”的指针(🔔:虚函数表(Virtual Function Table,简称 vtable))。这里用的是裸指针,指向 unit 类型的指针*mut()实际上类似于 C 语言中的void*。我们来尝试一下使用 unsafe 代码,如果把它里面的数值当成整数拿出来会是什么结果:

rust
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);
}

执行结果为:

rust
Size of p_duck 8, Size of p_bird 16
Duck::fly 139997348684016
Swan::fly 139997348684320
TraitObject    [data:140733800916056, vtable:139997351089872]
data in vtable [139997348687008, 0, 1, 139997348684016]
TraitObject    [data:140733800915512, vtable:139997351089952]
data in vtable [139997348687008, 0, 1, 139997348684320]

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

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

Released under the MIT License