本文基于 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[]数组存储任意类型classContainer{publicObject[]items;publicContainer(Object[]items){this.itemsitems;}}classDog{publicDog(){}publicvoidbark(){}}classMain{publicstaticvoidmain(String[]args){DogdnewDog();ContainercnewContainer(newDog[]{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编译可以通过((Dog)c.items[0]).bark();但这只是把类型安全的责任推给了运行时。一旦容器里混入了其他类型程序就会在运行时崩溃classDog{publicvoidbark(){}}classCat{}classMain{publicstaticvoidmain(String[]args){ContainercnewContainer(newObject[]{newCat(),newDog()});((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 泛型的引入正是为了解决上面这个问题——它在编译期做类型检查阻止你往ContainerDog里放Catimportjava.util.ArrayList;classContainerT{publicArrayListTitems;publicContainer(){this.itemsnewArrayListT();}}classMain{publicstaticvoidmain(String[]args){DogdnewDog();ContainerDogcnewContainer();c.items.add(newDog());// OKc.items.add(newCat());// 编译错误类型不匹配}}但请注意——这只是一层很薄的安全层。在运行时依然只有一个Container类型它存储的依然是对象引用。2.4 装箱Boxing基本类型的代价Java 泛型只能用于对象类型继承自Object的类型不能直接用于int、byte这样的基本类型primitive types。如果你想在泛型容器里存储整数就必须使用对应的包装类并且 Java 会自动完成装箱autoboxingclassMain{publicstaticvoidmain(String[]args){ContainerBytecnewContainer();c.items.add((byte)1);c.items.add((byte)2);c.items.add((byte)3);}}这段代码看起来像是直接存入了字节值但实际上编译器悄悄地将其转换为c.items.add(newByte((byte)1));c.items.add(newByte((byte)2));c.items.add(newByte((byte)3));每个值实际上都是一个堆上的对象。这意味着一个存放三个字节的 Container和一个存放三条狗的 Container在内存里占用的空间是一样的——因为两者都只是存储了对象引用。下图展示了这一内存布局ContainerByte ┌──────────────────────┐ │ items: [ref, ref, ref]│ ←── 每个 ref 指向堆上的 Byte 对象 └──────────────────────┘ │ ▼ ┌─────┐ ┌─────┐ ┌─────┐ │Byte │ │Byte │ │Byte │ ←── 堆上的包装对象 │ 1 │ │ 2 │ │ 3 │ └─────┘ └─────┘ └─────┘2.5 类型擦除泛型的本质Java 的泛型在运行时会发生类型擦除type erasure。这意味着一旦一个CollectionT被构建出来并传递到别处就无法在运行时得知T究竟是什么类型。voidfoobarT(coll:CollectionT){// 在运行时根本无法知道 T 是什么。// 编译器只是用它来做静态类型检查。// 无法创建一个 T 的实例。// T 的类信息并未存储在 Collection 内部。// 在运行时CollectionT 就是 CollectionObject// 不管 T 是什么它都占用相同的空间执行相同的代码。}Java 泛型的核心模型只有一份Container的代码T 只是编译器用来做类型检查的标签运行时一律按Object处理。三、Rust 泛型擦除之上还有具化3.1 Rust 同样进行类型擦除首先Rust 也存在类型擦除。在没有额外 trait 约束的情况下泛型函数在运行时同样无法访问 T 的具体类型信息structContainerT{t:T,}fnfoobarT(c:ContainerT){// 在运行时无法知道 T 是什么。// 无法对 T 进行模式匹配// 无法根据不同的 T 走不同的代码路径。// 区别必须来自外部调用方。}fnmain(){letaContainer{t:42};}在这一点上Rust 和 Java 是相同的。3.2 但 Rust 泛型是被具化的Reification / 单态化关键区别来了Rust 的泛型在编译时会被具化reification。具化是什么意思词典定义reify动词将某个抽象的事物视为或表示为具体的物质存在赋予某个概念或想法以明确的内容和形式。在 Rust 社区通常使用单态化monomorphization这个更精确的术语。单态化的意思是编译器会从一份多态polymorphic的泛型代码出发为每一个具体的类型参数生成一份独立的、单一形态mono的代码。具体来说Containeru8和Containeru32在 Rust 里是完全不同的类型——它们有不同的大小虽然实现了相同的方法集合但每个方法都有各自独立的机器码。3.3 用内存大小来验证这一点在 Rust 里测量类型大小非常方便structContainerT{items:[T;3],}fnmain(){usestd::mem::size_of_val;letcu8Container{items:[1u8,2u8,3u8],};println!(size of cu8 {} bytes,size_of_val(cu8));letcu32Container{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 编译器所做的事情等价于你手写了两个完全不同的结构体structContainerU8{items:[u8;3],}structContainerU32{items:[u32;3],}只不过这个展开过程由编译器替你完成。下图展示了 Rust 的内存布局模型Containeru8 Containeru32 ┌─────────────┐ ┌────────────────────────────────┐ │ 1 │ 2 │ 3 │ 3 字节 │ 1 │ 0 │ 0 │ 0 │ ... │ 12 字节 └─────────────┘ └────────────────────────────────┘ 直接存值无装箱 直接存值无装箱四、具化的代价二进制体积与内存布局4.1 二进制体积膨胀问题具化单态化的主要代价之一是二进制体积增大。如果你的泛型容器有很多方法并且你使用了许多不同的T编译器就需要为每种组合生成一份代码副本。当泛型类型互相组合嵌套时这种膨胀会更加严重。4.2 栈与堆本地变量的大小必须确定Rust 的内存安全模型要求函数中声明的所有局部变量其大小必须在编译期确定这样才能在栈stack上正确分配内存。如果声明一个Containeru32编译器知道它是 12 字节会在栈上预留 12 字节。如果声明一个BoxContaineru32则容器本身被分配在堆heap上栈上只留一个指针32 位系统 4 字节64 位系统 8 字节。4.3 迭代器的大小实测组合出来的复杂类型Rust 中的迭代器是一个很好的例子用来说明具化如何产生不同大小的具体类型fnmain(){usestd::mem::size_of_val;letv1vec![1,2,3];letv2vec![4,5,6];{letsimplev1.iter();println!(size of simple {} bytes,size_of_val(simple));}{letchainedv1.iter().chain(v2.iter());println!(size of chained {} bytes,size_of_val(chained));}{letvvvec![v1,v2];letflattenedvv.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_, i32chained的类型是std::iter::Chainstd::slice::Iter_, i32, std::slice::Iter_, i32flattened的类型是类似的嵌套泛型结构它们都是具体的、有确定大小的类型全部分配在栈上栈内存布局示意 simple: [16 bytes] chained: [ 40 bytes ] flattened: [ 64 bytes ]4.4 Box 登场把数据推入堆指针留在栈如果我们把这些迭代器都装进Box{letsimpleBox::new(v1.iter());println!(size of boxed simple {} bytes,size_of_val(simple));}{letchainedBox::new(v1.iter().chain(v2.iter()));println!(size of boxed chained {} bytes,size_of_val(chained));}{letvvvec![v1,v2];letflattenedBox::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实现了Dereftrait我们可以对其解引用传入size_of_val{letsimpleBox::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 bytesBox 只是一个薄薄的指针外壳真正的数据依然在堆上大小依然是具化后的真实大小。4.5 用内存地址来亲眼验证我们还可以打印内存地址直观地验证什么在栈上什么在堆上fnprint_addrT(name:str,reference:T){println!(addr of {} {:#?},name,referenceas*const_);}fnmain(){usestd::mem::size_of_val;letv1vec![1,2,3];print_addr(v1 ,v1);letv2vec![4,5,6];print_addr(v2 ,v2);{letsimpleBox::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掉从而消除调用开销甚至将整个函数链压缩为少量的机器指令。来看一个例子fneqT(a:T,b:T)-boolwhereT:PartialEq,{ab}pubfnmain(){letmutiterstd::env::args();let(a,b)(iter.next().unwrap(),iter.next().unwrap());compare(a,b);}fncompare(a:str,b:str){eq(a,b);eq(a.len(),b.len());}5.2 汇编层面函数调用消失了在 Compiler ExplorerRust 在线汇编查看器上不开优化时可以看到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 Wengerfasterthanlime原文链接https://fasterthanli.me/articles/rust-vs-java-generics发布时间2019 年 5 月 9 日
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[]数组存储任意类型classContainer{publicObject[]items;publicContainer(Object[]items){this.itemsitems;}}classDog{publicDog(){}publicvoidbark(){}}classMain{publicstaticvoidmain(String[]args){DogdnewDog();ContainercnewContainer(newDog[]{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编译可以通过((Dog)c.items[0]).bark();但这只是把类型安全的责任推给了运行时。一旦容器里混入了其他类型程序就会在运行时崩溃classDog{publicvoidbark(){}}classCat{}classMain{publicstaticvoidmain(String[]args){ContainercnewContainer(newObject[]{newCat(),newDog()});((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 泛型的引入正是为了解决上面这个问题——它在编译期做类型检查阻止你往ContainerDog里放Catimportjava.util.ArrayList;classContainerT{publicArrayListTitems;publicContainer(){this.itemsnewArrayListT();}}classMain{publicstaticvoidmain(String[]args){DogdnewDog();ContainerDogcnewContainer();c.items.add(newDog());// OKc.items.add(newCat());// 编译错误类型不匹配}}但请注意——这只是一层很薄的安全层。在运行时依然只有一个Container类型它存储的依然是对象引用。2.4 装箱Boxing基本类型的代价Java 泛型只能用于对象类型继承自Object的类型不能直接用于int、byte这样的基本类型primitive types。如果你想在泛型容器里存储整数就必须使用对应的包装类并且 Java 会自动完成装箱autoboxingclassMain{publicstaticvoidmain(String[]args){ContainerBytecnewContainer();c.items.add((byte)1);c.items.add((byte)2);c.items.add((byte)3);}}这段代码看起来像是直接存入了字节值但实际上编译器悄悄地将其转换为c.items.add(newByte((byte)1));c.items.add(newByte((byte)2));c.items.add(newByte((byte)3));每个值实际上都是一个堆上的对象。这意味着一个存放三个字节的 Container和一个存放三条狗的 Container在内存里占用的空间是一样的——因为两者都只是存储了对象引用。下图展示了这一内存布局ContainerByte ┌──────────────────────┐ │ items: [ref, ref, ref]│ ←── 每个 ref 指向堆上的 Byte 对象 └──────────────────────┘ │ ▼ ┌─────┐ ┌─────┐ ┌─────┐ │Byte │ │Byte │ │Byte │ ←── 堆上的包装对象 │ 1 │ │ 2 │ │ 3 │ └─────┘ └─────┘ └─────┘2.5 类型擦除泛型的本质Java 的泛型在运行时会发生类型擦除type erasure。这意味着一旦一个CollectionT被构建出来并传递到别处就无法在运行时得知T究竟是什么类型。voidfoobarT(coll:CollectionT){// 在运行时根本无法知道 T 是什么。// 编译器只是用它来做静态类型检查。// 无法创建一个 T 的实例。// T 的类信息并未存储在 Collection 内部。// 在运行时CollectionT 就是 CollectionObject// 不管 T 是什么它都占用相同的空间执行相同的代码。}Java 泛型的核心模型只有一份Container的代码T 只是编译器用来做类型检查的标签运行时一律按Object处理。三、Rust 泛型擦除之上还有具化3.1 Rust 同样进行类型擦除首先Rust 也存在类型擦除。在没有额外 trait 约束的情况下泛型函数在运行时同样无法访问 T 的具体类型信息structContainerT{t:T,}fnfoobarT(c:ContainerT){// 在运行时无法知道 T 是什么。// 无法对 T 进行模式匹配// 无法根据不同的 T 走不同的代码路径。// 区别必须来自外部调用方。}fnmain(){letaContainer{t:42};}在这一点上Rust 和 Java 是相同的。3.2 但 Rust 泛型是被具化的Reification / 单态化关键区别来了Rust 的泛型在编译时会被具化reification。具化是什么意思词典定义reify动词将某个抽象的事物视为或表示为具体的物质存在赋予某个概念或想法以明确的内容和形式。在 Rust 社区通常使用单态化monomorphization这个更精确的术语。单态化的意思是编译器会从一份多态polymorphic的泛型代码出发为每一个具体的类型参数生成一份独立的、单一形态mono的代码。具体来说Containeru8和Containeru32在 Rust 里是完全不同的类型——它们有不同的大小虽然实现了相同的方法集合但每个方法都有各自独立的机器码。3.3 用内存大小来验证这一点在 Rust 里测量类型大小非常方便structContainerT{items:[T;3],}fnmain(){usestd::mem::size_of_val;letcu8Container{items:[1u8,2u8,3u8],};println!(size of cu8 {} bytes,size_of_val(cu8));letcu32Container{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 编译器所做的事情等价于你手写了两个完全不同的结构体structContainerU8{items:[u8;3],}structContainerU32{items:[u32;3],}只不过这个展开过程由编译器替你完成。下图展示了 Rust 的内存布局模型Containeru8 Containeru32 ┌─────────────┐ ┌────────────────────────────────┐ │ 1 │ 2 │ 3 │ 3 字节 │ 1 │ 0 │ 0 │ 0 │ ... │ 12 字节 └─────────────┘ └────────────────────────────────┘ 直接存值无装箱 直接存值无装箱四、具化的代价二进制体积与内存布局4.1 二进制体积膨胀问题具化单态化的主要代价之一是二进制体积增大。如果你的泛型容器有很多方法并且你使用了许多不同的T编译器就需要为每种组合生成一份代码副本。当泛型类型互相组合嵌套时这种膨胀会更加严重。4.2 栈与堆本地变量的大小必须确定Rust 的内存安全模型要求函数中声明的所有局部变量其大小必须在编译期确定这样才能在栈stack上正确分配内存。如果声明一个Containeru32编译器知道它是 12 字节会在栈上预留 12 字节。如果声明一个BoxContaineru32则容器本身被分配在堆heap上栈上只留一个指针32 位系统 4 字节64 位系统 8 字节。4.3 迭代器的大小实测组合出来的复杂类型Rust 中的迭代器是一个很好的例子用来说明具化如何产生不同大小的具体类型fnmain(){usestd::mem::size_of_val;letv1vec![1,2,3];letv2vec![4,5,6];{letsimplev1.iter();println!(size of simple {} bytes,size_of_val(simple));}{letchainedv1.iter().chain(v2.iter());println!(size of chained {} bytes,size_of_val(chained));}{letvvvec![v1,v2];letflattenedvv.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_, i32chained的类型是std::iter::Chainstd::slice::Iter_, i32, std::slice::Iter_, i32flattened的类型是类似的嵌套泛型结构它们都是具体的、有确定大小的类型全部分配在栈上栈内存布局示意 simple: [16 bytes] chained: [ 40 bytes ] flattened: [ 64 bytes ]4.4 Box 登场把数据推入堆指针留在栈如果我们把这些迭代器都装进Box{letsimpleBox::new(v1.iter());println!(size of boxed simple {} bytes,size_of_val(simple));}{letchainedBox::new(v1.iter().chain(v2.iter()));println!(size of boxed chained {} bytes,size_of_val(chained));}{letvvvec![v1,v2];letflattenedBox::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实现了Dereftrait我们可以对其解引用传入size_of_val{letsimpleBox::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 bytesBox 只是一个薄薄的指针外壳真正的数据依然在堆上大小依然是具化后的真实大小。4.5 用内存地址来亲眼验证我们还可以打印内存地址直观地验证什么在栈上什么在堆上fnprint_addrT(name:str,reference:T){println!(addr of {} {:#?},name,referenceas*const_);}fnmain(){usestd::mem::size_of_val;letv1vec![1,2,3];print_addr(v1 ,v1);letv2vec![4,5,6];print_addr(v2 ,v2);{letsimpleBox::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掉从而消除调用开销甚至将整个函数链压缩为少量的机器指令。来看一个例子fneqT(a:T,b:T)-boolwhereT:PartialEq,{ab}pubfnmain(){letmutiterstd::env::args();let(a,b)(iter.next().unwrap(),iter.next().unwrap());compare(a,b);}fncompare(a:str,b:str){eq(a,b);eq(a.len(),b.len());}5.2 汇编层面函数调用消失了在 Compiler ExplorerRust 在线汇编查看器上不开优化时可以看到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 Wengerfasterthanlime原文链接https://fasterthanli.me/articles/rust-vs-java-generics发布时间2019 年 5 月 9 日