喜欢的话别忘了点赞、收藏加关注哦(加关注即可阅读全文),对接下来的教程有兴趣的可以关注专栏。谢谢喵!(=・ω・=)
1.15.1. 静态分发(static dispatch)
编译泛型代码或者调用dyn Trait(详见【Rust自学】17.2.3. trait对象执行的是动态派发)上的方法时发生了什么?
编译器会针对每个T
(每个类型),都将类型或函数复制一部分(每个类型都有自己的函数),这个过程叫单态化(monomorphization)。详见【Rust自学】10.2.6. 泛型代码的性能。
当你构建Vec<i32>
或HashMap<String, bool>
时,编译器会复制它的泛型类型以及所有的实现块。例如Vec<i32>
就是把Vec<T>
的T
替换成i32
,对Vec
做了一个完整的复制,所有遇到的T
都换成i32
。
也就是说编译器会把实例的泛型参数使用具体类型替换。需要注意的是,编译器其实并不会做完整的复制粘贴,他只复制你用的代码。
看个例子:
impl String {
pub fn contains(&self, p:impl Pattern) -> bool {
p.is_contained_in(self);
}
}
- 这个例子针对
String
类型实现了一个contains
方法 contains
方法的第二个参数p
的trait约束是Pattern
。p
没有实际类型,只有trait约束,所以p
就相当于泛型参数
p
在实际使用时可能会是不同的类型。针对不同的类型,该方法都会复制一遍,因为我们需要知道is_contained_in
方法的地址,以便进行调用。CPU需要知道在哪跳转和继续执行。
对于任何给定的p
,编译器知道那个地址的类型是实现了Pattern
trait方法的。不存在一个可给任意类型的通用地址。
PS:我知道你在想Python的动态类型,Python变量本质上是对象的引用,而并非直接存储值。
正是因为如此,编译器需要为每个类型复制一个(方法体),每份都有自己的地址来用来跳转。这就是所谓的静态分配(static dispatch),因为对于方法的任何给定副本,我们“分派到”的地址都是静态已知的。
- 静态(static) 在编程中通常指编译中已知的事物(或可被视为此的)
1.15.2. 单态化(monomorphization)
单态化(monomorphization)指的是从一个泛型类型到多个非泛型类型的过程。Rust的trait就有这个特点。
当编译器优化完代码后,就好像根本没有泛型。每个实例都是单独优化的,具有了所有的已知类型,所以上文例子里的is_contained_in
方法调用的执行效率就如同trait不存在一样,没有任何性能损失。
编译器对涉及的类型完全掌握,(在合适的情况下)甚至可以将它们进行inline实现。
- “对涉及的类型完全掌握”指的是Rust是静态类型的语言,在编译时就能够确定所有变量和函数的类型,不需要在运行时进行类型推导。
- “将它们进行inline实现”指的是将函数的实现直接展开到调用的地方,避免函数调用的开销。
#[inline(always)]
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let x = add(2, 3); // 可能被编译器优化为 let x = 2 + 3;
}
1.15.3. 单态化的代价
- 所有实例都需要单独编译,编译时间会因它而增加(如果不能优化编译)
- 每个单态化的函数会有自己的一段机器码,让程序更大
- 指令在泛型方法的不同实例间无法共享,CPU的指令缓存效率降低,因为它需要持有相同指令的多个不同副本
1.15.4. 动态分发(dynamic dispatch)
动态分发(dynamic dispatch)使代码可以调用泛型类型上的trait方法,而无需知道具体的类型。
上面的代码例稍作修改即可实现动态分发:
impl String {
pub fn contains(&self, p:&impl Pattern) -> bool {
p.is_contained_in(&*self);
}
}
这个例子中,实现动态分发需要调用者提供两个信息:
Pattern
的地址is_contained_in
的地址
为什么impl Pattern
前要加&
?
- 动态分发依赖于trait 对象,而trait对象本质上是一个宽指针(fat pointer,上一篇文章有讲),所以说传入的数据得是一个引用(因为Rust不能确定动态分发类型的内存大小,所以只能用引用)
1.15.5. vtable
实际上,调用者会提供指向一块内存的指针,它叫做虚方法表(virtual method table,简称vtable)。
上例中,它持有该类型中所有的trait方法实现的地址,其中一个就是is_contained_in
这个方法的地址。
当代码想要调用提供类型的一个trait方法时,就会从vtable查询is_contained_in
方法的实现地址并调用。这就允许我们使用相同的函数体,而不关心调用者想要使用的类型。
每个vtable还包含具体类型的布局和对齐信息(总是需要这些信息配合使用)。
1.15.6. 对象安全(Object-Safe)
类型实现了一个trait和它的vtable的组合就形成了一个trait object(trait对象)。
大部分trait可转为trait object,但不是所有。例如Clone
trait就不行(它的clone
方法返回self
),Extend
trait也不行。这些例子就不是对象安全的(object-safe)。
对象安全的具体要求是:
- trait的所有方法都不能是泛型的,也不可以使用
self
- trait不可以拥有静态方法,因为无法知道在那个实例上调用的方法
1.15.7. self: Sized
self: Sized
意味着self
无法用于trait object(因为它是!Sized
)。
将self: Sized
用在某个trait,就是要求永远不使用动态分发。
我们也可以将self: Sized
用在特定方法上,这时当trait通过trait object访问的时候,该方法就不可用了。
当检查trait对象是否安全的时候,使用了where self: Sized
的方法就会免除
1.15.8. 动态分发的优缺点
优点 | 缺点 |
---|---|
编译时间减少 | 编译器无法对特定类型优化 |
提升 CPU 指令缓存效率 | 只能通过 vtable 调用函数 |
直接调用方法的开销增加 | |
trait object 上的每次方法调用都需要查 vtable |
1.15.9. 如何在静态分发和动态分发间选择
静态分发 | 动态分发 |
---|---|
在library中使用静态分发 | 在binary中使用动态分发 |
无法知道用户的需求 | binary 是最终代码 |
如果使用动态分发,用户也只能如此 | 动态分发使代码更整洁(省去了泛型参数) |
如果使用静态分发,用户可自行选择 | 编译更快 |
以边际性能为代价 |