Rust 泛型 vs Java 泛型:它们看起来相似,但骨子里截然不同

本文基于 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 的类型),不能直接用于 intbyte 这样的基本类型(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 的参数类型是 &strusize,因此可以放心地将 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 日

相关推荐
codealy2 小时前
Rust 核心理论与内存安全(二)
安全·rust
Rust研习社2 小时前
告别环境混乱!使用 mise 管理你的开发环境
前端·后端·rust
一只旭宝2 小时前
【C加加入门精讲15】:IO流缓冲区、字符串流、缓冲流及STL vector容器零基础实战教程一、博客前言
开发语言·c++
人道领域2 小时前
【LeetCode刷题日记】106.从遍历序列重建二叉树:手撕递归边界,彻底搞懂左闭右闭 vs 左闭右开
java·算法·leetcode
在坚持一下我可没意见2 小时前
Python 修仙修炼录 08:字典秘境,参悟键值玄机
开发语言·笔记·python·入门·字典
luck_bor2 小时前
Map&Stream流
java·开发语言
阿文的代码库2 小时前
如何在C++中使用标准库的智能指针
开发语言·c++·算法
用户298698530142 小时前
Java 统计 Word 文档中的单词数量
java·后端