Skip to content

23.1 trait object

什么是 trait object 呢? Trait object 是一种允许我们在运行时使用不同具体类型的实例的方式,前提是这些类型都实现了某个共同的 trait。 它通常通过指向 trait 的指针(如 &dyn TraitBox<dyn Trait>)来实现动态分派。 dyn Trait 本身是一个动态大小类型(DST),意味着它的大小在编译时无法确定。因此,我们不能直接在栈上创建 dyn Trait 类型的变量,而是需要通过指针来间接操作。 例如,假如 Bird 是一个 trait 的名称,那么 dyn Bird 就是一个 DST。而 &dyn Bird&mut dyn BirdBox<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 和两个实现它的结构体 CircleSquare。然后我们有几个函数来演示 dyn Traitimpl 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_circleget_a_transformed_square 返回 impl Shape。这表明这些函数会返回某个实现了 Shape trait 的具体类型,但调用者在编译时只知道它实现了 Shape,而不知道其确切的具体类型(该类型对调用者是“不透明的”)。

关键在于,对于一个给定的 impl Trait 返回类型的函数,它必须总是返回 同一个 具体类型。 例如,get_a_circle 总是返回 Circleget_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) 默认 selfSelf 类型且 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 指向的虚函数表也不一样。

Released under the MIT License