trait 对象的静态分发与动态分发
rust by example 是这么定义 trait 的 [1]:
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 用于讲解后面的内容。
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) 将泛型函数展开。
而静态分发有两种形式:
fn get_runnable<T>(runnable: T) where T: Run {
runnable.run();
}
fn get_runnable(runnable: impl Run) {
runnable.run();
}
两者在调用时都能通过
get_runnable(Dog {});
方式调用,区别在于前者可以使用 turbo-fish
语法(也就是 ::<>
操作符):
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 不会允许这样的代码通过编译:
fn get_runnable(runnable: dyn Run) {
runnable.run();
}
但是指向实现 trait 的 struct 的指针大小是一定的,因此可以把 trait 对象隐藏在指针后(如 &dyn Trait
,Box<dyn Trait>
,Rc<dyn Trait>
等),编译器编译时会默认对象实现了 trait,并在运行时动态加载调用的对应函数。
fn get_runnable(runnable: &dyn Run) {
runnable.run();
}
动态分发靠的就是指向 trait 对象的指针。
实现原理
静态分发
静态分发的实现原理比较简单,每多一种调用类型,rustc 就会生成多一个函数:
fn get_runnable<T>(runnable: T) where T: Run {
runnable.run();
}
fn main() {
get_runnable::<Dog>(Dog {});
get_runnable::<Duck>(Duck {});
}
通过编译后,get_runnable
函数会生成两种:
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 对象时,可以加上
-
trait 对象的可分发函数不能有类型(范型)参数,所以如果 trait 中存在范型参数,只能静态分发了
trait Run { fn run<T>(&self, t: T); }
-
Self
只能出现在方法的接受者(receiver)中,也就是方法的第一个参数,&self
、&mut self
...