Appearance
23.1 trait object
什么是 trait object 呢? Trait object 是一种允许我们在运行时使用不同具体类型的实例的方式,前提是这些类型都实现了某个共同的 trait。 它通常通过指向 trait 的指针(如 &dyn Trait
或 Box<dyn Trait>
)来实现动态分派。 dyn Trait
本身是一个动态大小类型(DST),意味着它的大小在编译时无法确定。因此,我们不能直接在栈上创建 dyn Trait
类型的变量,而是需要通过指针来间接操作。 例如,假如 Bird
是一个 trait 的名称,那么 dyn Bird
就是一个 DST。而 &dyn Bird
、&mut dyn Bird
、Box<dyn Bird>
、Rc<dyn Bird>
等包含 dyn Bird
的指针类型,就是我们常说的 Trait Object。
示例:
rust
trait Shape {
fn area(&self) -> f64;
fn describe(&self) -> String;
}
struct Circle {
radius: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
fn describe(&self) -> String {
format!("a circle with radius {}", self.radius)
}
}
struct Square {
side: f64,
}
impl Shape for Square {
fn area(&self) -> f64 {
self.side * self.side
}
fn describe(&self) -> String {
format!("a square with side {}", self.side)
}
}
// dyn Trait: 可以返回实现了 Shape trait 的不同具体类型
fn get_random_shape(is_circle: bool) -> Box<dyn Shape> {
if is_circle {
Box::new(Circle { radius: 1.0 })
} else {
Box::new(Square { side: 2.0 })
}
}
// impl Trait: 必须返回一个单一的具体类型(尽管对调用者来说是不透明的)
// 以下函数总是返回 Circle 类型
fn get_a_circle() -> impl Shape {
Circle { radius: 3.0 }
}
// 以下函数根据条件返回不同大小的 Square,但始终是 Square 类型
fn get_a_transformed_square(make_large: bool) -> impl Shape {
if make_large {
Square { side: 5.0 }
} else {
Square { side: 1.0 }
}
}
fn main() {
let shape1: Box<dyn Shape> = get_random_shape(true); // shape1 内部是 Box<Circle>
let shape2: Box<dyn Shape> = get_random_shape(false); // shape2 内部是 Box<Square>
println!("Shape 1 is {}, area: {:.2}", shape1.describe(), shape1.area());
println!("Shape 2 is {}, area: {:.2}", shape2.describe(), shape2.area());
// 使用 dyn Trait 存储异构集合
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 2.5 }),
Box::new(Square { side: 3.5 }),
get_random_shape(true), // 可能是 Circle
get_random_shape(false), // 可能是 Square
];
println!("\\nShapes in a vector (using dyn Trait):");
for shape in shapes {
println!(" - {}, area: {:.2}", shape.describe(), shape.area());
}
// 使用 impl Trait
let specific_circle = get_a_circle(); // 类型是 impl Shape,实际是 Circle
println!("\\nSpecific circle (using impl Trait) is {}, area: {:.2}", specific_circle.describe(), specific_circle.area());
let small_square = get_a_transformed_square(false); // 类型是 impl Shape,实际是 Square
println!("Transformed square (small) is {}, area: {:.2}", small_square.describe(), small_square.area());
let large_square = get_a_transformed_square(true); // 类型是 impl Shape,实际是 Square
println!("Transformed square (large) is {}, area: {:.2}", large_square.describe(), large_square.area());
// 下面的代码会导致编译错误,因为 impl Trait 函数的所有返回路径必须是相同的具体类型
// fn get_problematic_shape(is_circle: bool) -> impl Shape {
// if is_circle {
// Circle { radius: 1.0 } // 返回 Circle
// } else {
// Square { side: 2.0 } // 返回 Square - 错误!
// }
// }
}
示例代码中,我们定义了 Shape
trait 和两个实现它的结构体 Circle
和 Square
。然后我们有几个函数来演示 dyn Trait
和 impl Trait
的用法。
使用 dyn Trait
:动态返回与异构集合
函数 get_random_shape
返回一个 Box<dyn Shape>
。这意味着该函数可以返回任何实现了 Shape
trait 的具体类型的实例,并将其包装在 Box
中。在示例中,根据传入的布尔值,它可能返回一个 Circle
或一个 Square
。这种在运行时确定具体类型的能力是动态分派的核心。
此外,main
函数中还演示了如何创建一个 Vec<Box<dyn Shape>>
。这是一个可以存储不同类型形状(只要它们都实现了 Shape
trait)的异构集合。当我们遍历这个 vector 并调用如 describe()
或 area()
等方法时,实际调用的方法会根据每个元素在运行时的具体类型来确定。
使用 impl Trait
:静态但匿名的返回类型
函数 get_a_circle
和 get_a_transformed_square
返回 impl Shape
。这表明这些函数会返回某个实现了 Shape
trait 的具体类型,但调用者在编译时只知道它实现了 Shape
,而不知道其确切的具体类型(该类型对调用者是“不透明的”)。
关键在于,对于一个给定的 impl Trait
返回类型的函数,它必须总是返回 同一个 具体类型。 例如,get_a_circle
总是返回 Circle
。get_a_transformed_square
函数虽然内部有条件逻辑(基于 make_large
参数),但它的所有执行路径都返回 Square
类型(只是实例的 side
属性可能不同)。如果一个函数尝试在不同的路径返回不同的具体类型(例如,一个分支返回 Circle
,另一个分支返回 Square
,如 main
函数中注释掉的 get_problematic_shape
示例所示),则会导致编译错误。
这种方式允许库作者隐藏返回类型的具体细节,同时编译器仍然可以在编译时进行类型检查和优化(如通过单态化,monomorphization),因为返回的具体类型对编译器是已知的。这是静态分派的一种体现(对于函数体内部的调用而言,返回给调用者的是一个不透明类型)。
核心区别与选择
dyn Trait
:- 允许多态,即在运行时确定具体类型。
- 适用于需要存储异构集合(例如
Vec<Box<dyn MyTrait>>
)的场景。 - 涉及胖指针和虚函数表(vtable)查找,有轻微的运行时开销。
impl Trait
:- 返回类型是某个确定的、但对调用者不透明的具体类型。
- 编译器在编译期进行类型检查和单态化(monomorphization),通常性能更好。
- 不适用于需要存储不同具体类型的集合。
Trait Object 的限制(对象安全):
并非所有 trait 都能形成 trait object。一个 trait 必须是“对象安全”(Object Safe)的,才能用于 dyn Trait
。对象安全的规则确保了方法能够通过 trait object 被正确调用。主要规则通常包括:
- 方法不能返回
Self
类型。 - 方法不能有泛型参数。
- 方法的接收者不能是
Self
( 特指当Self: Sized
被约束时,例如fn foo(self)
默认self
是Self
类型且Self: Sized
),或者更准确地说,方法的接收者类型不能依赖于Self
以一种不满足对象安全的方式。
如果一个 trait 不符合这些规则(例如,Clone
trait 中的 clone
方法返回 Self
,所以 Clone
不是对象安全的),编译器会阻止你创建它的 trait object。具体的对象安全规则比较细致,但其核心目标是保证动态分派的可行性。
当指针指向 trait 的时候,这个指针就不是一个普通的指针了,变成一个“胖指针”。 请大家回忆一下前文所讲解的 DST 类型:数组类型[T]
是一个 DST 类型,因为它的大小在编译阶段是不确定的,相对应的,&[T]
类型就是一个“胖指针”,它不仅包含了指针指向数组的其中一个元素,同时包含一个长度信息。它的内部表示实质上是 Slice 类型。
同理,Bird
只是一个 trait 的名字,符合这个 trait 的具体类型可能有多种,这些类型并不具备同样的大小,因此使用 dyn Bird
来表示满足 Bird
约束的 DST 类型。指向 DST 的指针理所当然也应该是一个“胖指针”,它的名字就叫 trait object。比如Box<dyn Bird>
,它的内部表示可以理解成下面这样:
rust
// 仅为示意,并非真实源码结构
pub struct TraitObjectRepresentation {
pub data: *mut (), // 指向实际数据的指针
pub vtable: *mut (), // 指向虚函数表 (vtable) 的指针
}
它里面包含了两个成员,都是指向单元类型的裸指针。在这里声明的指针指向的类型并不重要,我们只需知道它里面包含了两个裸指针即可。由上可见,和 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 {
// 打印出指针 vtable 指向的内存区间的值
// vtable 通常包含:
// 1. 指向析构函数的指针 (drop_in_place)
// 2. 实例的大小 (size_of_val)
// 3. 实例的对齐方式 (align_of_val)
// 4. 依次是 trait 中各个方法的指针
// 具体顺序和内容可能因编译器版本和 trait 定义
println!("data in vtable (first few entries): [destructor_ptr: {}, size: {}, align: {}, method1_ptr: {}]",
*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);
}
执行结果为:
Size of p_duck 8, Size of p_bird 16
Duck::fly 139997348684016
Swan::fly 139997348684320
TraitObject [data:140733800916056, vtable:139997351089872]
data in vtable (first few entries): [destructor_ptr: 139997348687008, size: 0, align: 1, method1_ptr: 139997348684016]
TraitObject [data:140733800915512, vtable:139997351089952]
data in vtable (first few entries): [destructor_ptr: 139997348687008, size: 0, align: 1, method1_ptr: 139997348684320]
我们可以看到,直接针对对象取指针,得到的是普通指针,它占据 64 bit 的空间(在 64 位系统上)。 如果我们把这个指针使用 as
运算符转换为 trait object,它就成了胖指针,占据 128 bit(16 字节),携带了额外的信息。这个额外信息很重要,因为我们还需要使用这个指针调用函数。如果指向 trait 的指针只包含了对象的地址,那么它就没办法实现针对不同的具体类型调用不同的函数了。 所以,它不仅要包含一个指向真实对象的指针(data
),还要有一个指向所谓的“虚函数表”(vtable)的指针。 我们把虚函数表里面的内容打印出来可以看到,其中包含了我们需要被调用的具体函数的地址(示例中的 method1_ptr
对应 fly
方法)。除了方法指针,vtable 通常还包含指向析构函数、类型大小和对齐方式等元数据。除了方法指针,vtable 通常还包含指向析构函数、类型大小和对齐方式等元数据。
从这里的分析结果可以看到,Rust 的动态分派和 C++ 的动态分派,内存布局有所不同。在 C++ 里,如果一个类型里面有虚函数,那么每一个这种类型的变量内部都包含一个指向虚函数表的地址。 而在 Rust 里面,对象本身不包含指向虚函数表的指针,这个指针是存在于 trait object 指针里面的。如果一个类型实现了多个 trait,那么不同的 trait object 指向的虚函数表也不一样。