上一篇文章收尾时提到,Option<T>、Result<T, E> 以及 Vec<T> 内部依赖的是同一套语言机制。本篇把这件事彻底讲明白:泛型究竟如何声明、Rust 为什么能让抽象不产生运行时开销,以及当你写下 fn largest<T> 时,编译器为什么会直接报错。

从两个只差类型的函数说起

先写一个挑选最大值的函数。如果字段是 i32,代码如下:

fn largest_i32(list: &[i32]) -> &i32 { let mut largest = &list[0]; for item in list { if item > largest { largest = item; } } largest }

随后给出的 char 版本,函数体内的逻辑一个字都没有变化,区别只体现在签名里的类型上。这与上一章中所说的「两个仅相差一个值的函数」属于同一类重复——当时的处理方式是把值抽象为参数,这次需要抽取的则是类型本身。

Rust 的解决方式是在函数名后追加尖括号:

fn largest<T>(list: &[T]) -> &T { [...] }

这行代码可以读作:largest 这个函数是围绕某个类型 T 展开的。T 只是约定俗成的符号,取自 type 的首字母,改成别的名字也完全可行。类型参数与值参数一样,遵循先声明后使用的规则,而声明的位置就夹在函数名与参数列表中间。

再看结构体。写 struct Point<T> { x: T, y: T } 时,需要留意 x 与 y 被约束为同一种 T。假若希望两个字段各自持有不同类型,则要给出两个参数:struct Point<T, U> { x: T, y: U }。对于 Point { x: 5, y: 4.0 },前者无法通过编译,后者则可以。

枚举的用法其实读者早已熟悉:enum Option<T> { Some(T), None } 与 enum Result<T, E> { Ok(T), Err(E) }。理解这两行也就不再困难——Option 持有单个类型参数,Result 则持有两个。前文提到 File::open 返回的 Result<File, io::Error>,相当于把 T 替换为 std::fs::File、把 E 替换为 std::io::Error。

为泛型类型编写方法时,impl 后面同样需要声明参数:impl<T> Point<T> { fn x(&self) -> &T { &self.x } }。必须写成 impl<T> Point<T>,而不能写成 impl Point<T>,否则编译器会把 T 误认为一个已经确定的具体类型。这则方法的定义针对的是「任意 T 对应的 Point」。

另一种写法是只针对某个确定类型提供方法:impl Point<f32> { fn distance_from_origin(&self) -> f32 { (self.x.powi(2) + self.y.powi(2)).sqrt() } }。也就是说,只有 Point<f32> 才具备这项计算能力,Point<i32> 没有。原因在于开平方这一运算仅仅适用于浮点数。这种带有条件的实现方式,等到下一篇讲 trait 时会变成频次很高的常见操作。

那泛型版 largest 为什么会被编译器拒绝

前面给出的泛型 largest 一旦编译就会失败,输出内容为:

error[E0369]: binary operation `>` cannot be applied to type `&T`

help: consider restricting type parameter `T` with trait `PartialOrd`

