本文基于 Amos Wenger 的博客文章《Rust generics vs Java generics》整理翻译,并加入一定解释说明。
内容结构概览
一、引言:一个被纠正的误解
二、Java 泛型:万物皆对象的世界
2.1 没有泛型的时代:Object 数组的困境
2.2 运行时类型转换的风险
2.3 泛型带来的编译时安全层
2.4 装箱(Boxing):基本类型的代价
2.5 类型擦除:泛型的本质
三、Rust 泛型:擦除之上,还有"具化"
3.1 Rust 同样进行类型擦除
3.2 但 Rust 泛型是被"具化"的(Reification / 单态化)
3.3 用内存大小来验证这一点
四、具化的代价:二进制体积与内存布局
4.1 二进制体积膨胀问题
4.2 栈与堆:本地变量的大小必须确定
4.3 迭代器的大小实测:组合出来的复杂类型
4.4 Box 登场:把数据推入堆,指针留在栈
4.5 用内存地址来亲眼验证
五、具化的收益:内联优化
5.1 编译器掌握完整类型信息
5.2 汇编层面:函数调用消失了
六、总结
一、引言:一个被纠正的误解
作者 Amos 在上一篇关于递归迭代器的文章中提到,他需要停止用 Java 泛型的思维去理解 Rust 泛型,因为他认为"Rust 的泛型会进行类型擦除"。
然后有人礼貌地指出:Java 的泛型同样会进行类型擦除,两者的区别并不在这里。
于是就有了这篇文章------让我们一起搞清楚,Rust 和 Java 的泛型,到底有什么本质上的不同。
二、Java 泛型:万物皆对象的世界
2.1 没有泛型的时代:Object 数组的困境
理解 Java 泛型,要先从 Java 的核心设计哲学说起:所有类的实例都是对象,所有对象都继承自 Object。
在泛型出现之前,如果你想写一个通用容器,最直接的做法是用 Object[] 数组存储任意类型:
java
class Container {
public Object[] items;
public Container(Object[] items) {
this.items = items;
}
}
class Dog {
public Dog() {}
public void bark() {}
}
class Main {
public static void main(String[] args) {
Dog d = new Dog();
Container c = new Container(new Dog[] {d, d, d});
c.items[0].bark(); // 编译错误!
}
}
最后一行会报编译错误:
error: cannot find symbol
c.items[0].bark();
^
symbol: method bark()
location: class Object
问题在于:编译器只知道 c.items 里装的是 Object,而 Object 类没有 bark() 方法。
2.2 运行时类型转换的风险
如果我们显式地将其强制转换为 Dog,编译可以通过:
java
((Dog)c.items[0]).bark();
但这只是把类型安全的责任推给了运行时。一旦容器里混入了其他类型,程序就会在运行时崩溃:
java
class Dog { public void bark() {} }
class Cat {}
class Main {
public static void main(String[] args) {
Container c = new Container(new Object[] {new Cat(), new Dog()});
((Dog)c.items[0]).bark(); // 运行时崩溃!
}
}
运行结果:
Exception in thread "main" java.lang.ClassCastException: Cat cannot be cast to Dog
at Main.main(Main.java:8)
2.3 泛型带来的编译时安全层
Java 泛型的引入,正是为了解决上面这个问题------它在编译期做类型检查,阻止你往 Container<Dog> 里放 Cat:
java
import java.util.ArrayList;
class Container<T> {
public ArrayList<T> items;
public Container() {
this.items = new ArrayList<T>();
}
}
class Main {
public static void main(String[] args) {
Dog d = new Dog();
Container<Dog> c = new Container();
c.items.add(new Dog()); // OK
c.items.add(new Cat()); // 编译错误!类型不匹配
}
}
但请注意------这只是一层很薄的安全层 。在运行时,依然只有一个 Container 类型,它存储的依然是对象引用。
2.4 装箱(Boxing):基本类型的代价
Java 泛型只能用于对象类型(继承自 Object 的类型),不能直接用于 int、byte 这样的基本类型(primitive types)。
如果你想在泛型容器里存储整数,就必须使用对应的包装类,并且 Java 会自动完成"装箱"(autoboxing):
java
class Main {
public static void main(String[] args) {
Container<Byte> c = new Container();
c.items.add((byte) 1);
c.items.add((byte) 2);
c.items.add((byte) 3);
}
}
这段代码看起来像是直接存入了字节值,但实际上编译器悄悄地将其转换为:
java
c.items.add(new Byte((byte) 1));
c.items.add(new Byte((byte) 2));
c.items.add(new Byte((byte) 3));
每个"值"实际上都是一个堆上的对象。这意味着:一个存放三个字节的 Container,和一个存放三条狗的 Container,在内存里占用的空间是一样的------因为两者都只是存储了对象引用。
下图展示了这一内存布局:
Container<Byte>
┌──────────────────────┐
│ items: [ref, ref, ref]│ ←── 每个 ref 指向堆上的 Byte 对象
└──────────────────────┘
│
▼
┌─────┐ ┌─────┐ ┌─────┐
│Byte │ │Byte │ │Byte │ ←── 堆上的包装对象
│ 1 │ │ 2 │ │ 3 │
└─────┘ └─────┘ └─────┘
2.5 类型擦除:泛型的本质
Java 的泛型在运行时会发生类型擦除 (type erasure)。这意味着,一旦一个 Collection<T> 被构建出来并传递到别处,就无法在运行时得知 T 究竟是什么类型。
java
void foobar<T>(coll: Collection<T>) {
// 在运行时,根本无法知道 T 是什么。
// 编译器只是用它来做静态类型检查。
// 无法创建一个 T 的实例。
// T 的类信息并未存储在 Collection 内部。
// 在运行时,Collection<T> 就是 Collection<Object>,
// 不管 T 是什么,它都占用相同的空间,执行相同的代码。
}
Java 泛型的核心模型 :只有一份 Container 的代码,T 只是编译器用来做类型检查的标签,运行时一律按 Object 处理。
三、Rust 泛型:擦除之上,还有"具化"
3.1 Rust 同样进行类型擦除
首先,Rust 也存在类型擦除。在没有额外 trait 约束的情况下,泛型函数在运行时同样无法访问 T 的具体类型信息:
rust
struct Container<T> {
t: T,
}
fn foobar<T>(c: Container<T>) {
// 在运行时,无法知道 T 是什么。
// 无法对 T 进行模式匹配,
// 无法根据不同的 T 走不同的代码路径。
// 区别必须来自外部(调用方)。
}
fn main() {
let a = Container { t: 42 };
}
在这一点上,Rust 和 Java 是相同的。
3.2 但 Rust 泛型是被"具化"的(Reification / 单态化)
关键区别来了:Rust 的泛型在编译时会被具化(reification)。
"具化"是什么意思?词典定义:
reify(动词):将某个抽象的事物视为或表示为具体的物质存在;赋予某个概念或想法以明确的内容和形式。
在 Rust 社区,通常使用"单态化"(monomorphization)这个更精确的术语。单态化的意思是:编译器会从一份多态(polymorphic)的泛型代码出发,为每一个具体的类型参数生成一份独立的、单一形态(mono)的代码。
具体来说:Container<u8> 和 Container<u32> 在 Rust 里是完全不同的类型------它们有不同的大小,虽然实现了相同的方法集合,但每个方法都有各自独立的机器码。
3.3 用内存大小来验证这一点
在 Rust 里,测量类型大小非常方便:
rust
struct Container<T> {
items: [T; 3],
}
fn main() {
use std::mem::size_of_val;
let cu8 = Container {
items: [1u8, 2u8, 3u8],
};
println!("size of cu8 = {} bytes", size_of_val(&cu8));
let cu32 = Container {
items: [1u32, 2u32, 3u32],
};
println!("size of cu32 = {} bytes", size_of_val(&cu32));
}
输出:
size of cu8 = 3 bytes
size of cu32 = 12 bytes
结果非常直观------u8 是 1 字节,3 个就是 3 字节;u32 是 4 字节,3 个就是 12 字节。
这和 Java 中三个 Byte 对象与三条 Dog 对象占用相同空间的情况,形成了鲜明对比。
实际上,Rust 编译器所做的事情,等价于你手写了两个完全不同的结构体:
rust
struct ContainerU8 {
items: [u8; 3],
}
struct ContainerU32 {
items: [u32; 3],
}
只不过这个"展开"过程由编译器替你完成。
下图展示了 Rust 的内存布局模型:
Container<u8> Container<u32>
┌─────────────┐ ┌────────────────────────────────┐
│ 1 │ 2 │ 3 │ 3 字节 │ 1 │ 0 │ 0 │ 0 │ ... │ 12 字节
└─────────────┘ └────────────────────────────────┘
直接存值,无装箱 直接存值,无装箱
四、具化的代价:二进制体积与内存布局
4.1 二进制体积膨胀问题
具化(单态化)的主要代价之一,是二进制体积增大。
如果你的泛型容器有很多方法,并且你使用了许多不同的 T,编译器就需要为每种组合生成一份代码副本。当泛型类型互相组合嵌套时,这种膨胀会更加严重。
4.2 栈与堆:本地变量的大小必须确定
Rust 的内存安全模型要求:函数中声明的所有局部变量,其大小必须在编译期确定,这样才能在栈(stack)上正确分配内存。
- 如果声明一个
Container<u32>,编译器知道它是 12 字节,会在栈上预留 12 字节。 - 如果声明一个
Box<Container<u32>>,则容器本身被分配在堆(heap)上,栈上只留一个指针(32 位系统 4 字节,64 位系统 8 字节)。
4.3 迭代器的大小实测:组合出来的复杂类型
Rust 中的迭代器是一个很好的例子,用来说明具化如何产生不同大小的具体类型:
rust
fn main() {
use std::mem::size_of_val;
let v1 = vec![1, 2, 3];
let v2 = vec![4, 5, 6];
{
let simple = v1.iter();
println!("size of simple = {} bytes", size_of_val(&simple));
}
{
let chained = v1.iter().chain(v2.iter());
println!("size of chained = {} bytes", size_of_val(&chained));
}
{
let vv = vec![v1, v2];
let flattened = vv.iter().flatten();
println!("size of flattened = {} bytes", size_of_val(&flattened))
}
}
输出:
size of simple = 16 bytes
size of chained = 40 bytes
size of flattened = 64 bytes
来解释一下这三个类型的全称:
simple的类型是std::slice::Iter<'_, i32>chained的类型是std::iter::Chain<std::slice::Iter<'_, i32>, std::slice::Iter<'_, i32>>flattened的类型是类似的嵌套泛型结构
它们都是具体的、有确定大小的类型,全部分配在栈上:
栈内存布局(示意):
simple: [16 bytes]
chained: [ 40 bytes ]
flattened: [ 64 bytes ]
4.4 Box 登场:把数据推入堆,指针留在栈
如果我们把这些迭代器都装进 Box:
rust
{
let simple = Box::new(v1.iter());
println!("size of boxed simple = {} bytes", size_of_val(&simple));
}
{
let chained = Box::new(v1.iter().chain(v2.iter()));
println!("size of boxed chained = {} bytes", size_of_val(&chained));
}
{
let vv = vec![v1, v2];
let flattened = Box::new(vv.iter().flatten());
println!(
"size of boxed flattened = {} bytes",
size_of_val(&flattened)
);
}
输出:
size of boxed simple = 8 bytes
size of boxed chained = 8 bytes
size of boxed flattened = 8 bytes
全部变成了 8 字节(作者使用的是 64 位 Linux 系统,8 字节 = 64 位指针,符合预期)。
迭代器们去哪儿了?它们被移到堆上了。栈上只留下一个指向堆的指针。
那么能不能还原迭代器真实的大小?可以------Box 实现了 Deref trait,我们可以对其解引用,传入 size_of_val:
rust
{
let simple = Box::new(v1.iter());
println!("~~ simple ~~");
println!("box = {} bytes", size_of_val(&simple));
println!("contents = {} bytes", size_of_val(&*simple));
}
// 以此类推...
输出:
~~ simple ~~
box = 8 bytes
contents = 16 bytes
~~ chained ~~
box = 8 bytes
contents = 40 bytes
~~ chained ~~
box = 8 bytes
contents = 64 bytes
Box 只是一个薄薄的指针外壳,真正的数据依然在堆上,大小依然是具化后的真实大小。
4.5 用内存地址来亲眼验证
我们还可以打印内存地址,直观地验证"什么在栈上,什么在堆上":
rust
fn print_addr<T>(name: &str, reference: &T) {
println!("addr of {} = {:#?}", name, reference as *const _);
}
fn main() {
use std::mem::size_of_val;
let v1 = vec![1, 2, 3];
print_addr("v1 ", &v1);
let v2 = vec![4, 5, 6];
print_addr("v2 ", &v2);
{
let simple = Box::new(v1.iter());
println!("~~ simple ~~");
print_addr("box ", &simple);
print_addr("contents", &*simple);
}
// 以此类推...
}
输出:
addr of v1 = 0x00007ffff436d070
addr of v2 = 0x00007ffff436d088
~~ simple ~~
addr of box = 0x00007ffc2bd60120
addr of contents = 0x0000560aca0dea80
~~ chained ~~
addr of box = 0x00007ffc2bd60158
addr of contents = 0x0000560aca0debe0
~~ chained ~~
addr of box = 0x00007ffc2bd60208
addr of contents = 0x0000560aca0dec50
规律一目了然:
- 栈上的变量 (v1、v2、各个 Box 指针本身):地址集中在
0x00007fff...附近 - 堆上的数据 (Box 的 contents):地址集中在
0x0000560a...附近
这和之前关于递归迭代器的文章形成了呼应:迭代器的大小依赖于实际数据结构的内容,在编译期无法总是确定。但我们可以退而求其次,在栈上只保留一个指针(固定大小),让指针指向堆上大小不定的具体数据。
五、具化的收益:内联优化
具化不只带来代价,它同时也给编译器提供了大量的优化机会。
5.1 编译器掌握完整类型信息
当编译器在编译时完整地知道所有类型,它就可以将函数调用彻底内联(inline)掉,从而消除调用开销,甚至将整个函数链压缩为少量的机器指令。
来看一个例子:
rust
fn eq<T>(a: T, b: T) -> bool
where T: PartialEq,
{
a == b
}
pub fn main() {
let mut iter = std::env::args();
let (a, b) = (iter.next().unwrap(), iter.next().unwrap());
compare(&a, &b);
}
fn compare(a: &str, b: &str) {
eq(a, b);
eq(a.len(), b.len());
}
5.2 汇编层面:函数调用消失了
在 Compiler Explorer(Rust 在线汇编查看器)上,不开优化 时,可以看到 eq 被调用了两次,并且 len() 也被显式调用了。
开启优化后,情况发生了根本性的变化:
eq函数调用完全消失len()调用消失- 对于字符串相等性判断,编译器直接生成了
memcmp调用
整个调用链被编译器压缩为尽可能少的指令。这背后的原因正是:由于单态化,编译器在处理 compare 函数时,已经完整地知道了 eq 的参数类型是 &str 和 usize,因此可以放心地将 eq 的函数体直接展开到调用点,并进一步做常量折叠、死代码消除等优化。
如果不是作者手动加了 #[inline(never)] 来阻止编译器,连 compare 函数本身也会被内联掉。
六、总结
| 对比维度 | Java 泛型 | Rust 泛型 |
|---|---|---|
| 类型擦除 | 有(运行时不知道 T) | 有(运行时默认不知道 T) |
| 类型具化(单态化) | 无(只有一份代码) | 有(每种 T 生成一份代码) |
| 泛型参数约束 | 必须是对象(继承 Object) | 可以是任意 Sized 类型 |
| 基本类型支持 | 需要装箱(Boxing),有额外开销 | 直接支持,无装箱,零开销 |
| 内存布局 | 泛型容器大小与 T 无关,只存引用 | 泛型容器大小随 T 变化,直接存值 |
| 编译产物 | 只有一份泛型代码 | 每种具体类型各一份代码(可能体积更大) |
| 优化机会 | 有限(运行时多态) | 丰富(编译期完全内联、常量折叠等) |
用一句话总结这篇文章的核心洞察:
Java 和 Rust 的泛型都做了类型擦除------但 Java 的泛型止步于此,而 Rust 在此之上还做了单态化(具化)。单态化的代价是潜在的二进制体积膨胀,但收益是:没有装箱开销、内存布局高效、以及编译器可以充分利用类型信息进行激进的内联优化。而且,如果编译器的优化器足够聪明,它甚至会把单态化产生的额外副本内联掉------所以你通常并不需要真正"为此付费"。
原文作者:Amos Wenger(@fasterthanlime)
原文链接:https://fasterthanli.me/articles/rust-vs-java-generics
发布时间:2019 年 5 月 9 日