[openjdk][翻译]为类型擦除辩护

最近对Proposals比较感兴趣,看了一点JEP,下周准备看下PEP。
偶然在知乎看到了in-defense-of-erasure这篇文章,对于Glavo说的

“…(同构的泛型)这种方式具有一个强大的优势,这个优势无法由其他方法获得,那就是:逐步迁移的兼容性。这是一种不破坏现有的源代码或二进制类文件的情况下,把非泛型类兼容的转换为泛型类的能力”

不是很理解,因此就翻译了一下来自openjdk官网这篇文章。 这篇文章很棒,值得一看。- 译注


背景故事:我们如何得到现在的泛型的(或者说,我如何学会停止担忧并且热爱类型擦除)

Brian Goetz
June 2020

在我们谈论泛型的去向之前,我们首先要谈谈它们在哪里,以及它们是如何到达那里的。本文档将主要关注我们是如何获得我们现在拥有的泛型的,以及为什么,作为一种手段,为我们现在的泛型将如何影响我们正在尝试构建的“更好”的泛型奠定基础。
特别要强调的是,类型擦除是Java在2004年添加泛型时做出的明智而务实的选择,而且使得我们通过擦除来实现转译的原因在今天仍然适用。

擦除
问任何开发人员关于Java泛型的问题,你很可能会听到他对于擦除的抱怨。因为擦除可能是Java中最广泛的被误解的概念。
擦除并非特指于java,也并非特指于泛型。它通常是将代码翻译为更低等级(比如从java到字节码,从c到源码)必要的工具。这是因为当我们从高级语言到中间表示再到本地代码再到硬件的堆栈时,较低级别提供的类型抽象几乎总是比较高级别提供的更简单、更弱 — 这是正确的(因为我们不想将虚拟分派的语义拷贝到X86指令集中,也不想在寄存器中模仿Java的原始类型集)。理想情况下,擦除是一种在高级别类型进行完备类型检查后,将更高类型级别的表示映射到较低级别的较不丰富类型的技术,也是编译器每天都要做的事情。
例如,Java字节码集包含用于在堆栈和局部变量集(iload、istore)之间移动整数值的指令,以及用于对int(iadd、imul等)进行算术运算的指令。对于浮点(fload、fstore、fmul等)、long(lload、lstore、lmul)、double(dload、dstore、dmul)和对象引用(aload、astore)有类似的指令。) 但对于字节、短字符、字符或布尔值没有这样的指令 — 因为编译器会将这些类型擦除为int,并使用int移动和算术指令。这是字节码指令集设计中的一个实用设计权衡;它降低了指令集的复杂性,从而提高了运行时的效率。Java语言的许多其他特性(例如,检查异常、方法重载、枚举、明确赋值分析、嵌套类、lambdas或局部类捕获局部变量等)都是“语言虚构(language fictions)” — 它们在Java编译器中被检查,但在转换为类文件时被删除。

同构翻译和异构翻译
在具有参数多态性的语言中翻译泛型类型有两种常见方法 — 同构和异构翻译。在同构翻译中,泛型类Foo<T>被翻译为单个artifact(可以理解为编译结果单元–译注),例如Foo.class(泛型方法也是如此)。在异构翻译中,泛型类型或方法(Foo<String>、Foo<Integer>)的每个实例化都被视为单独的实体,并生成单独的artifact。例如,C++使用异构翻译:模板的不同实例化是完全不同的类型,具有不同的语义和不同的生成代码。vector和vector类型是独立的类型。一方面,这对于类型安全(每个实例化可以在扩展后单独进行类型检查)和生成代码的质量(因为每个实例例化可以单独优化)都是非常好的。另一方面,这意味着更大的代码占用空间(因为vector和vector有单独的代码),我们不能谈论“一些的vector”(就像Java通过通配符所做的那样),因为每个实例化都是完全不相关的类型。
作为可能占用空间成本的一个极端演示,Scala有一个@specialized注解,当应用于类型变量时,它会导致编译器为所有原始类型发出专用版本。这听起来很酷,但会导致生成的类激增,其中是类中专用类型变量的数量,因此可以从几行的代码中轻松生成100MB的JAR。
同构翻译和异构翻译之间的选择涉及到语言设计者一直做出的各种权衡。异构翻译提供了更多的类型特异性,代价是静态和动态占用空间更大,运行时共享更少 — 所有这些都具有性能影响。同构翻译更容易抽象参数化的类型族,例如Java的wildcards或C#的 declaration-site variance (这两者都是C++所缺乏的,对于C++,vector和vector之间没有共同点)。

