Appearance
23.1 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。
示例:
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_trait
和 impl_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 指向的虚函数表也不一样。