1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {

| ++++++++++++++++++++++

背后的原因,正是理解泛型最重要的地方。T 相当于预留给你日后填入的一块空缺。编译器解析函数体期间,还无从得知 T 究竟是谁,但它必须确认 item > largest 这一判断对任意 T 都能成立。结果是否定的——T 有可能是任何类型,自然也包括那些根本不支持比较大小的类型。

这个问题并非 Rust 所特有。在 Java 中写下 <T> T max(List<T> list),随后直接使用 a > b,同样不会通过编译,需要先声明 <T extends Comparable<T>>。也就是说,类型参数支持哪些操作,必须由声明方明确写出,编译器不会替你做判断。只有先把能力声明清楚,相关操作才有使用的可能。

单态化:那个空缺早在编译期就被补上了

接下来正面回答那个关键问题:程序运行时到底如何分辨 T 是 i32 还是 char?答案在于运行时压根不需要分辨,原因是一经编译,T 就不复存在了。

Rust 采用的方案称为单态化(monomorphization)。The Rust Book 对此的原话是:使用泛型不会让你的程序比用具体类型更慢;编译器通过单态化做到这一点——把泛型代码转换成具体代码,方法是填入编译时用到的具体类型。整个流程可以这样理解:编译器会扫描所有调用泛型代码的位置,统计这些代码被哪些类型调用过,再针对每一种类型分别产出一份专门的实现。

Book 中举的例子非常直观。当写下:

let integer = Some(5); let float = Some(5.0);

编译器读到这里,发现 Option 中先后装过 i32 和 f64,于是将那一份泛型 Option 定义展开为两份具体定义,一份面向 i32,另一份面向 f64,并据此替换掉原有的泛型定义。最终运行的程序中既不包含「先查一下当前是哪种 Option」这样的动作,也不存在间接跳转,只有针对具体函数的直接调用。

Book 给出的结论值得原文引用:“因为 Rust 把泛型代码编译成在每个实例中都指定了类型的代码,所以使用泛型不付运行时代价。代码运行时,它的表现和手写复制每一份定义完全一样。”

这段话强调的重点并不是「泛型速度很快」,而是:泛型跑起来之后,与你不做抽象、老老实实复制粘贴写出两份代码,所产生的机器码是彼此等价的。这正是零开销抽象四个字的准确含义。

对照 Java 与 Python,看三种记账方式

Java 的泛型同样对外宣称没有运行时开销。Oracle 教程的原话是:「类型擦除确保不会为参数化类型创建新类;因此泛型不产生运行时开销。」两种语言都把运行时开销压到了零,可所走的路径完全相反,而这个差异直接决定了两边究竟能写出什么样的代码。

Java 采用的手法叫擦除:编译器会把全部类型参数替换成它的上界(若未指定上界就替换成 Object),在必要位置上插入强制转换,同时生成桥接方法来维持多态能力。因此 List<String> 与 List<Integer> 在运行阶段其实是同一个类,类型信息只存在于一瞬间的编译期。Oracle 也明确解释了选择这条路的动机:这样做的最大好处是泛型代码能与「未参数化的旧代码」完全互通,其实是为了与 2004 年之前写就的库保持二进制兼容。

代价体现在那份「泛型限制」清单列出的一连串禁令上:

其一,基本类型不能充当类型参数。Pair<int, char> 会直接编译失败,必须写作 Pair<Integer, Character>,而编译器会自动完成装箱,调用的是 Integer.valueOf(8)。

其二,不能执行 new T()。

其三,无法对参数化类型使用 instanceof 或做强制转换。运行阶段根本不保存类型参数,instanceof List<String> 无从验证,最多只能写成 instanceof List<?>。

Rust ho走的则是完全相反的方向:不是擦除,而是复制。Vec<i32> 与 Vec<String> 在运行时属于两个不同的类型,各自配备专属实现,其间既没有装箱,也没有强制转换,更不存在类型查询。正因如此,你可以为 impl Point<f32> 单独定义方法,也能直接构造出具体的值——对于每个具体类型而言,类型信息自始至终都在,只是无需在运行时再去查找。

Rust 当然也有代价,Rust 编译器开发指南对此的表述很直接:单态化「得到的是更快的程序,但代价是编译时间(生成这些副本要花些时间)和二进制体积(这些副本可能占很多空间)」。这并不是理论层面上的顾虑。一项面向嵌入式 Rust 的研究在 Ti50 安全芯片固件中观察到,仅 Grant::enter() 这一个方法,在最终的二进制里就存在 59 份拷贝,接近 9kB flash——缘由是每个调用点传入的闭包类型各不相同,每调用一次就要新生成一份代码。Rust 社区正在推进的「多态代码生成」项目,目标之一便是让代码生成、链接以及体积三方面都得到缩减。由此可见,「零运行时开销」并不等于免费,账目只是从运行阶段转移到了构建阶段:要占用编译时间、让产物体积变大,在嵌入式、WebAssembly 这样对体积敏感的场景里尤其值得权衡。

Python 代表了第三种记账方式:一概不做检查。PEP 484 中写得很清楚,注解在运行时能够通过 __annotations__ 读取到,但运行阶段不会执行任何类型检查;该提案预设了一个独立于程序之外的检查器,用户可以自愿选择它来扫描自己的代码,本质上相当于「一个很强的 linter」。同一个 PEP 还特意强调,Python 将继续维持动态类型特性,文中写道“作者们无意让类型提示变得强制,哪怕只是约定”。换言之,list[int] 这种写法并不会让解释器拦下你实际传入的字符串,错误要等到真正调用的瞬间,以 AttributeError 的形式暴露出来。

把三种路线并列对比:Java 是一份代码加运行时转换;Rust 是为每个类型生成一份代码且不附转换;Python 则是一份代码、什么额外机制都没有。第一条路线节省的是体积,付出的代价是装箱与类型转换;第二条路线省下转换与间接调用,付出编译时间和二进制体积;第三条路线完全舍弃编阶段的检查,换来的结果是错误只能在运行时现身。

这时再去回看 E0369 那行提示,分量自然大不相同。T: PartialOrd 同时向编译器传达了两件事:其一是函数体内针对 T 调用了哪些操作;其二是在为每个具体类型生成代码时,该具体类型必须能通过这道检查。若某个类型没有实现 PartialOrd,出错点会落在调用处——编译直接不通过,而不是拖到运行时才比较出一个莫名其妙的结果。

下一篇文章将进入 trait:如何定义一整套行为、怎样为具体类型实现它、bound 的几种书写方式(impl Trait、+、where),并且解释为什么在真实项目代码里,trait 出现的频率往往要高于泛型参数。

小结:Rust 泛型的零运行时开销来自编译期单态化,函数签名中的 T: PartialOrd 等 bound 实际上事先约定了该类型可参与的操作;代价转移到编译时间与二进制体积,而 Java 以类型擦除保住二进制兼容、Python 把检查完全交给外部工具,三者代表了对类型信息处理的三种取舍。
参考来源:The Rust Book 第 10.1 章(https://doc.rust-lang.org/book/ch10-01-syntax.html)、Rust 编译器开发指南 Monomorphization 小节(https://rustc-dev-guide.rust-lang.org/backend/monomorph.html)、Oracle 教程 Type Erasure 与 Generics 技术说明、PEP 484(https://peps.python.org/pep-0484/)、论文《Tighten Rust’s Belt: Shrinking Embedded Rust Binaries》(https://sing.stanford.edu/site/publications/rust-lctes22.pdf)、Rust Project Goals「Polymorphic code generation」(https://goals.rust-lang.org/2026/polymorphization.html)、Comprehensive Rust「Monomorphization and Binary Size」(https://google.github.io/comprehensive-rust/idiomatic/polymorphism/refresher/monomorphization.html)。