trait 对象的静态分发与动态分发

2024-05-15 21:00:58 浏览数 (2)

rust by example 是这么定义 trait 的 [1]:

Traits

A trait is a collection of methods defined for an unknown type: Self. They can access other methods declared in the same trait.

自然,我们就会需要传递“实现了某个 trait”的 struct 这种范型能力。在 rust 中,提供了 两种方式 来实现这种能力,先引入一个 trait 和两个 struct 用于讲解后面的内容。

代码语言:javascript复制
trait Run {
    fn run(&self);
}

struct Duck;

impl Run for Duck {
    fn run(&self) {
        println!("Duck is running");
    }
}

struct Dog;

impl Run for Dog {
    fn run(&self) {
        println!("Dog is running");
    }
}

静态分发和动态分发

首先引入分发 (dispatch):当代码涉及多态时,编译器需要某种机制去决定实际的调用关系。rust 提供了两种分发机制,分别是静态分发 (static dispatch) 和动态分发 (dynamic dispatch)。[2]

静态分发

静态分发其实就是编译期范型,所有静态分发在编译期间确定实际类型,Rustc 会通过单态化 (Monomorphization) 将泛型函数展开。

而静态分发有两种形式:

代码语言:javascript复制
fn get_runnable<T>(runnable: T) where T: Run {
    runnable.run();
}

fn get_runnable(runnable: impl Run) {
    runnable.run();
}

两者在调用时都能通过

代码语言:javascript复制
get_runnable(Dog {});

方式调用,区别在于前者可以使用 turbo-fish 语法(也就是 ::<> 操作符):

代码语言:javascript复制
get_runnable::<Dog>(Dog {});

动态分发

首先引入 trait对象(trait object) 的概念:trait 对象是指实现了某组 traits 的非具体类型值,这组 trait 一定包含一个 对象安全(object safe) 的基 trait,和一些 自动trait(auto trait)。

在 2021 版本后,要求 trait 对象一定需要 dyn 关键字标识,以和 trait 本身区分开来。对于某个 trait MyTrait,以下东西都是 trait 对象 [3]:

  • dyn MyTrait
  • dyn MyTrait Send
  • dyn MyTrait Send Sync
  • dyn MyTrait 'static
  • dyn MyTrait Send 'static
  • dyn MyTrait
  • dyn 'static MyTrait
  • dyn (MyTrait)

动态分发也就是运行时范型,虽然 trait 对象是 Dynamically Sized Types(DST, 也叫unsized types),意味着它的大小只有运行时可以确定,意味着 rustc 不会允许这样的代码通过编译:

代码语言:javascript复制
fn get_runnable(runnable: dyn Run) {
    runnable.run();
}

但是指向实现 trait 的 struct 的指针大小是一定的,因此可以把 trait 对象隐藏在指针后(如 &dyn TraitBox<dyn Trait>Rc<dyn Trait> 等),编译器编译时会默认对象实现了 trait,并在运行时动态加载调用的对应函数。

代码语言:javascript复制
fn get_runnable(runnable: &dyn Run) {
    runnable.run();
}

动态分发靠的就是指向 trait 对象的指针。

实现原理

静态分发

静态分发的实现原理比较简单,每多一种调用类型,rustc 就会生成多一个函数:

代码语言:javascript复制
fn get_runnable<T>(runnable: T) where T: Run {
    runnable.run();
}

fn main() {
    get_runnable::<Dog>(Dog {});
    get_runnable::<Duck>(Duck {});
}

通过编译后,get_runnable 函数会生成两种:

代码语言:javascript复制
fn get_runnable_for_dog(runnable: Dog) {
    runnable.run()
}

fn get_runnable_for_duck(runnable: Duck) {
    runnable.run()
}

rustc 会自动将类型与调用函数匹配。

显而易见的,通过静态分发实现的多态无运行时性能损耗,但是编译出的二进制文件大小增加。

动态分发

动态分发就略复杂了,实现的关键在指针,每个指向 trait 对象的指针包含:

  • 指向实现某个 trait 实例的指针
  • 虚拟函数列表 (virtual method table, 一般直接叫 vtable),包含
    • 某个 trait 和它父 trait 的所有函数
    • 指向这个实例对函数列表内函数的实现的指针

使用 trait 对象的目的是对方法的“延迟绑定(late binding)”,调用 trait 对象的某个方法最终在运行时才分发,也就是说调用时先从 vtable 中载入函数指针,再间接调用这个函数。对于 vtable 中每一个函数的实现,每个 trait 对象都可以不一样。

其实 rust 中 str 字符串类型和 [T] 数组类型都是 trait 对象。

对象安全

trait 对象一定要基于 对象安全 的 trait,这里不大谈特谈,只简单提及两个有趣的地方。

std::Sized

  • 当不希望 trait 被用为 trait 对象时,可以加上 Self: Sized 的约束
  • 当不希望某个函数出现在 trait 对象的 vtable 中,可以加上 where Self: Sized 的约束

trait 对象的可分发函数不能有类型(范型)参数,所以如果 trait 中存在范型参数,只能静态分发了

代码语言:javascript复制
trait Run {
 fn run<T>(&self, t: T);
}

Self 只能出现在方法的接受者(receiver)中,也就是方法的第一个参数,&self&mut self...

  1. https://doc.rust-lang.org/rust-by-example/trait.html ↩︎
  2. https://web.mit.edu/rust-lang_v1.25/arch/amd64_ubuntu1404/share/doc/rust/html/book/first-edition/trait-objects.html ↩︎
  3. https://doc.rust-lang.org/reference/types/trait-object.html ↩︎

0 人点赞