Java类型擦除
Java使用同构翻译来翻译泛型。泛型在编译时进行类型检查,但是当生成字节码时,像List<String>这样的泛型类型会被擦除到List中,而像<T extends Object>这样的类型变量会在和它的绑定一起被擦除(在本例中为Object)。
例如:

class Box<T> {
    
    
    private T t;

    public Box(T t) {
    
     this.t = t; }

    public Box<T> copy() {
    
     return new Box<>(t); }

    public T t() {
    
     return t; }
}

javac编译器产生一个类文件:Box.class,Box的所有实例化的实现 — 包括:通配符(Box<?>)、原始类型(Box)、字段、方法和超类描述符都被擦除。类型变量擦除到边界,泛型类型被擦除到head(比如List<String>擦除到List),如下所示:

class Box {
    
    
    private Object t;

    public Box(Object t) {
    
     this.t = t; }

    public Box copy() {
    
     return new Box(t); }

    public Object t() {
    
     return t; }
}

泛型签名被保留在Signature属性中,以便编译器在读取类文件时可以看到泛型签名,但JVM仅在链接中使用擦除的描述符。这种转换方案意味着在class文件级别,Box<T>的布局和API都被删除。在使用时,对Box<String>的引用被删除为Box,在使用时则有对于String的隐式转换。

为什么?有哪些选择?
正是在这一点上,人们很容易发怒并宣称这些显然是愚蠢或懒惰的选择,或者擦除是一种肮脏的做法。毕竟,为什么编译器会丢弃完美的类型信息?

为了更好地理解这个问题,我们还应该问:如果我们将这种类型的信息具体化,我们希望用它做什么,以及与之相关的成本是什么?我们可以设想使用具体化类型参数信息的几种不同方式:

扫描二维码关注公众号,回复: 15949464 查看本文章
  • 反射 对于一些人来说,“具体化泛型”仅仅意味着你可以问List它是什么列表,无论是使用instanceof或模式匹配之类的语言工具,还是使用反射库来查询类型参数。

  • 布局或API特化。在具有原始类型或内联类的语言中,可以将Pair<int,int>的布局flattern,以容纳两个int,而不是两个对装箱对象的引用。

  • 运行时类型检查。当客户端试图将Integer放入List<String>中(例如,通过原始List引用),这将导致堆异常时,最好能捕捉到这一点,并在导致堆异常的时候失败,而不是在它到达合成强制转换时检测到它。

虽然这三种可能性并非相互排斥,但它们(反射、专门化和类型检查)有助于实现不同的目标(程序员的便利性、性能和安全性) — 并且具有不同的含义和成本。虽然说“我们想要reification”很容易,但如果我们深入研究,我们会发现其中哪些是最重要的,以及它们的相对成本和收益存在重大分歧。

为了理解擦除在这里是多么明智和务实的选择,我们还必须了解当时的目标、优先事项和限制以及备选方案。

目标:逐步迁移兼容性 Gradual migration compatibility
Java泛型采用了一个雄心勃勃的要求:

必须能够以二进制兼容和源码兼容的方式将现有的非泛型类演化为泛型。

这意味着现有的客户端和子类(比如ArrayList)可以继续重新编译,而不需要对泛化的ArrayList<T>进行更改,并且现有的类文件将继续链接到泛化的数组列表<T>的方法。支持这一点意味着客户机和泛化类的子类可以选择立即泛化、稍后泛化或从不泛化,并且可以独立于其他客户机或子类的维护者选择做什么。

如果没有这一要求,泛化一个类将需要一个“flag day”,在这个日子里,如果不修改,所有客户端和子类至少都必须重新编译 — 一次完成。对于ArrayList这样的核心类来说,这基本上要求世界上所有的Java代码都立即重新编译(或永久降级以保留在Java 1.4上),我们需要一个泛型类型系统,它允许核心平台类(以及流行的第三方库)被泛型,而不需要客户端知道它们的泛型。(更糟糕的是,这不会是一个flag day,而是很多,因为世界上所有的代码都不会在一个原子事务中被泛化。)

另一种表述这一需求的方式是:放弃所有本来可以被泛型化的代码,或者让开发人员在泛型和保留他们在现有代码中已经做出的实现之间做出选择,这是不可接受的。通过使泛化成为一种兼容的操作,可以保留对该代码的实现,而不是使其无效。

