Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

1.15 Trait bounds(Trait 约束)的编译与分派

1.15.1. 静态分发(static dispatch)

编译泛型代码时发生了什么?

编译器会针对每个T(每个类型),都将类型或函数复制一部分(每个类型都有自己的函数),这个过程叫单态化(monomorphization)。详见【Rust指南】10.2.6. 泛型代码的性能。(调用dyn Trait上的方法则不同:那是动态分发,见本文后面部分;详见【Rust指南】17.2.3. trait对象执行的是动态派发。)

当你构建Vec<i32>HashMap<String, bool>时,编译器会复制它的泛型类型以及所有的实现块。例如Vec<i32>就是把Vec<T>T替换成i32,对Vec做了一个完整的复制,所有遇到的T都换成i32

也就是说编译器会把实例的泛型参数使用具体类型替换。需要注意的是,编译器其实并不会做完整的复制粘贴,他只复制你用的代码。

看个例子:

#![allow(unused)]
fn main() {
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方法,而无需知道具体的类型。

上面的代码例稍作修改即可实现动态分发:

#![allow(unused)]
fn main() {
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 是最终代码
如果使用动态分发,用户也只能如此动态分发使代码更整洁(省去了泛型参数)
如果使用静态分发,用户可自行选择编译更快
以边际性能为代价