【Rust中级教程】1.15. Trait bounds(Trait 约束)的编译与分派

发布于:2025-02-22 ⋅ 阅读:(16) ⋅ 点赞:(0)

喜欢的话别忘了点赞、收藏加关注哦(加关注即可阅读全文),对接下来的教程有兴趣的可以关注专栏。谢谢喵!(=・ω・=)
请添加图片描述

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约束是Patternp没有实际类型,只有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 是最终代码
如果使用动态分发,用户也只能如此 动态分发使代码更整洁(省去了泛型参数)
如果使用静态分发,用户可自行选择 编译更快
以边际性能为代价