对“flag day”的厌恶来自Java设计的一个重要方面:**Java是单独编译和动态链接的。**单独编译意味着每个源文件都被编译成一个或多个类文件,而不是将一组源编译成单个工件。动态链接意味着类之间的引用在运行时基于符号信息进行链接;如果类C调用D中的方法void m(int x),那么在C的类文件中,我们将记录正在调用的方法的名称和描述符((I)V),在链接时,我们将在D中查找具有此名称和描述符的方法,如果找到匹配项,则会链接调用点。

这听起来可能需要做很多工作,但独立编译和动态链接是Java最大的优势之一 — 可以针对一个版本的D编译C,并在类路径上使用不同版本的D运行(只要您不在D中进行任何二进制不兼容的更改)。

对动态链接的普遍承诺使我们能够简单地在类路径上放置一个新的JAR来更新到依赖的新版本,而无需重新编译任何东西。我们经常这样做,甚至不会在意这一点— 但如果这种方法停止了,它确实会被注意到。

在泛型被引入Java时,世界上已经有很多Java代码,它们的类文件中充满了对Java.util.ArrayList等API的引用。如果我们不能兼容地泛化这些API,那么我们就必须编写新的API来替换它们,更糟糕的是,旧API的所有客户端代码都会被一个无法支持的选择所困扰 — 要么永远使用1.4,要么重写它们以同时使用新的API(不仅包括应用程序代码,还包括应用程序所依赖的所有第三方库)。这将使当时存在的几乎所有Java代码变得没有价值。

C#做出了相反的选择 — 更新他们的VM实现,并使他们现有的库和依赖它的所有用户代码无效。当时他们可以这样做,因为世界上C#代码相对较少;Java当时没有这个选项。

然而,这种选择的一个结果是,泛型类将同时具有泛型和非泛型用户或子类,这是一种可预期的情况。这对软件开发过程非常有帮助,但在这种混合使用下,它对类型安全有潜在的后果。

堆污染 heap pollution
以这种方式擦除,并支持通用客户端和非通用客户端之间的互操作性,会造成堆污染的可能性 — box中存储的运行时类型与预期的编译时类型不兼容。当客户机使用Box<String>时,每当将T赋值给String时,就会插入强制转换,以便在数据从类型变量世界(Box的实现)转换到具体类型世界时检测到堆污染。在存在堆污染的情况下,这些强制转换可能会失败。

堆污染可能来自于非泛型代码使用泛型类,或者我们使用未经检查的强制转换或原始类型伪造对错误泛型类型的变量的引用。(当我们使用未检查的强制转换或原始类型时,编译器会警告我们可能会导致堆污染。)
例如:

Box<String> bs = new Box<>("hi!");   // safe
Box<?> bq = bs;                      // safe, via subtyping
Box<Integer> bi = (Box<Integer>) bq; // unchecked cast -- warning issued
Integer i = bi.get();                // ClassCastException in synthetic cast to Integer

此代码中的问题在于Box<?>中未检查的强制转换到Box;我们必须让开发人员相信,指定的Box实际上是一个box<Integer>。但堆污染并没有立即被捕获;只有当我们尝试使用框中的字符串作为整数时,我们才能检测到出问题。在我们的转换下,如果我们在将box用作box<String>之前将其转换为box<Integer>,然后再将其转换回box<String>,则不会发生任何不好的情况(无论好坏)。

Java实际上为泛型提供了相当强大的安全保障,只要我们遵循以下规则:

如果程序编译时没有unchecked或raw warning,则编译器插入的合成强制转换永远不会失败。

换句话说,堆污染只能在我们与非泛型代码进行互操作或对编译器撒谎时发生。在发现堆污染时,我们会得到一个简单明了的异常,告诉我们预期的类型和实际的类型。

context:JVM实现和语言的生态系统

围绕泛型的设计选择也受到JVM实现和JVM上运行的语言的生态系统结构的影响。虽然对大多数开发人员来说,“Java”是一个整体实体,但实际上Java语言和Java虚拟机(JVM)是独立的实体,每个实体都有自己的规范。Java编译器为JVM生成类文件(其格式和语义在Java虚拟机规范中有规定),但是JVM将很高兴地运行任何有效的类文件,而不管它最初来自什么源语言。据统计,有200多种语言使用JVM作为编译目标,其中一些语言与Java语言有很多共同点(例如Scala、Kotlin),另一些语言则非常不同(例如JRuby、Jython、Jaskell)

