Rust 中的 trait 对象

2,245 阅读6分钟

在上一节中有一段代码无法通过编译:

fn returns_summarizable(switch: bool) -> impl Summary {  
    if switch {
        Tweet { ... }  // 此处不能返回两个不同的类型
    } else {
        Post { ... }  // 此处不能返回两个不同的类型
    }
}

其中 Post 和 Tweet 都实现了 Summary trait,因此上面的函数试图通过返回 impl Summary 来返回这两个类型,但是编译器却报错了,原因是 impl Trait 的返回值类型并不支持多种不同的类型返回,那如果我们想返回多种类型,该怎么办?这时候就需要用到 trait 对象。

trait 对象的定义

trait 是一种约束,而不是具体类型,它属于DST(类似于 str),其 size 无法在编译阶段确定,只能通过指针来间接访问,可以通过 &Trait 引用或者 Box<Trait> 智能指针的方式来使用。

Rust 中用一个胖指针(除了一个指针外,还包含另外一些信息 ),来表示 一个指向 trait 的引用 (类似于 str&str),即 trait object,分别指向 data 与 vtable。简单讲,trait object 包含 data 指针和 vtable 指针。

注:什么是 Trait Object 呢?指向 trait 的指针就是Trait Object。假如 Summary 是一个 trait 的名称,那么 &SummaryBox<Summary> 都是一种 Trait Object,是“胖指针“。

trait object.jpeg

trait object reference

pub struct TraitObjectReference { // trait object 胖指针
    pub data: *mut (), // data 指针
    pub vtable: *mut (), // vtable 指针
}

// vtable 本质上是一个结构体指针
struct Vtable {
    destructor: fn(*mut ()),
    size: usize,
    alignment: usize,
    fn_1: fn(*const ()),
    fn_2: fn(*const ()),
    fn3_n: fn(*const ()),
}

注:每个 vtable 中的 destructor 字段都指向一个函数,该函数将清理 vtable 类型的任何资源。size 和 align 字段存储了被清除类型的大小,以及它的对齐要求。destructorsizealignment 这三个字段是每个 vtable 都共有的类型。fn_1 到 fn_n就是 在trait 中定义的方法。比如 trait_object.fn_1()这样的方法调用将从 vtable 中检索出正确的指针,然后对其进行动态调用。

vtable 是 virtual method table 的缩写。 本质上是结构体指针的结构,指向具体实现中每种方法的一段机器代码。在 Rust 中,当使用多态性时,编译器会自动为每个带有虚函数的类型创建一个虚表。在运行时,这个虚表会被动态分配内存,并用于存储虚函数的地址。虚拟表只在编译时生成一次,由同类型的所有对象共享。

trait  object3.png

多 trait 时 vtable 示意图

当调用一个 trait object 的方法时,rust 会自动使用虚拟方法表,以确定调用哪个方法的实现。

dyn 关键字

dyn 关键字只用在 trait 对象的类型声明上,常见形式有 Box<dyn trait> &dyn trait等。

以 Shape trait 为例:

use std::f64::consts::PI;

// 圆形
struct Circle {
    radius: f64,
}

// 正方形
struct Square { 
    side: f64
}

/// 声明一个图形 shape trait
trait Shape { 
    fn area(&self) -> f64;
}

impl Circle {  
   fn new(radius: f64) -> Self {  
    Circle { radius }
   }  
}

/// 为 Circle 实现 Shape
impl Shape for Circle {
    fn area(&self) -> f64 {
        PI * self.radius * self.radius
    }
}

/// 为 Square 实现 Shape
impl Shape for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

/// 计算 trait 对象列表面积之和
fn get_total_area(shapes: Vec<Box<dyn Shape>>) -> f64 {
    shapes.into_iter().map(|s| s.area()).sum()
}

fn main() {
    let circle = Circle::new(5.0); // 实现了Shape trait 的结构体
    let shape: &dyn Shape = &circle; // &circle 是 trait 对象,用&dyn Shape申明
    println!("shape => {}", shape.area());
   
    // 特性对象也允许我们在集合中存储不同类型的值:
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 1.0 }), // Box<Circle> cast to Box<dyn Shape>
        Box::new(Square { side: 1.0 }), // Box<Square> cast to Box<dyn Shape>
    ];
    assert_eq!(PI + 1.0, get_total_area(shapes)); // ✅

}

trait 对象执行动态分发

Rust可以同时支持“静态分派(static dispatch)”和“动态分派(dynamic dispatch)”。

静态分发:指的是具体调用哪个函数,在编译阶段就确定下来了。Rust中的“静态分派”靠泛型(impl Trait是泛型的语法糖)来完成。对于不同的泛型类型参数,编译器会执行的单态化处理,为每一个被泛型类型参数代替的具体类型生成不同版本的函数和方法,在编译阶段就确定好了应该调用哪个函数。

动态分派:指的是具体调用哪个函数,在执行阶段才能确定。Rust中的“动态分派”靠 Trait Object 来完成。Trait Object 本质上是指针,它可以指向不同的类型,指向的具体类型不同,调用的方法也就不同。

例如:

trait Fly {
    fn fly(&self);
}

fn static_fly(fly: impl Fly) { // 静态分发,trait限定,执行单态化,编译阶段生成不同函数
    fly.fly()
}

fn static_fly1<T: Fly>(fly: T) { // 静态分发,泛型约束,执行单态化,,编译阶段生成不同函数
    fly.fly()
}

fn dynamic_fly(fly: &dyn Fly) { // 动态分发,需要使用关键字 dyn,执行阶段才确定具体实现类型
    fly.fly()
}

当使用 trait 对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于 trait 对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust 在运行时使用 trait 对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。

下面这张图很好的解释了静态分发 Box<T> 和动态分发 Box<dyn Trait> 的区别:

image.png

trait 对象需要类型安全

只有对象安全(object-safe)的 trait 可以实现为特征对象。这里有一些复杂的规则来实现 trait 的对象安全,但在实践中,如果一个 trait 中定义的都符合以下规则,则该 trait 是对象安全的:

  • 方法的返回类型不能是 Self
  • 方法没有任何泛型参数
  • Trait 不能Sized约束。

这主要因为把一个对象转为 trait object 后,原始类型信息就丢失了,所以这里的 Self 也就无法确定了,那么该方法将不能使用原本的类型。当 trait 使用具体类型填充的泛型类型时也一样:具体类型成为实现 trait 的对象的一部分,当使用 trait 对象时其具体类型被抹去了,无法知道应该用什么类型来填充泛型类型。

一个非对象安全的 trait 例子是标准库中的 Clone trait。Clone trait 中的 clone 方法的声明如下:

pub trait Clone {
    fn clone(&self) -> Self;
}

使用Box <dyn Clone>&dyn Clone 会报错

fn dynamic_clone(text: &dyn Clone) { // 报错: `Clone` cannot be made into an object
   text.clone()  
}

总结

如果说泛型给了我们编译时的多态性,那么 trait 对象就给了我们运行时的多态性。通过 trait 对象,我们可以允许函数在运行时动态地返回不同的类型。trait 对象的结构体大小是未知的,所以必须要通过指针来引用它们。具体类型与 trait 对象在字面上的区别在于,trait 对象必须要用 dyn 关键字来修饰前缀。

参考