泛型实现共享代码时会遇到一个核心矛盾:同一套模板生成的不同泛型实例,代码完全相同,但运行时类型语义并不等同。
Java 擦除、C# 具现、NLang 字典,是三条决定运行时能看见什么的路线,差别在各自怎么处理类型实参。下面以 NLang 为样本,逐个看三个判定点:对象转型、异常分派、运行时失败分层。
文中区分泛型通用原理与 NLang 特有实现,陌生名词可查阅文末附录。承接前两篇:《泛型实现启示(一):模型的选择》确定了泛型模型,《泛型实现启示(二):实例化共享》把类型实参存成运行时数据;这里看这批数据在安全检查里实际用在哪些地方。
一、一份代码,多个类型
泛型之前,虚拟机的运行时检查只有两类:类转型比对类记录(class metadata,运行时里描述一个类的条目,相当于 JVM 的 Klass、.NET 的 MethodTable),必要时沿超类链往上走;数组下标查长度,越界抛可捕获的异常。两类都便宜 —— 指令带着表下标,比对就是查表加走链。
矛盾出在类记录这一层。代码只编一份,一个泛型模板在运行时就只有一条类记录;它的实例化却可以有任意多个,Box<int>和Box<string>落在同一条记录上。检查只做到这一层,两类实例化就分不开:转型一律放行,异常一律按类记录接住,分不出是哪个实例化。
泛型有四条实现路线,第四条单态化(每个实例化复制一份代码)只改代码份数、不改判定方式,不单独占一列;剩下三条决定运行时能看见什么,对上面这个矛盾各有各的答法:
- 擦除:运行时不留类型实参,承认分不开。检查退化到裸类,错误推迟到真正使用元素的那一刻。
- 具现:按实例化造出各自独立的运行时类型,分得开。检查落到 “这两个类型是不是同一个”。
- 字典:代码只一份,类型实参另存成运行时数据,分得开,代价是多一份存储和查表。
三条路线对三个判定点各有各的答法,结论和依据放到文末一并列出。先说明 NLang 的存法:一个泛型模板只登记一条类记录,用模板的非限定名,全部实例化共用。类型实参怎么表示、怎么去重,《泛型实现启示(二):实例化共享》已经定下:描述符按规范拼写组织,进程级驻留,相等化简成指针比较。细节后面按判定点展开。
指令侧带两类信息。编译期能完整拼出拼写的位置,带描述符表下标,表随模块存放实例化清单;拼写里含类型参数、编译期拼不出的位置,带一份拼接记录,运行时照着它现拼。
这三样都是字典路线的具体落法,其中旁表是一张与堆逐格并行、存对象类型身份的表。“另存一份元数据” 是通用思路,存在对象旁边、存进对象头、还是当参数传下去,各家可以不一样 ——Go 的字典路线就是把字典当额外参数传,不放进对象。
二、对象转型:能查到类型实参吗
通用问题:向下转型时,运行时能校验到泛型实参这一层吗?这决定了错转什么时候被发现 —— 转型当场,还是取元素、调方法的时候。
三条路线的答案差别很大。擦除只能查到裸类;具现能查到实例化,每个实例化本身就是运行时类型;字典要看元数据里记了什么 —— 记了规范拼写就能查到,只记模板名就只查到裸类。
变成转型目标里的T,差别更明显。Java 的(T) o编译能过,运行时只按T的上界查,上界是Object就等于没查;C# 的(T) o查的是那个实例化真正的类型。
同一件事在 Java 和 C# 里各写一段,结果不同。Java 这一段编译能过,问题留到取值:
List<Integer> li = new ArrayList<Integer>();
li.add(42);
Object o = li;
List<String> ls = (List<String>) o; // 编译通过,只有一条 unchecked 警告
String s = ls.get(0); // 这里才抛 ClassCastException
C# 这一段,同一句转型当场就抛:
List<int> li = new List<int> { 42 };
object o = li;
List<string> ls = (List<string>) o; // 当场抛 InvalidCastException
差别全出在转型这一步做不做校验。Go 更进一步:类型断言不接受类型参数,这类错转在语言里根本构造不出来。
NLang 属于 “记了规范拼写就能查” 这一档。最直接的例子,是拼写在编译期就能完整拼出的实例化:
class Box<T> {
public T value;
public Box(T v) { value = v; }
}
Box<int> bi = Box(7);
Object o = bi;
Box<int> same = o as Box<int>; // 通过,value读回7
Box<string> other = o as Box<string>; // 抛InvalidCastException
同样的转型在 Java 里能够通过,上一段代码演示的正是这一点。NLang 的运行时手里有一份对象的类型实参记录,当场就能拦下,报错把两边的规范拼写都写出来,不止一个类名:
NLang VM: invalid cast - expected `Box<string>`, got `Box<int>`
比对不涉及字符串比较。Box<string>的拼写在编译期就能完整拼出,驻留之后是进程里唯一一条描述符,指令只带它的表下标;被转对象身上那份从旁表里读 —— 堆里第 i 个对象是什么类型,旁表第 i 格就存哪份描述符。读出来的那份也驻留过,两个指针一比:等就过,不等就抛。
这就是 “对象随身带一份类型身份” 在 NLang 里的落法。JVM 把类指针放在对象头,.NET 放 MethodTable 指针,Go 用 *rtype,位置和形态各家不同,思路是同一个:类型身份跟着对象走。另一点容易踩:NLang 的as落空直接抛异常,和 Java 的强制转型一样;C# 的as对引用类型落空返回null、不抛。两者同名而语义不同,读代码时不能照搬 C# 的直觉。
更复杂的情况是转型目标里带有T。目标写成Box<T>,T是转型表达式所在泛型的类型参数,编译期定不下来,比对参照要到调用时才成形:
class Holder<T> {
public Object recast(Object o) { return o as Box<T>; }
}
Holder<int> h = Holder();
h.recast(bi); // 通过,对象原样递出去
Holder<string> h2 = Holder();
h2.recast(bi); // 抛InvalidCastException,消息还是那句:expected `Box<string>`, got `Box<int>`
同一段方法体,两个实例化各走各的判定。参照要到调用时才成形,因为目标里的T编译期不可知;NLang 这类位置的指令带着拼接记录,记录里写的是 “模板名 Box,类型实参取帧描述符的第 0 位”。
帧描述符随调用帧一起带进来。运行时对着拼接记录把比对行拼出来,再拿去比。共享模型省下的是代码份数,代价挪到了执行期:同一个方法体每执行一次,都要先拼一次比对参照。
o as T把目标退化成类型参数本身。这时没有实例化描述符可比,查的是帧描述符里T那一位实参的种类。实参是int,对象就得是个装箱(boxing,把标量值转成堆对象)的整数,通过时顺手拆箱(unboxing),裸值直接交给T槽;种类对不上,当场报错:
NLang VM: invalid cast - expected `int`, got `string`
写法长这样:
class Caster<T> {
public T value;
public Caster(T v) { value = v; }
public void keep(Object o) { value = o as T; }
}
Caster<int> c = Caster(0);
Object n = 3;
c.keep(n); // 通过,拆箱后value等于3
Object s = "ab";
c.keep(s); // 抛InvalidCastException,报错:expected `int`, got `string`
空句柄是特例:null转型到任何类型都成立,结果还是null,o as T也一样直通,不查种类。
转型这一点的答案到此为止:能查就当场拦,查不到就往后拖。接下来看异常分派有没有同样的选择权。
三、异常捕获:分派能看见类型实参吗
通用问题:抛出的异常,能不能按泛型实例化的粒度匹配 catch 子句?答案不取决于泛型本身,取决于异常分派读的是哪份数据:只读类记录,就分不开;读得到实例化身份,就分得开。
Java 先给了半个答案 —— 语言层直接禁止。JLS §14.20 要求 catch 参数是可具现(reifiable)类型,也就是运行时形态能完整定下来的类型。List<String>擦除之后不满足这条,catch (List<String> e)编译就被拒;能过关的只剩裸类和catch (List<?> e)这类写法。理由很直接:分派做不到的事,语言就不允许这样写。
C# 相反,catch (Boom<int> e)合法。具现路线给每个实例化造出了运行时真类型,分派读得到实例化身份,写出来就能兑现。
字典路线的情况不同:描述符已经存在,问题在于分派要不要读它。
NLang 这一侧的做法是不读。语义上做得到,代价是每次异常匹配多一次描述符比对,异常表行还要按实例化登记;两相权衡,分派停在类记录这一档,语言层也就不放开 “按实例化接”。
结果反直觉:直觉做法是给异常类填上实参,单独接住某一个实例化。例子用const class声明不可变类,字段在构造之后不再改动;Exception本身就是const class,编译器要求子类照样声明:
const class Boom<T> : Exception {
public T value;
public Boom(T v) { super("boom"); value = v; }
}
try {
Boom<string> b = Boom("x");
throw b;
} catch (Boom<int> e) {
// 想单独处理填int的那个
}
catch (Boom<int> e)编译直接拒绝,报错信息把理由说明得很完整:
catch type cannot be a generic instantiation: exception dispatch matches the template row and cannot tell instantiations apart.
消息里的 template row 就是按非限定名登记的类记录。异常分派比的是类记录,具体走三步。try 语句先编译成一张异常表,每一行登记一个类记录号。抛出时,拿对象身上的类记录逐行去比,沿超类链往上算命中,第一条命中的接管。泛型类的类记录只有一条,表行里也只有类记录号,分派从头到尾没有描述符参与 —— 对象身上那份读不读得到,都不影响命中。
所以假如放行catch (Boom<int> e),抛Boom<string>会照样落进这个 catch,“只管 int” 名不副实。规则不是怕麻烦,是写出来的判定做不到。
裸模板名也不行:catch (Boom e)同样被拒,泛型声明在每个使用位置都要给类型实参,catch 不例外 ——Java 留的原类型(raw type)口子这里没有。能写进 catch 的只剩非泛型的 Throwable 子类 ——Exception和Error各自的分支。
要接住泛型异常,做法是给异常类加一个非泛型基类,catch 捕获基类(下面只列类声明和 catch 子句,try 照常写):
const class BoomBase : Exception {
public BoomBase(string m) { super(m); }
}
const class Boom<T> : BoomBase {
public T value;
public Boom(T v) { super("boom"); value = v; }
}
catch (BoomBase e) {
// Boom<int>、Boom<string>,全部实例化都进这里
}
分派沿超类链往上走,每个实例化都从这个底座派生,一个 catch 全接住。代价是多一层基类;catch 里也读不到e.value,因为e的静态类型是BoomBase,底座不带类型实参,身上没这个字段。要分实例化,得先e as Boom<int>转过去再读 —— 走的是上一节的转型判定,转错了照样抛InvalidCastException。
机制不支持某个粒度时,语言有三条路:放宽机制,让使用者自己补一层,或者不提供这个能力。NLang 选第三条,要接住泛型异常,得靠上一段那个非泛型基类中转。
四、运行时失败:为什么必须分两层
通用问题:运行时异常,该由程序捕获、恢复,还是由虚拟机直接停机?边界划在哪,取决于错误的性质,不取决于它出现在哪段代码里。程序自身犯的错该让使用者接住;实现自身的不变量被破坏、资源耗尽,接住也没有意义 —— 继续跑只会让问题扩散。
最常见的写法是用一个catch (Exception e)包住整段逻辑。它能捕获转型失败,却无法在栈溢出之后让程序继续执行:Java 里catch (Throwable)确实能把Error也接住,可接住之后多数情况什么也补救不了,进程该走还是走。这条线划在哪,不是代码风格问题,是责任归属问题。
NLang 分两层。上面那些落空都抛InvalidCastException,程序可以 catch、可以恢复:转型是一句可能落空的猜测,落空了走异常处理,属于正常流程。
另一层不在异常表里:虚拟机有两种情形会直接抛出宿主的 C++ 异常,用户代码无法捕获。一是发现槽里的内容和指令预期的种类对不上,认定 “机器不该处于这种状态”;二是自身资源用尽,比如递归深度碰到上限。
NLang VM: OP_LoadElementN expects an array slot
NLang VM: recursion limit exceeded
这类消息写给实现者看:报的是操作码名和内部状态,不是向使用者解释输入哪里不对。
分界线落在 “错的是谁”。转型落空,错在程序的判断或输入,接住之后程序还有话可说;槽里的内容和指令预期的种类对不上,说明虚拟机自己走坏了,再走下去只会把错误带进别的对象;递归深度碰到上限则是资源不够,程序本身没有写错,但虚拟机已经无法继续执行。三种情形里,只有第一种还有 “接着走” 的余地。
Java 对同样的局面分层:Error的官方定义就是合理的应用不该试图捕获的严重问题,其中VirtualMachineError一支写明虚拟机已损坏或资源耗尽;.NET 把栈溢出做成 catch 不到的失败。两家的分法,与 NLang 这两层大致对得上。这条线不是泛型带出来的,虚拟机领域一直按错误性质这样划。
对泛型这套实现,这层尤其要紧。这几节依赖的几条性质 —— 描述符驻留后同拼写即同指针、旁表与堆逐格并行、T槽按保守扫描处理 —— 都要求恒真;一旦违例,说明实现自己坏了,不是程序哪里写错。这类违例不表现为接得住的异常,而是直接停机;Debug 构建里的断言还会更早把它们拦下来。
五、结语
三个判定点走完,答案并列如下:
| 判定点 | Java(擦除) | C#(具现) | NLang(字典) |
|---|---|---|---|
| 对象转型:还能不能分辨类型实参 | 只认裸类,错转到取元素时才暴露 | 每个实例化是独立运行时类型,直接比类型对象 | 比实例化描述符,驻留后同拼写即同指针 |
类型参数转型:泛型体内目标含T怎么办 | 擦除到T的上界,只看上界形态 | 运行时类型已在,直接查 | 拼接记录现场拼参照;目标就是T时查帧描述符的种类 |
| catch:能不能按实例化捕获 | 限定可具现类型,同样写不了实例化 | 分得开,可以写 | 只比类记录,禁止写实例化 |
C# 这一列只给结论,依据是同一条:具现路线给每个实例化造出运行时真类型。所以能直接比类型对象、能按实例化写 catch 也落在运行时。更细的行为要看各自规范,这张表停在 “能不能” 这一层。
同一个问题,三种解法,三项开销。NLang 把类型实参写进运行时数据,判定在转型当场完成,代价是每处判定都要读一份外置元数据。Java 擦掉了实参,运行时开销最小,只能把开销推到取元素上。C# 让每个实例化都成为运行时真类型,检查最直接;引用类型共享代码、值类型各留一份副本,代价落在运行时类型数量和值类型的代码体积上。
通用技术启示
四条启示,前面几节都碰到过,这里集中说清:
- 共享代码会引出同一个矛盾:代码一份、实例化多个,凡是运行时需要区分类型的地方都会撞上它。三条路线各是一种答法 —— 多编代码(单态化)、丢掉类型信息(擦除)、把元数据外置(字典)。
- 能分辨多少,取决于手上有什么数据:转型能查到实参,是因为有描述符;catch 查不到,是因为分派只读类记录。不是类型系统强弱的问题,是判定点读哪份数据的问题。
- 底层机制会反向约束上层语法:异常分派只比类记录,泛型异常就天然写不进 catch;C# 能写,是因为具现路线造出了运行时真类型。语言层的 “不能”,往往只是机制面的 “读不到”。
- 错误分层是通用范式:业务可处理的错误和实现自身的失配,归不同的人管。任何运行时都要划这条线,不只是泛型。
这套框架可以直接用于几类判断:估序列化、ORM、依赖注入这类框架在运行时到底分辨得到什么粒度;判断一门语言的泛型能不能撑起反射需求;读 JVM、CLR、Go 运行时代码时,知道该去哪找类型身份。反过来,选型时可以先回答三个问题:要分辨到哪一层、愿意为此付多少存储和查表、失败让程序接还是让虚拟机停。它的适用范围也有边界:回答的是运行时能分辨什么,不评价哪门语言的泛型更好用。
NLang 这一侧的小结
- 对象转型比实例化描述符,驻留之后同拼写即同指针;编译期拼不出规范拼写时,用拼接记录现场拼。
- 异常分派只比类记录,语言层禁止按实例化捕获,要分实例化得靠非泛型基类中转。
- 失败分两层:转型落空抛可捕获异常;不变量违例、资源耗尽直接停机。
这几节的判定读的是同一批数据:旁表、帧描述符、类记录。存储没有新增类别,执行侧只多了一批检查点。问的不是 “在执行哪段代码”,而是 “这个对象是谁”—— 答案贴在对象和调用帧上,指令只带表下标或拼接记录。
还有一项开销没有计入:现场拼装的那部分。每次执行都要先拼一次比对参照。只要调用点的类型实参在编译期就能完整拼出,这一步本可以在编译期做完:在编译期生成完整描述符并写进模块,运行时只剩指针比对。怎么让编译器替运行时把实例化提前造出来,是下一步要解决的事。
参考
- Java Language Specification §14.20(try 语句:catch 参数类型必须是可具现类型)https://docs.oracle.com/javase/specs/jls/se21/html/jls-14.html#jls-14.20
- Oracle Java SE API:Error(合理的应用不应试图捕获的严重问题)https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Error.html
- Oracle Java SE API:VirtualMachineError(虚拟机已损坏或资源耗尽)https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/VirtualMachineError.html
- Microsoft Learn:StackOverflowException(从.NET Framework 2.0 起 try/catch 抓不到,相应进程默认终止)https://learn.microsoft.com/en-us/dotnet/api/system.stackoverflowexception
- 《泛型实现启示(一):模型的选择》https://blog.csdn.net/DLite/article/details/170242246
- 《泛型实现启示(二):实例化共享》https://blog.csdn.net/DLite/article/details/170657445
附录:术语
- 转型(cast):把一个槽里的值按另一个类型来看待。从宽类型转到窄类型也叫向下转型(downcast):值实际是什么只有运行时知道,需要检查。
- 类记录(class metadata):运行时里描述一个类的条目,普通类按全限定名登记,泛型类按模板的非限定名登记。一个泛型模板只登记一条,全部实例化共用。
- 旁表(side table):全进程共用的一张宿主内存表,与堆的记录逐格并行:堆里第 i 个记录是对象,旁表第 i 格存它的实例化描述符。
- 规范拼写(canonical name):实例化在运行时的标准写法:模板的非限定名拼上各类型实参的拼写,嵌套与数组递归拼入。两个实例化是否相同,就定义为规范拼写是否相同。
- 驻留(intern):描述符的去重管理:新建前先查进程级的描述符池,同拼写直接复用。驻留之后,同拼写即同指针,类型相等化简为指针比较。
- 描述符表(descriptor table):随模块存放实例化描述符清单的数据结构,条目是规范拼写或拼接记录,指令带下标取用。链接把各模块相同的行合并成一条。
- 保守扫描(conservative scan):对内容种类未知的存储位置的扫描方式:把这些字节同时当成整数和可能的对象句柄看,像活对象句柄就按活对象处理,宁可多留也不误回收。
T槽用这个方法,因为槽里放裸值还是句柄,取决于类型实参。 - 拼接记录(composition record):带在指令旁边的组装说明,用在编译期拼不出整份拼写的位置;它记模板名和每个类型实参的来源,执行时对着接收者或调用帧的帧描述符现场拼出比对行。
- 帧描述符(frame descriptor):存在调用帧记录里的那一位实例化描述符。实例方法携带接收者身上的那份;自由函数和静态方法读调用点送进帧的那份。
- 擦除(type erasure):泛型的一条实现路线。代码只编一份,编译后类型实参被丢掉,运行时分不清
List<String>和List<Integer>。Java 走这条。 - 具现(reification):泛型的一条实现路线。运行时按实例化造出具体类型;C# 的做法是对引用类型共享代码、对值类型各留一份副本。与单态化的区别:单态化复制代码,具现造运行时类型,C# 两样都做。
- 字典(dictionary):泛型的一条实现路线。代码只编一份,每个实例化另有一份可查的类型记录,运行时凭记录回答 “这是哪个类型”。这份记录可以随对象存,也可以当额外参数传 ——Go 走后者。NLang 走这条。与键值表那种字典容器无关。另有一处同词不同物:Go 实现随调用传递的那个隐藏参数也叫 “字典”(详见第二篇),本篇的 “字典” 只指路线。
- 槽(slot):虚拟机给每个存储位置预留的固定大小一格。标量直接放进槽里,堆对象放的是句柄。
- 句柄(handle):指向堆上对象的引用值,作用相当于一个受垃圾回收管理的指针。
- 装箱(boxing):把标量值转成堆对象,让它能存进只放句柄的位置。
- 拆箱(unboxing):把装箱的堆对象还原成裸值放进槽里,装箱的反操作。
- 异常分派(exception dispatch):抛出的异常和 catch 子句的匹配过程:按抛出位置查异常表,拿异常身上的类记录和表行登记的类记录号沿超类链比对,第一条命中接管。
- 可具现类型(reifiable):Java 术语,运行时形态能完整定下来的类型。擦除之后,带实参的参数化类型不合格;catch 能写哪些类型就按这条线划。
- 不变量(invariant):执行期间应当恒真的结构条件。违例不是程序错误而是实现缺陷,虚拟机直接抛出并终止运行,不给用户代码捕获的机会。
- 宿主(host):嵌入并驱动 NLang 运行时的程序。宿主内存指运行时不经垃圾回收、直接向系统申请的那部分内存,旁表就存放在那里。
内容基于 NLang 0.13.x 版本,以后可能发生变化。
版本下载:github.com/dliting/nlang/releases
转载自 CSDN-专业IT技术社区




