ITEM 32: COMBINE GENERICS AND VARARGS JUDICIOUSLY
可变参数(item 53)和泛型都是在Java 5中添加到平台中的,您可能希望它们能够优雅地交互;遗憾的是,他们没有。可变参数的目的是允许用户向方法传递可变数量的参数,但这是一个有漏洞的抽象:当您调用可变参数方法时,将创建一个数组来保存可变参数;该数组是可见的,它应该是一个实现细节。因此,当可变参数具有泛型或参数化类型时,您会得到混淆的编译器警告。
回顾item 28,非具体化类型是指运行时表示的信息少于编译时表示的信息,并且几乎所有泛型和参数化类型都是不可具体化的。如果方法声明其可变参数为不可具体化类型,则编译器将对声明生成警告。如果方法是在可变参数上调用的,而这些参数的推断类型是不可具体化的,那么编译器也会在调用时生成一个警告。警告如下:
“warning: [unchecked] Possible heap pollution from parameterized vararg type List<String>”
堆污染发生在参数化类型的变量引用非该类型的对象时[JLS, 4.12.2]。它可能导致编译器自动生成的强制转换失败,违反泛型类型系统的基本保证。
例如,考虑这个方法,它是 P127 代码片段的一个稍加掩饰的变体:
// Mixing generics and varargs can violate type safety!
static void dangerous(List<String>... stringLists) {
List<Integer> intList = List.of(42);
Object[] objects = stringLists;
objects[0] = intList; // Heap pollution
String s = stringLists[0].get(0); // ClassCastException
}
此方法没有可见的强制转换,但在使用一个或多个参数调用时将引发ClassCastException。它的最后一行有一个由编译器生成的不可见强制转换。此转换失败,说明类型安全已被破坏,在泛型可变数组参数中存储值是不安全的。
这个例子提出了一个有趣的问题:当显式创建泛型数组是非法的时,为什么用泛型可变参数声明方法是合法的?换句话说,为什么前面显示的方法只生成警告,而第127页上的代码片段生成错误?答案是,具有泛型或参数化类型的可变参数的方法在实践中非常有用,因此语言设计人员选择接受这种不一致。事实上,Java库导出了几个这样的方法,包括 Arrays.asList(T... a),Collections.addAll(Collection<? super T> c, T... elements) 以及 EnumSet.of(E first, E... rest)。与前面的 dangerous 方法不同,这些库方法是类型安全的。
在Java 7之前,使用泛型可变参数的方法的作者对调用时产生的警告无能为力。这使得这些api难以使用。用户不得不忍受这些警告,或者最好在每个调用上使用 @SuppressWarnings(“unchecked”) 注释消除它们(item 27)。这很乏味,损害了可读性,并隐藏了标记实际问题的警告。
在Java 7中,SafeVarargs 注解被添加到平台中,以允许具有泛型可变参数的方法的作者自动抑制用户端的警告。本质上,SafeVarargs 注解构成了方法作者的一种承诺,即它是类型安全的。作为交换,编译器同意不警告用户调用可能不安全的方法。
重要的是,除非方法是安全的,否则不要用 @SafeVarargs 注解方法。那么,怎样才能确保这一点呢?回想一下,调用方法时创建一个泛型数组来保存可变参数。如果方法不将任何内容存储到数组中(这会覆盖参数),并且不允许对数组的引用转义(这会使不受信任的代码能够访问数组),那么它就是安全的。换句话说,如果可变参数数组仅用于将变量数量的参数从调用者传输到方法(这毕竟是可变参数的目的),那么该方法是安全的。
值得注意的是,您可以违反类型安全性,而无需在可变参数数组中存储任何内容。考虑下面的泛型可变参数方法,它返回一个包含其参数的数组。乍一看,它似乎是一个方便的小工具:
// UNSAFE - Exposes a reference to its generic parameter array!
static <T> T[] toArray(T... args) {
return args;
}
这个方法只返回它的可变参数数组。这种方法可能看起来不危险,但确实很危险!这个数组的类型由传递给方法的参数的编译时类型决定,而编译器可能没有足够的信息来进行准确的判断。因为这个方法返回它的可变参数数组,所以它可以将堆污染传播到调用堆栈。
为了使其具体化,考虑下面的泛型方法,它接受三个 T type参数,并返回一个数组,其中包含随机选择的两个参数:
static <T> T[] pickTwo(T a, T b, T c) {
switch(ThreadLocalRandom.current().nextInt(3)) {
case 0: return toArray(a, b);
case 1: return toArray(a, c);
case 2: return toArray(b, c);
}
throw new AssertionError(); // Can't get here
}
这个方法本身并不危险,并且不会生成警告,除非它调用toArray方法,该方法有一个泛型可变参数。
编译这个方法时,编译器生成代码来创建一个可变参数数组,在这个参数数组中,要将两个 T 实例传递给 toArray。这段代码分配一个 Object[] 类型数组,这是保证保存这些实例的最特定的类型,无论在调用站点上传递给pickTwo 的对象类型是什么。toArray 方法简单地将这个数组返回给pickTwo,而 pickTwo 又返回给它的调用者,所以 pickTwo 总是返回一个Object[]类型的数组。
现在考虑这个主要的方法,它测试了pickTwo:
public static void main(String[] args) {
String[] attributes = pickTwo("Good", "Fast", "Cheap");
}
这个方法没有任何问题,所以它在编译时不会生成任何警告。但是当您运行它时,它会抛出一个 ClassCastException,尽管它不包含可见的强制转换。您没有看到的是编译器对 pickTwo 返回的值生成了一个隐藏的 String[]强制转换,以便它可以存储在属性中。转换失败,因为 Object[] 不是String[] 的子类型。这个失败非常令人不安,因为它从实际导致堆污染的方法(toArray)中删除了两个级别,并且在实际参数存储在可变参数数组中之后,没有修改可变参数数组。
这个例子是为了让人们认识到:给另一个方法访问一个泛型的可变参数数组是不安全的,这里有两个例外:将数组传递给另一个带@SafeVarargs 注解的可变参数方法是安全的;将数组传递给只计算一个数组内容的非可变参数方法也是安全的。
下面是一个安全使用泛型可变参数的典型例子。该方法接受任意数量的列表作为参数,并按顺序返回一个包含所有输入列表元素的列表。由于该方法使用 @SafeVarargs 注解,因此在声明或调用站点上不会生成任何警告:
// Safe method with a generic varargs parameter
@SafeVarargs
static <T> List<T> flatten(List<? extends T>... lists) {
List<T> result = new ArrayList<>();
for (List<? extends T> list : lists)
result.addAll(list);
return result;
}
决定何时使用 @SafeVarargs 注解的规则很简单:对每个具有泛型或参数化类型的可变参数的方法使用 @SafeVarargs,这样它的用户就不会受到不必要的和令人困惑的编译器警告的影响。这意味着永远不要编写像dangerous 或 toArray 这样不安全的可变参数方法。每当编译器警告您所控制的方法中的泛型可变参数可能造成堆污染时,请检查该方法是否安全。提醒一下,如果:
- 它没有在可变参数数组中存储任何东西,并且
- 它不会使数组(或克隆)对不受信任的代码可见。
如果违反了其中任何一条禁令,请修复它。
注意,@SafeVarargs 注解只对不能被覆盖的方法合法,因为不可能保证所有可能覆盖的方法都是安全的。在 Java 8 中,注解只对静态方法和最终实例方法合法;在Java 9中,在私有实例方法上注解也是合法的。
使用 @SafeVarargs 注释的另一种方法是采纳 item 28 的建议,用列表参数替换可变参数(它是一个伪装的数组)。下面是将这种方法应用到 flatten 方法时的效果。注意,只有参数声明发生了变化:
// List as a typesafe alternative to a generic varargs parameter
static <T> List<T> flatten(List<List<? extends T>> lists) {
List<T> result = new ArrayList<>();
for (List<? extends T> list : lists)
result.addAll(list);
return result;
}
此方法可以与静态工厂方法列表一起使用,允许可变数量的参数。注意,下面的方法依赖 带有注释 @SafeVarargs 的方法 List.of:
audience = flatten(List.of(friends, romans, countrymen));
这种方法的优点是编译器可以证明该方法是类型安全的。您不需要使用@SafeVarargs 注解来保证它的安全性,也不需要担心在确定它是安全的时候可能会出错。主要的缺点是客户端代码比较冗长,并且可能比较慢。这个技巧也可以在无法编写安全的可变参数方法的情况下使用,比如P147的 toArray 方法。它的列表模拟 List.of。我们甚至不需要写它;Java库的作者已经为我们完成了这项工作。然后 pickTwo 方法变成:
static <T> List<T> pickTwo(T a, T b, T c) {
switch(rnd.nextInt(3)) {
case 0: return List.of(a, b);
case 1: return List.of(a, c);
case 2: return List.of(b, c);
}
throw new AssertionError();
}
测试它的方法是这样的:
public static void main(String[] args) {
List<String> attributes = pickTwo("Good", "Fast", "Cheap");
}
生成的代码是类型安全的,因为它只使用泛型,而不使用数组。
总之,可变参数和泛型不能很好地交互,因为可变参数是构建在数组之上的脆弱抽象,而且数组与泛型具有不同的类型规则。虽然泛型可变参数不是类型安全的,但是它们是合法的。如果选择使用泛型(或参数化)可变参数编写方法,首先要确保该方法是类型安全,然后使用 @SafeVarargs 注解,这样使用起来就不会感到不舒服。