楔子
关于 Java 类型系统的一道题目。
发现在思考这个问题的同时,可以发散到协变与逆变、Java 的泛型、类型擦除,所以记录一下。
public static void f() {
String[] a = new String[2];
Object[] b = a;
a[0] = "hi";
b[1] = Integer.valueOf(42); // ArrayStoreException
}
执行这个方法会在最后一行抛出异常,而问题在第二行赋值时就「开始」了。
Java 的数组设计上是支持协变的,也就是说子类数组可以赋值给父类数组,行为上和多态有些类似,但是并不会实际改变数组元素的实际类型。
Java 的数组不同于泛型,数组在运行时会进行类型检查。所以可以编译通过,但是运行时抛出 ArrayStoreException
异常。
所以如果从本质上,原理上来说,现在很多讨论的观点认为,Java 数组支持协变就是一个设计缺陷。
为什么设计上支持协变
形式定义
- 协变(covariant),如果它保持了子类型序关系 ≦ 。该序关系是:子类型 ≦ 基类型。
- 逆变(contravariant),如果它逆转了子类型序关系。
- 不变(invariant),如果上述两种均不适用。
考虑数组类型构造器: 从Animal
类型,可以得到Animal[]
。 是否可以把它当作
- 协变:一个
Cat[]
也是一个Animal[]
- 逆变:一个
Animal[]
也是一个Cat[]
- 以上二者均不是(不变)?
如果要避免类型错误,且数组支持对其元素的读、写操作,那么只有第3个选择是安全的。Animal[]
并不是总能当作 Cat[]
,因为当一个客户读取数组并期望得到一个 Cat
,但 Animal[]
中包含的可能是个 Dog
。所以逆变规则是不安全的。
反之,一个 Cat[]
也不能被当作一个 Animal[]
。因为总是可以把一个 Dog
放到 Animal[]
中。在协变数组,这就不能保证是安全的,因为背后的存储可以实际是 Cat[]
。
因此协变规则也不是安全的—数组构造器应该是不变。注意,这仅是可写数组的问题;对于只读数组,协变规则是安全的。
这示例了一般现像。只读数据类型是协变的;只写数据类型是逆变的。可读可写类型应是「不变」的。
回到 Java 来说,在 SE5 之前还没有泛型,使数组为「不变」将导致许多有用的多态被排除。
对于一个排序(sort)数组的方法,使用 Object
与 equals
方法。函数的实现并不依赖于数组元素的确切类型,因此可以写一个单独的实现而适用于所有的数组:
void sort(Object[] a);
如果数组类型被处理为「不变」,那么它仅能用于确切为 Object[]
类型的数组。对于字符串数组等就不能做重排操作了。
所以,Java把数组类型处理为协变。在Java中,String[]
是Object[]
的子类型。
协变数组在写入数组的操作时会出问题。Java 为此把每个数组对象在创建时附标一个类型。 每当向数组存入一个值,编译器插入一段代码来检查该值的运行时类型是否等于数组的运行时类型。如果不匹配,就会抛出一个ArrayStoreException
。
这个方法的缺点是留下了运行时错误的可能。C# 早期也是没有泛型,所以对于数组,也是如此设计的。
泛型
泛型(generic) 允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。不同语言中也称之为参数多态(parametric polymorphism)、模板(Template)。
Java 有了泛型后,就可以类型安全的编写类似排序的这种多态函数,可以给定参数类型。
java.util.Arrays
public static <T> void sort(T[] a, Comparator<? super T> c) {
if (c == null) {
sort(a);
} else {
if (LegacyMergeSort.userRequested)
legacyMergeSort(a, c);
else
TimSort.sort(a, 0, a.length, c, null, 0, 0);
}
}
类型擦除
Java 的泛型是通过类型擦除(type erasure)实现的。
Java 泛型的类型参数的实际类型在编译时会被消除,所以无法在运行时得知其类型参数的类型,而且无法直接使用基本值类型作为泛型类型参数。
在编译泛型时会自动加入类型转换的编码。由于运行时会消除泛型的对象实例类型信息等缺陷经常被人诟病,Java 及 JVM 的开发方面也尝试解决这个问题,例如 Java 通过在生成字节码时添加类型推导辅助信息,从而可以通过反射接口获得部分泛型信息。
以下三个 List 的实际类型都是 List<Object>
。
可以反编译 class 文件,会发现调用诸如 get(0);
的方法,都会加上强转 (String) get(0);
List rawList = new ArrayList();
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
这么看 Java 的泛型就像是「语法糖」。这种实现方式的好处在于不必修改 JVM,减少了潜在改动带来的风险。
与 Java 的泛型停留在编译不同,C# 的泛型则是做到了 CIL 层。在 C# 中,每个泛型接口的类型参数都可被标注为协变(out)、逆变(in)或不变(不标注)。
泛型通配符
Java 通过通配符提供使用点变型标记,一个参数化类型可以通过通配符 ?
加上上下界的形式实例化。
List<T>
List<? extends T>
,上界List<? super T>
, 下界List<?>
,等价于List<? extends Object>
对于一个确定的泛型类型参数,List<String>
,它是没有协变性的,是不变的。List<String>
不是 List<Object>
的子类。自然做不到如下方式的赋值
List<String> strings = new ArrayList<>();
List<Object> objects = strings;
这样就避免了最上方数组示例中会出现的问题。
通配符类型则是在上界协变,在下界逆变的。
extends
修饰的泛型容器,限定了上界,不可写。编译器只知道容器内是 Number 或者它的子类,但是具体是什么类型并不知道。编译器只是标记一个「占位符」,来表示捕获 Number 或者它的子类。无论对容器 add 什么,都不知道能不能与「占位符」匹配,所以除了 null,什么都不可以添加。
List<? extends Number> list = new ArrayList<>();
list.add(null);
// error! 'add(capture<? extends java.lang.Number>)' in 'java.util.List' cannot be applied to '(int)'
list.add(1);
super
修饰的泛型容器,限定了下届,不可读。或者说只能读出 Object 。下届限定了下限,放宽了元素的类型限制,既然「描述」的元素是 T
的基类,那么往容器中添加粒度比 T
小的都可以。往外读的时候就只能用所有元素的基类 Object 才能描述。
class A {}
class B extends A {}
class C extends B {}
class D extends C {}
public void f() {
List<? super B> list = new ArrayList<>();
list.add(new C());
list.add(new D());
for (Object o : list) {
System.out.println(o);
}
}
PECS
此时对于 PECS(Producer Extends, Consumer Super. Joshua Bloch 在 《Effective Java》 中提出的助记短语) 就更加容易理解了。
- 对于频繁读取内容的,适合使用上界
extends
- 对于频繁往里插入的,适合使用下届
super
java.util.Collections
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
int srcSize = src.size();
if (srcSize > dest.size())
throw new IndexOutOfBoundsException("Source does not fit in dest");
if (srcSize < COPY_THRESHOLD ||
(src instanceof RandomAccess && dest instanceof RandomAccess)) {
for (int i=0; i<srcSize; i++)
dest.set(i, src.get(i));
} else {
ListIterator<? super T> di=dest.listIterator();
ListIterator<? extends T> si=src.listIterator();
for (int i=0; i<srcSize; i++) {
di.next();
di.set(si.next());
}
}
}
使用 extends
与 super
,保证了从 src
中取出的元素一定是 dest
元素的子类或类型相同,这样就不会在拷贝时产生类型安全问题。
这个方法为什么不设计为这样呢?
public static <T, S extends T> void copy(List<T> dest, List<S> src) {
...
}
可以参考 The Java™ Tutorials - Generic Methods 中的回答
It is possible to use both generic methods and wildcards in tandem. Here is the method
Collections.copy()
:
class Collections {
public static <T> void copy(List<T> dest, List<? extends T> src) {
...
}
Note the dependency between the types of the two parameters. Any object copied from the source list,
src
, must be assignable to the element typeT
of the destination list,dst
. So the element type ofsrc
can be any subtype ofT
—we don’t care which. The signature ofcopy
expresses the dependency using a type parameter, but uses a wildcard for the element type of the second parameter.We could have written the signature for this method another way, without using wildcards at all:
class Collections {
public static <T, S extends T> void copy(List<T> dest, List<S> src) {
...
}
This is fine, but while the first type parameter is used both in the type of
dst
and in the bound of the second type parameter,S
,S
itself is only used once, in the type ofsrc
—nothing else depends on it. This is a sign that we can replaceS
with a wildcard. Using wildcards is clearer and more concise than declaring explicit type parameters, and should therefore be preferred whenever possible.
S
仅仅在 src 类型中使用了一次,其他都不依赖于它。使用通配符比声明显式类型参数更清晰,更简洁。