JEP 草案 通用泛型(预览)
本文为JEP draft: Universal Generics (Preview) (java.net)翻译
总结
允许Java类型变量涵盖引用类型和原始值类型。当类型变量或原始值类型可能被赋值为空时产生警告。这是一个预览语言特性。
非目标
核心原始值类型特性由JEP 401 引入。这个JEP只关心支持原始值类型作为类型参数。
将来,我们希望JVM在Java编译器的帮助下优化原始值类型参数化的性能。但是现在,泛型继续通过擦除来实现。
为了响应这些语言的变化,需要对通用标准库代码进行大规模调整,但是这些调整将在单独的JEP中进行。未来的工作还可能重构手动特化的原始代码的实现。
动机
一个常见的编程任务是将解决特定类型值问题的代码扩展为处理其他类型值的代码。Java开发人员可以使用三种不同的策略来执行这个任务:
- 手动特化 每次使用不同的类型多次重写相同的代码(可能使用复制粘贴)
- 子类多态 将解决方案中的类型更改为所有预期操作数类型的公共超类型
- 参数多态将解决方法中的类型更改为 类型变量,由调用当根据他们需要操作的类型进行实例化
java.util.Arrays.binarySearch 方法 就是这三种解决办法的一个很好的实例
static int binarySearch(Object[] a, Object key)
static <T> int binarySearch(T[] a, T key, Comparator<? super T> c)
static int binarySearch(char[] a, char key)
static int binarySearch(byte[] a, byte key)
static int binarySearch(short[] a, short key)
static int binarySearch(int[] a, int key)
static int binarySearch(long[] a, long key)
static int binarySearch(float[] a, float key)
static int binarySearch(double[] a, double key)
第一个变式使用了子类型多态。它可以用于所有的应用类型数组,Object[]是它们共同的超类
类似地,搜索参数key可以是任何参数,方法的行为取决于参数的动态属性——但是数组的内容和key参数是否支持相互比较呢?
第二个变式使用了参数多态。它可以用于所有的应用类型数组,但是需要调用方提供一个比较函数。参数化的方法签名会在编译期确保对于每一个调用者数组的内容和key参数可以匹配比较函数所支持的类型
其余的变式使用手动特化。它们处理的是基本原始类型的数组,这些数组没有有用的公共超类型。不幸的是,这意味着一个几乎相同的方法有7个不同的副本,给API规范增加了很多干扰,并违反了DRY原则。
在 JEP 401引入的原始值类型是一种新的类型,它允许开发者直接操作自定义的原始值,原始值也可以轻量的方式转为应用类型,而且也支持子类关系。原始值数组也支持这种转化(比如 值数组可以被视为Object[])。因此,原始值类型可以与依赖于子类多态的API一起开箱即用,就像那个Object[]变量的binarySearch一样
不幸的是,Java的参数多态方法只针对引用类型设计。因此,原始值类型,就像基本原始类型(int、double等)一样,不能作为类型参数。如果Point是一个原始值类型,那么尝试用比较函数对Point数组进行排序需要选择一个引用类型作为T的实例化,然后提供一个适用于该引用类型的所有值的比较函数。原始值类型确实附带了一个好用的引用类型——在本例中是Point.ref,但是使用类似于Point.ref的类型作为类型参数会导致一些问题
- 为
Point编写比较函数的方法是使用Point类型的参数。但是要使用通用Comparator接口,lambda表达式需要声明Point.ref类型的参数。(类似地,如果代码需要将Comparator存储在一个局部变量中,变量的类型将是Comparator<Point.ref>。) - 参数类型
Point.ref提高了null输入的可能性,这就意味着这个函数需要处理输入的null值(或许会使用非空断言 - 最重要的是,在将来,我们将通过在寄存器中直接传递打平的
Point值来优化对比较函数的调用。而引用类型Point.ref干扰打平
由于这些原因,如果大多数泛型api除了支持引用类型外还支持原始值类型,这将非常有用。语言可以通过放宽类型参数必须是引用类型的要求,并相应地调整类型变量、边界和推断的处理来实现这一点。
开发人员需要考虑的一个重要方面是,通用类型变量现在可能表示不允许为空的类型。Java编译器可以产生警告,就像Java 5中引入的未经检查的警告一样,提醒开发人员注意这种可能性。语言可以提供一些新的功能来应对警告。
回到基本原始类型的手动特化问题,在 JEP 402 中,语言将被更新为将基本原始类型(译者注:int double 之类的)视为原始值类型。在这一点上,基本原始类型将能够利用子类型和参数多态性,将来的api将不再需要为每个基本基元类型生成手动特化。类型变量将覆盖所有Java类型。
描述
下面描述的特性是预览特性,使用——enable-preview编译时和运行时标志启用。
类型变量和边界
以前,Java的类型变量边界是根据语言的子类型关系来解释的。我们现在认为,如果下列任意一个为真,类型S就被类型T所限制:
- S是T的子类型(其中每个类型都是自身的子类型,引用类型是许多其他类型的子类型,根据它们的类声明和其他子类型进行规范)
- S是一个原始值类型,其对应的引用类型以T为界
- S是一个类型变量,其上界以T为界;或者T是一个有下界的类型变量且S以T的下界为界
通常,类型变量是用上界声明的,而那些没有上界声明的()隐式地有上界Object ()。任何类型都可以作为上界,任何类型都可以作为实例化类型变量的类型参数提供,只要类型参数以类型变量的上界为界。
当Point是一个原始值类型,List的类型是有效的,因为Point以Object为界
因此,类型变量可以覆盖几乎任何类型,并且不再被假定为代表引用类型。
通配符也有界限,同样可以是任何类型。当测试一个参数化类型是另一个参数化类型的子类型时,将执行类似的边界检查。
当原始类Point实现Shape时,List的类型是List<? extends Shape >的子类型 ,而且List是 List<? super Point>的子类型,因为Point被Shape限定
类型参数推断得到了增强,以支持推断基本值类型。 因为原始值类型在类型图边界的位置比引用”低“,所以当推理变量没有相等界时,推理将倾向于使用原始值的下界
(译者:原文为Because primitive value types are "lower" than reference types in the bounded by graph, when an inference variable has no equality bounds, inference will prefer a primitive value lower bound)
调用List.of(new Point(3.0,-1.0))通常会被推断为List,如果它发生在赋值上下文中,目标类型为Collection<Point.ref> ,它将被推断为List<Point.ref>类型
对类型变量、边界检查和推断的这些更改是自动应用的。许多泛型API将平滑地处理原始值类型,而不需要API作者的任何干预。
(To do:与JEP 402结合使用,会有一些源代码兼容性风险,因为现有代码中的类型推断更倾向于int而不是Integer。还存在一个风险,即迁移的、支持引用的原始类的用户将遇到意外的.val类型。需要进一步探索)
null污染和null警告
引用可以为空,但是原始值类型不是引用类型,所以JEP 401禁止将空值赋给原始值类型。
Point p = null; //错误
当我们允许类型变量涵盖更广泛的类型集合时,我们必须要求开发人员对类型变量的实例化做出更少的假设。具体来说,将null赋给具有类型变量类型的变量通常是不合适的,因为类型变量可能由原始值类型实例化。
class C<T> { T x = null; /* 不能这么做 */ }
C<Point> c = new C<Point>();
Point p = c.x; // 错误
在这个例子中,字段x的类型被擦除为Object,所以在运行时C将愉快地存储一个null,即使这违背了编译时类型的期望。这种情况是null污染的一个例子,一种新的堆污染。与其他形式的堆污染一样,当程序试图将一个值赋给一个被擦除类型不支持的变量时(在本例中是对p的赋值),会在运行时检测到该问题。
至于其他形式的堆污染,编译器会产生null警告来阻止空污染:
- 当一个
null字面量被赋值给类型变量的时候会产生一个警告 - 当具有类型变量类型的非
final字段未被构造函数初始化时,将发生警告。
(对于某些值转换还有空警告,将在后面的小节中讨论。)
class Box<T> {
T x;
public Box() {} // warning: 未初始化的字段
T get() {
return x;
}
void set(T newX) {
x = newX;
}
void clear() {
x = null; // warning: null赋值
}
T swap(T oldX, T newX) {
T currentX = x;
if (currentX != oldX)
return null; // warning: null赋值
x = newX;
return oldX;
}
}
现有泛型代码的很大一部分会产生空警告,因为它们是在认为类型变量是引用类型的情况下编写的。我们鼓励开发人员尽可能地更新他们的代码,以消除null污染的来源。
编译时没有null警告的泛型代码可以用原始值类型安全地实例化:它不会引入null污染或下游NullPoiterException风险。
在未来的版本中,泛型代码的物理布局将针对每种原始值类型进行专门化。在这一点上,将更早地检测到null污染,并且未能处理警告的代码可能变得不可用。处理了警告的代码是特化的:未来的JVM增强不会破坏程序的功能。
引用类型变量类型
当泛型代码需要处理null时,该语言提供了一些特殊特性,以确保类型变量类型是(空友好的)引用类型。
-
由
IdentityObject(通过直接或者标识类绑定)限定的类型变量总是引用类型class C<T extends Reader> { T x = null; /* ok */ } FileReader r = new C<FileReader>().x; -
由上下文关键字
ref修改其声明的类型变量禁止非引用类型实参,因此始终是引用类型。
class C<ref T> { T x = null; /* ok */ }
FileReader r = new C<FileReader>().x;
Point.ref p = new C<Point.ref>().x;
-
类型变量的使用可以被语法
.ref修改,它表示从实例化类型到其最紧密的边界引用类型的映射(例如,Point映射到Point.ref,而FileReader映射到FileReader)。class C<T> { T.ref x = null; /* ok */ } FileReader r = new C<FileReader>().x; Point.ref p = new C<Point.ref>().x; Point.ref p2 = new C<Point>().x;
(上面的新语法可能会发生变化)
在最后一种情况下,类型T和T.ref是两个不同的类型变量类型。允许两种类型之间的赋值,作为引用转换或值转换的一种形式。
class C<T> {
T.ref x = null;
void set(T arg) { x = arg; /* ok */ }
}
由IdentityObject限定或用ref修饰符声明的类型变量是引用类型变量。所有其他类型变量都称为通用类型变量。
类似地,命名引用类型变量或具有T.ref形式的类型称为引用类型变量类型,而命名没有.ref的通用类型变量的类型称为通用类型变量类型。
值转化的警告
原始值转换允许将原始引用类型转换为原始值类型,将对象引用映射到对象本身。对于JEP 401,如果引用为空,则在运行时转换会失败。
Point.ref pr = null;
Point p = pr; // NullPointerException
当值转换应用于类型变量类型时,没有运行时检查,但是转换可能是空污染的来源。
T.ref tr = null;
T t = tr; // t 被污染了
为了帮助防止NullPointException和null污染,值转换产生null警告,除非编译器能够证明被转换的引用是非空的。
class C<T> {
T.ref x = null;
T get() { return x; } // warning: 值转化可能是空的
T.ref getRef() { return x; }
}
C<Point> c = new C<>();
Point p1 = c.get();
Point p2 = c.getRef(); // warning: 值转化可能是空的
如果形参、局部变量或final字段具有引用类型变量类型,编译器可能能够在某些用法下证明该变量的值是非空的。在这种情况下,值转换可能不会出现空警告。证明类似于控制流分析,确定变量在使用前是否已经初始化。
<T> T deref(T.ref val, T alternate) {
if (val == null) return alternate;
return val; // no warning
}
参数化类型转化
未经检查的转换传统上允许将原始类型转换为同一类的参数化。这些转换是不合理的,因此伴随着未经检查的警告。
当开发人员对某些类型变量使用应用.ref等更改时,他们可能最终在API签名中使用与其他代码不同步的参数化类型(例如,List< T.ref>;)。为了平稳迁移,允许的未检查转换集被扩展为包括以下参数化到参数化转换:
- 将参数化类型的类型实参从通用类型变量类型(
T)更改为其引用类型(T.ref),反之亦然
List<T.ref> newList() { return Arrays.asList(null, null); }
List<T> list = newList(); // unchecked warning
- 将参数化类型的类型参数从原始值类型(
Point,LocalDate.val)更改为其引用类型(Point. ref,LocalDate),反之亦然
void plot(Function<Point.ref, Color> f) { ... }
Function<Point, Color> gradient = p -> Color.gray(p.x());
plot(gradient); // unchecked warning
- 将参数化类型中的通配符绑定从通用类型变量类型(
T)或原始值类型(Point,LocalDate.val)更改为其引用类型(T.ref,Point.ref,LocalDate),反之亦然(其中子类型不允许转换)
Supplier<? extends T.ref> nullFactory() { return () -> null; }
Supplier<? extends T> factory = nullFactory(); // unchecked warning
- 递归地将未检查的转换应用于任何类型参数或参数化类型的通配符绑定
Set<Map.Entry<String, T>> allEntries() { ... }
Set<Map.Entry<String, T.ref>> entries = allEntries(); // unchecked warning
这些未经检查的转换在小代码段中似乎很容易避免,但它们提供的灵活性将显著简化迁移,因为不同的程序组件或库在不同的时间采用通用泛型。
除了未经检查的赋值之外,这些转换还可以通过未经检查的类型转换和方法重写来使用。
interface Calendar<T> {
Set<T> get(Set<LocalDate> dates);
}
class CalendarImpl<T> implements Calendar<T> {
Set<T.ref> get(Set<LocalDate.val> dates) { ... } // unchecked warning
}
编译到class文件
泛型类和方法继续通过擦除来实现:生成的字节码用它们擦除的界限替换类型变量。因此,在通用api中,基本对象通常作为引用进行操作。
检测堆污染的通常规则适用于此:在某些程序点插入强制转换,以断言某个值具有预期的运行时类型。对于原始值类型,这包括检查值是否为非空。
签名属性被扩展为对编译时类型信息的其他形式进行编码:
- 被声明未
ref T类型变量 - 使用
T.ref形式的类型变量 - 作为类型参数和类型变量/通配符边界出现的原始值类型
其余
我们可以要求开发人员在使用泛型api时始终使用原始引用类型。这不是一个很好的解决方案,正如在动机部分所讨论的那样。
我们还可以要求API作者选择通用类型变量,而不是默认情况下使类型变量通用。但是我们的目标是让通用泛型成为规范,在实践中没有理由大多数类型变量不能是通用的。选择加入会引入太多的摩擦,导致Java生态系统碎片化。
如前所述,基于擦除的编译策略不允许我们对操作原始对象的泛型api所期望的性能。将来,我们希望增强JVM,以允许编译生成针对不同类型参数的异构类。但是,在这个JEP中,通过优先考虑语言的变化,开发人员现在可以编写更具表达性的代码,并使他们的通用api特殊化,同时预期未来的性能改进。
我们可以避免引入新的警告,并接受null污染作为使用原始值类型编程的例行事实。这将带来“更干净”的编译体验,但泛型api在运行时的不可预测性将令人不快。最终,我们希望在泛型api中使用null的开发人员注意并仔细考虑他们的使用如何与原始值类型交互。
在另一种极端情况下,我们可以将部分或所有警告视为错误。但是我们不想引入源代码和迁移的不兼容性——遗留代码和遗留api的用户仍然可以成功编译,即使有新的警告。
风险和假设
这些特性的成功依赖于Java开发人员了解并采用类型变量与null交互的更新模型。新的警告将是高度可见的,它们需要被理解和欣赏,而不是被忽视,这样它们才能产生预期的效果。
在专门化泛型之前使用这些特性会带来一些挑战。一些开发人员可能对性能不满意(对比ArrayList<Point>和Point[] ),并对原始值类型使用泛型的成本产生了不正确的长期直觉。其他开发人员在应用.ref时可能会做出不太理想的选择,直到在特化支持的VM上运行时才注意到任何不良影响,而此时代码已经更改了很长时间。
依赖
JEP 401, 原始对象是基础
后续的JEP将更新标准库,解决空警告并使库做好特化准备。
另一个后续JEP将引入JVM中通用api的运行时特化。