泛型与单态化:抽象不要钱
你可能有个根深蒂固的直觉:抽象是有代价的 —— 泛型、接口、多态,都比手写具体类型慢一点。在很多语言里这是真的。但 Rust 的泛型真的不要钱:一个泛型函数,编译器会为你用到的每种具体类型各印一份专门的机器码。跑起来和手写一样快,没有装箱,没有虚表。这一章拆开这台印刷机。
先看泛型长什么样
泛型就是「先不指定具体类型,留个占位 T,用的时候再填」。函数、结构体、枚举都能泛型化:
// 泛型函数:对任何能比大小的类型 T 都成立
fn biggest<T: PartialOrd>(a: T, b: T) -> T {
if a > b { a } else { b }
}
biggest(3, 7); // T = i32
biggest(2.5, 1.5); // T = f64
biggest('a', 'z'); // T = char
注意那个 T: PartialOrd —— 上一章讲的 trait 约束。它是泛型能成立的前提:函数体里用了 a > b,所以 T 必须「能比大小」(实现了 PartialOrd)。泛型提供「对哪些类型成立」,trait 约束提供「凭什么能对它们做这些操作」 —— 两者是一对搭档。
关键问题:这一个函数,运行时是怎么跑的
你写了一份 biggest,却用它比了整数、浮点、字符。运行时,机器执行的是同一段代码、靠什么在里面判断类型吗?不是。Rust 的答案是单态化(monomorphization):编译期,编译器看到你用了哪些具体类型,就为每一种,复制粘贴出一份专门的、写死了类型的函数。上面那三次调用,编译后二进制里实际上有三个函数:
// 你写的一份,被编译器印成了三份具体的(示意):
fn biggest_i32(a: i32, b: i32) -> i32 { if a > b { a } else { b } }
fn biggest_f64(a: f64, b: f64) -> f64 { if a > b { a } else { b } }
fn biggest_char(a: char, b: char) -> char { if a > b { a } else { b } }
下面这个 demo:上面是你写的一份泛型 biggest,下面是编译器根据「你实际用了哪些类型」印出来的具体副本。开关不同的调用点,看副本数量和统计随之变 —— 二进制里就是有这么多份,每份都是直接调用、能内联,运行时开销为 0。
盯着那个统计:源码里 1 个泛型函数,二进制里 N 份具体代码,运行时开销 0。因为每一份都是「类型写死」的普通函数 —— 编译器可以像对手写代码一样优化它、内联它,没有任何「运行时判断这是什么类型」的动作。这就是「零成本抽象」这个词的字面意思:你享受了泛型的表达力,却没为它付一分钱运行时。
代价在哪:世上没有免费午餐
单态化的成本没有消失,只是换了地方付 —— 从「运行时」挪到了「编译时和二进制体积」:
- 编译更慢。编译器要为每种类型组合生成、优化一份代码。这是 Rust 编译慢的主要原因之一。
- 二进制更大。用得越多、类型越杂,副本越多。一个被上百种类型实例化的泛型,会显著撑大产物(这叫「代码膨胀」)。
这就把上一章的选择接上了:如果你不想要这么多副本、或者需要在运行时才决定类型,就用 dyn Trait 动态分发 —— 一份代码 + 运行时查虚表,二进制小、但每次调用有点开销。单态化(泛型)省运行时、费体积;动态分发(dyn)省体积、费运行时。Rust 把这对权衡明明白白交到你手上,两条路都开着。
泛型 / impl Trait(单态化):编译期印很多份具体代码 → 跑得飞快,但编译慢、二进制大。默认选它。
dyn Trait(动态分发):一份代码 + 运行时虚表 → 二进制小、能混装不同类型,但每次调用有一跳。需要「一个集合装多种类型」或「运行时定类型」时选它。
回流
Java / Kotlin(类型擦除):JVM 的泛型是「假的」—— 编译后 List<String> 和 List<Integer> 都变成同一个 List(类型信息被擦除了)。好处是只有一份代码、不膨胀;坏处是基本类型要装箱(List<int> 不行,得 List<Integer>,每个数字都变成堆上的对象),运行时也拿不到类型(T.class 不行)。这正是 Rust 单态化的反面:Java 省体积、费性能(装箱);Rust 省性能、费体积。
Go:早期 Go 没泛型,你要么用 interface{}(装箱 + 运行时断言,慢且不安全),要么复制粘贴代码。1.18 加了泛型,实现是「单态化 + 字典」的混合,介于两者之间。Rust 从第一天就是彻底的单态化。
C++ 模板:和 Rust 单态化最像 —— 模板也是「为每种类型实例化一份」,所以 C++ 泛型也零成本、也一样会导致代码膨胀和编译变慢。区别是 Rust 的 trait 约束让泛型的错误在定义处就报清楚,而 C++ 模板的报错以「看不懂」闻名(因为约束是隐式的,错误要到实例化才炸)。Rust 相当于 C++ 模板 + 明确的约束。
这一章的一句话
Rust 泛型靠单态化实现:编译器为你用到的每种类型各印一份具体代码,所以抽象零运行时成本 —— 代价是编译更慢、二进制更大。要省体积或运行时定类型,才换用 dyn 动态分发。
卷 III 收工。你现在手里有了 Rust 的两样超能力:所有权(卷 II,安全)和类型系统(卷 III,表达力 + 零成本抽象)。但真实世界总有些情况,「一个所有者」这条铁律会显得太紧 —— 一个值真的需要被好几个地方共同持有、还得能改。下一卷,我们诚实地打开那几个「逃生口」。