3.1 泛型的运行时具化


3.1 泛型的运行时具化

本节摘要:C# 泛型由 CLR 在运行时"具化"——每个值类型参数得到独立机器码,引用类型共享一份。本节对比 Java 的类型擦除讲清这套机制,说明泛型约束如何用编译期检查换取安全性,以及协变逆变这些"型变规则"到底在保护什么。

为什么需要泛型:先看没有它的世界

第一章的装箱实验已经给出一半答案:ArrayList 装 int,每次 Add 都装箱。另一半答案是类型安全:

var list = new ArrayList { 1, 2, 3 }; string s = (string)list[0]; // 编译通过,运行时 InvalidCastException

编译器无法知道列表里是什么,强转错误要等运行时爆炸。泛型一举解决两个问题:

var list = new List<int> { 1, 2, 3 }; string s = (string)list[0]; // CS0030:编译期直接拒绝

List<int> 的元素是连续的 int 数组,没有装箱,缓存友好;类型检查在编译期完成。一个语言特性同时买回性能与安全,这在语言设计史上并不多见。

具化 vs 擦除:两种实现哲学

泛型怎么落地到运行时?C# 与 Java 给出了相反的答案。Java 选择擦除:编译后类型参数被抹掉,List<Integer>List<String> 运行时是同一个类,等于自动装箱的集合加编译期语法检查。CLR 选择具化:类型参数保留到运行时,JIT 为每个不同的值类型参数生成一份专属机器码;所有引用类型参数共享一份(因为引用都是同样大小的指针,只需指针替换)。

图 具化与擦除的运行时对比

图 具化与擦除的运行时对比

具化的直接红利你已经见过:List<int> 零装箱。还有一条常被忽略的:运行时能拿到类型参数——typeof(List<int>) 合法,反射、序列化框架可以区分元素类型,泛型数学(INumber<T> 接口让一套算法跑在所有数值类型上)也依赖具化。代价是代码膨胀:用一百种值类型实例化同一个泛型类,JIT 最多生成一百份机器码。对绝大多数应用这点内存无感,但它是真实存在的账。

约束:把假设写成合同

public static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;

没有约束时,T 是"任意类型",编译器只许你做 object 能做的事。where T : IComparable<T> 是一份声明:调用者必须传入可比较的类型。换来的是方法体内能直接调 CompareTo——编译器据此放行。常用约束从弱到强:struct(值类型)、class(引用类型)、接口约束、基类约束、new()(有无参构造,可 new T())。原则很简单:用到什么能力就约束什么,约束越少泛型越通用。约束是写给编译器的证据:你声明得越准确,它能替你拦下的错误越多。

⚠️ 常见坑:想对 T 做算术,新手写 a + b 直接编译报错——运算符是静态成员,接口约束默认不含运算符。历史解法是动态分派或表达式树编译,.NET 7 的 INumber<T> 泛型数学是官方正解。

协变与逆变:型变规则在防什么

IEnumerable<object> objs = new List<string>(); // OK:协变 // IList<object> list = new List<string>(); // CS0266:不变

IEnumerable<out T> 的 out 标记协变:只读地消费 T,IEnumerable<string> 可以安全当 IEnumerable<object> 用——读出来的是 string,天然也是 object。IList<T> 可读可写,若允许这样转换,下一步 list.Add(new Cat()) 就把猫塞进了字符串列表,类型系统当场穿仓。Action<in T> 相反是逆变:只写不读,接受更宽参数类型的委托可以当更窄的 delegate 用。型变规则不是语法装饰,是编译器在阻止"经由泛型偷运不兼容对象"的通路。设计自己的泛型接口时,能标 out/in 就标——它决定了使用者的组合自由度。

泛型与性能的一个实测直觉

把第一章的 ArrayList 实验补上对照:百万次求和,ArrayList(装箱加拆箱)与 List(连续 int 数组)的时间差常在十倍以上,GC 分配差三个数量级。这不是"泛型更快"的魔法,而是"具化让值类型保持值类型"的自然结果。记住因果链:具化 → 无装箱 → 连续内存 → 缓存命中高,GC 压力小。第四章讲 LOH 与数组池化时,这条链会再延伸两环。

把视角拉高一层收束:泛型具化、装箱、表达式树这三件事在运行时层面是同一主题的三个侧面——类型信息保留得越多,运行时能做的优化与检查就越多,付出的空间与代码生成就越大。C# 在这条轴上选择了偏运行时的一端,于是有了零装箱的集合、能辨类型的反射与泛型数学;Java 选择了偏编译期的一端,换来更薄的运行时。理解这个权衡比记住任何一方的优劣更有价值:将来你评估任何语言的类型系统设计,都可以问同一句话——类型信息在运行时还剩多少,剩下的那份换来了什么。

本节要点回顾

  • 泛型一次买回性能与安全:值类型无装箱、类型检查前移到编译期;
  • CLR 具化,Java 擦除:值类型逐个生成机器码,引用共享,运行时可辨类型参数;
  • 约束是能力合同:用到什么约束什么,换来编译期放行与拦截;
  • out 与 in 的型变规则:防止经泛型通道偷运不兼容对象,设计接口时主动标注;
  • 性能因果链:具化 → 零装箱 → 连续内存 → 缓存友好,GC 减压。

下一节看泛型的主战场:各类集合的底层数据结构与选型决策。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U