JVM作为编译目标如此成功的一个原因,即使对于与Java完全不同的语言,也是因为它为计算提供了一个相当抽象的模型,而Java语言的影响有限。语言和虚拟机之间的抽象层不仅有助于刺激JVM上运行的其他语言的生态系统,也有助于JVM的独立实现的生态系统。尽管今天的市场已经得到了实质性的整合,但在Java中添加泛型时,JVM有十几种商业上可行的实现。具体化泛型意味着我们不仅需要增强语言以支持泛型,还需要增强JVM。

虽然在当时技术上可能向JVM添加泛型支持,但这不仅是一项重大的工程投资,需要许多实现者之间的大量协调和协议,JVM上的语言生态系统也可能对具体化的泛型有意见。例如,如果具体化(reification) 的解释包括运行时的类型检查,Scala(及其declaration-site variance,比如逆变和协变)是否愿意让JVM执行Java的(不变的)通用子类型规则?

擦除是最务实的妥协
综上所述,这些约束(技术和生态系统)起到了强大的推动作用,推动我们采用同构翻译策略,在编译时删除泛型类型信息。总之,推动我们做出这一决定的力量包括:

  • 运行时间成本。异构泛型需要各种各样的运行时成本:更大的静态和动态占用空间、更大的类加载成本、更高的JIT成本和代码缓存压力等。这可能会让开发人员不得不在类型安全和性能之间做出选择。

  • 迁移兼容性。当时还没有一个已知的翻译方案可以允许将具体化的泛型移植到源代码和二进制代码兼容的地方,这就造成了flag day,并使开发人员对现有代码的大量投入无效。

  • 运行时间成本。如果reification被解释为在运行时检查类型(就像Java的协变数组中的存储被动态检查一样),这将对运行时产生重大影响,因为JVM必须在运行时使用语言的泛型类型系统对每个字段或数组元素存储执行泛型子类型检查。(当类型是List<String>这样简单的类型时,这听起来很容易,也没开销,但当类型是Map<?extends List<?super-Foo>>、?super-Set<?extensed Bar>时,这可能会很快变得开销很大。(事实上,后来的研究对泛型子类型的可判定性提出了质疑)。

  • JVM生态系统。让十几个JVM供应商就是否以及如何在运行时具体化类型信息达成一致是一个非常值得怀疑的命题。

  • 交付实用主义。即使有可能让十几个JVM供应商就一个实际可行的方案达成一致,这也会大大增加本已相当庞大且具有风险的工作的复杂性、时间和风险。

  • 语言生态系统。Scala这样的语言可能不愿意让Java的不变泛型融入JVM的语义。就JVM中泛型的一组可接受的跨语言语义达成一致,将再次增加复杂性、时间表和本已相当大且有风险的工作的风险。

  • 无论如何,用户都必须处理擦除(以及堆污染)。即使在运行时可以保留类型信息,在类被泛化之前,总会有有问题类文件被编译,因此堆中的任何给定ArrayList都有可能没有附加类型信息,并伴随着堆污染的风险。(这里稍微有些不理解–译注)

  • 某些有用的习语是无法表达的。当现有的泛型代码知道一些编译器不知道的运行时类型,并且没有简单的方法在泛型类型系统中表达时,它偶尔会求助于未检查的强制转换;对于具体化的泛型来说,这些技术中的许多都是不可能的,这意味着它们必须以不同的方式表达,而且往往要昂贵得多。

显然,成本和风险将是巨大的;有什么好处?前面,我们列举了具体化(reification)的三个可能好处:反射、布局专门化和运行时类型检查。上述论点基本上排除了我们可能会进行运行时类型检查(运行时成本、不可判定性风险、生态系统风险和已擦除实例的存在)的可能性。

当然,如果能问List它的元素类型是什么(也许它可以回答,但可能不是),那就太好了 — 这显然有收益。只是成本和收益相差几个数量级。(所选泛型策略的另一个代价是不能将primitives作为类型参数;我们必须使用List<Integer>而不是List<Int>。)

擦除是“a dirty hack”这一常见误解通常源于对替代品的真正成本缺乏认识,包括工程努力、上市时间、交付风险、性能、生态系统影响,我们必须考虑到已经编写的大量Java代码以及JVM实现和运行在JVM上的语言的多样生态系统。

猜你喜欢

转载自blog.csdn.net/treblez/article/details/127468821
今日推荐