数组和泛型容器有什么区别
要区分数组和泛型容器的功能,这里先要理解三个概念:协变性(covariance)、逆变性(contravariance)和无关性(invariant)。
若类A是类B的子类,则记作A ≦ B。设有变换f(),若:
当A ≦ B时,有f(A)≦ f(B),则称变换f()具有协变性;
当A ≦ B时,有f(B)≦ f(A),则称变换f()具有逆变性;
如果以上两者皆不成立,则称变换f()具有无关性。
在Java中,数组具有协变性,而泛型具有无关性,示例代码如下:
Object[] array = new String[10];
//编译错误
ArrayList<Object> list=new ArrayList<String>();
这两句代码,数组正常编译通过,而泛型抛出了编译期错误,应用之前提出的概念对代码进行分析,可知:
1、String ≦ Object
2、数组的变换可以表达为f(A)=A[],通过之前的示例,可以得出下推论:
f(String) = String[] 以及 f(Object) = Object[];
4、通过代码验证,String[] ≦ Object[] 是成立的,由此可见,数组具有协变性。
又可知:
5、ArrayList泛型的变换可以表达为 f(A)= ArrayList<A>,得出推论:
f(String) = ArrayList<String> 以及 f(Object) = ArrayList<Object>;
6、通过代码验证,ArrayList<String> ≦ ArrayList<Object>不成立,由此可见,泛型具备无关性
最终得出结论,
数组具备协变性,而泛型具备无关性
。
所以,为了让泛型具备协变性和逆变性,Java引入了有界泛型(参见3.1.2小节内容)概念。
除了协变性的不同,
数组还是具象化的,而泛型不是
。
什么是
具象化
(reified,也可以称之为具体化,物化)?
在
《
Java语言规范》
里,明确的规定了具象化类型的定义:
|
不论是在编译时还是运行时,数组都能确切的知道自己的所属的类型。但是泛型在编译时会丢失部分类型信息,在运行时,它又会被当作Object处理。
这里要涉及到类型擦除的相关知识,会在后面详细解释。在当前,只需要知道,Java的泛型最后都被当作
上界
(此概念会在后面说明)处理了。
引申:数组具备协变性,是Java的一个缺陷,因为极少有地方需要用到数组的协变性,甚至,使用数组的协变会引起不易检查的运行时异常,参见下面代码:
Object[] array = new String[10];
array[0] = 1;
很明显,这会在运行期抛出异常:java.lang.ArrayStoreException。
鉴于有如此多的不同,在Java里,数组和泛型是不能混合使用的。参见下面代码:
List<String>[] genericListArray = new ArrayList<String>[10];
T[] genericArray = new T[];
它们都会在编译期抛出Cannot create a generic array错误。这是因为,数组要求类型是具象化(refied)的,而泛型恰好不是。
换言之,数组必须清楚的知道自己内部元素的类型,并且会一直保存这个类型信息,在添加的时候元素的时候,该信息会用于做类型检查,而泛型的类型不确定。所以,在编译器层面就杜绝了这个问题。这在《Java语言规范》里有明确的说明:
|
这不得不说,又是Java在泛型设计上的一点缺陷,为什么Java的泛型设计会有这么多缺陷呢?难道真的是Java语言不够好吗?这些内容将在3.3节泛型历史中解答。
泛型使用建议
泛型在Java开发和设计中占据了重要的地位,如果正确高效的使用泛型尤为重要。下面通过介绍两条使用泛型时的建议,来加深对泛型的理解:
1、泛型类型只能是类类型,不能是基本数据类型,如果要使用基本数据类型作为泛型,应当使用其对应的包装类。比如,如果期望在List中存放整形变量,因为int是基本类型,所以不能使用List<int>,应该使用int的包装类Integer,所以正确的使用方法为List<Integer>。
当然,泛型不支持基本数据类型,试图使用基本数据类型作为泛型的时候必须转化为包装类这点,是Java泛型设计之初的缺陷。
2、使用到集合的时候,尽量的使用泛型集合来替代非泛型集合。一般来说,软件的开发期和维护期时间占比,也是符合二八定律的,维护期的时长能超出开发期数倍。使用了泛型的集合至少,在IDE工具上,是类型确定的,可以提高代码的可读性,并在编译期就避免一些严重的BUG。
3、不要使用常见类名(尤其是String这种属于java.lang的)作为泛型名,会造成编译器无法区分开类和泛型,并且不会抛出异常。
泛型擦除
在学习泛型擦除之前,明确一个概念:
Java的泛型不存在于运行时
。这也是为什么有人说Java没有真正的泛型。
泛型擦除
(类型擦除),它是指在编译器处理带泛型定义的类\接口\方法时,会在
字节码指令集
里抹去
全部泛型类型信息
,被擦除后泛型,在字节码里只保留泛型的原始类型(raw type)。
原始类型
,是指抹去泛型信息后的类型,在Java中,它必须是一个引用类型(非基本数据类型),一般而言,它对应的是泛型的定义上界。
举例:<T>中的T对应的原始泛型是Object,<T extends String>对应的原始类型就是String。
泛型信息会在编译时擦除
如何证明泛型会被擦除呢?这里提供了一段测试代码:
class TypeErasureSample<T> {
public T v1;
public T v2;
public String v3;
}
/**
* 泛型擦除示例
*/
public class Generic3_2 {
public static void main(String[] args) throws Exception {
TypeErasureSample<String> type = new TypeErasureSample<String>();
type.v1 = "String value";
// 反射设置v2的值为整型数
Field v2 = TypeErasureSample.class.getDeclaredField("v2");
v2.set(type, 1);
for (Field f : TypeErasureSample.class.getDeclaredFields()) {
System.out.println(f.getName() + ":" + f.getType());
}
/*
* 此处会抛出java.lang.ClassCastException: java.lang.Integer cannot be cast
* to java.lang.String
*/
System.out.println(type.v2);
}
}
程序运行结果为:
|
v1和v2的类型被指定为泛型T,但是通过反射发现,它们实质上还是Object,而v3原本定义的就是String,和前两项一比对,证明反射本身并无错误。
代码在输出type.v2的过程中抛出了类型转换异常,这说明了两件事:
1、为v2设置整型数已经成功(可以自行写一段反射来验证);
2、编译器在构建字节码的时候,一定做了类似于(String)type.v2的强行转换,关于这一点,可以通过反编译验证(反编译工具为jd-gui),结果如下所示:
public class Generic3_2
{
public static void main(String[] args) throws Exception
{
TypeErasureSample type = new TypeErasureSample();
type.v1 = "String value";
Field v2 = TypeErasureSample.class.getDeclaredField("v2");
v2.set(type, Integer.valueOf(1));
for (Field f : TypeErasureSample.class.getDeclaredFields()) {
System.out.println(f.getName() + ":" + f.getType());
}
System.out.println((String)type.v2);
}
}
可以看到,如果编译器认为type.v2有被申明为String的必要的时候,都会加上(String)强行转换。可以进行测试:
Object o = type.v2;
String s = type .v2;
后者会抛出类型转换异常,而前者是正常执行的。由此,可以得出结论,编译器会在构建字节码的时候,抹去一些泛型信息。
编译器保留的泛型信息有哪些?
上一节中介绍了编译器会擦除全部泛型信息,那么是不是所有的泛型信息都会在编译的过程中消失呢,答案是否定的
,
字节码里指令集之外的地方,会保留部分泛型信息
。下面的泛型在编译阶段是会被保留的:
1、泛型接口、类、方法定义上的所有泛型;
2、成员变量声明处的泛型。
参考下面的代码:
/**
* 定义了泛型参数的接口
*/
interface GI<T> {
}
/**
* 定义了泛型参数并实现了泛型接口的类
*/
class GC<T> implements GI<T> {
// 两种使用了泛型的成员变量
T m1;
ArrayList<T> m2 = new ArrayList<T>();
/**
* 定义了泛型参数的方法,并在返回值、参数和异常抛出位置使用了该泛型
*/
<K extends Exception> ArrayList<K> method(K p) throws K {
// 在方法体中使用了泛型
K k = p;
ArrayList<K> list = new ArrayList<K>();
list.add(k);
return list;
}
}
代码涵盖了泛型的各种声明和使用情况。接下来使用反编译工具看看结果,可以注意到,接口、类、方法定义的位置,大部分泛型信息依然存在,字段中使用到泛型作为声明的位置,泛型同样存在,而在所有在局部代码快对泛型做引用的位置,泛型内容消失了:
|
可以注意到,在之前没有提及的位置,比如GC.m2成员变量的实例化位置,method方法体里的泛型信息全部被擦除。
为什么Java会这么设计?这也很好理解:
1、如果不保留泛型定义,那么除非拥有源码,不然无法使用泛型。
2、即使保留了泛型定义,定义位置的泛型信息并未初始化,也就是说,泛型参数没有绑定为特定的某个类,对使用者不具备意义。而且,泛型信息在运行时也会被处理为上界,对使用并不会有影响。
相信注意细节的读者已经发现了,之前提及的“会被保留泛型信息的位置”里,“异常抛出位置”的K被替换为了Exception,这不正说明它被擦除了?
事实上,如果通过反射来获取泛型信息的时候(方法将在下一小节详细讲解),会发现,依然可以得到异常的泛型信息。得出结论,
作为抛出异常的泛型参数,没有消失
。
这是为什么呢?
既然反编译工具没有记录下泛型信息,只能说明某些反编译工具没有解析二进制文件里的某些信息。这些信息是什么呢?这里要引入的一个概念,
方法签名
(Method Signatrue)。
下面列出的是上一个例子的部分字节码内容(也就是class文件反编译的原始内容):
|
这段内容不长,也无需细看,如果稍微观察下,可以注意到第四行开始就是方法的定义部分,包括返回值ArrayList,参数Exception,抛出的异常Exception,注意到没有?它们,统统不带泛型信息,而在更早之前的位置(1-3行)可以看到三段注释,这就是之前所说的
方法签名了
。
方法签名是方法定义的一部分,它规定了方法的参数列表和返回值等信息。下面来详细解释下各个部分的概念。
第一行:
// Method descriptor #31 (Ljava/lang/Exception;)Ljava/util/ArrayList;
Method descriptor
是标志方法签名的开始。
#ID
是该方法的id号,在同一个方法体内不会重复。
(参数列表)
表示方法有一个Exception类型的形参,类名前的L是引用类型的标记;基础数据类型的标记是对应类型的首字母大写,比如int对应I。数组的标记是在原始标记前加上符号[,比如double[]对应[D,String[]对应[Ljava/lang/String。
最后的位置
是返回值,比如Ljava/util/ArrayList;表示方法的返回值是ArrayList。
第二行:
// Signature: <K:Ljava/lang/Exception;>(TK;)Ljava/util/ArrayList<TK;>;^TK;
Signature
是签名的意思,标识开始的关键字,这一行对应的就是泛型了。
<泛型参数名:上界>
这部分对应的是方法的泛型描述。
(参数列表)
和第一行的大体意思一致,但是多了泛型的定义,在字节码中,泛型会用其上界来替代(擦除),如果没有定义上界,则默认为Object,真正的泛型的定义就出现在本行的这个位置。用T前缀来表示泛型,比如泛型K就对应TK;。
紧跟着参数列表的是返回值
。该返回值描述和第一行的返回值描述一致,不过,同样多了泛型的描述,也是用T前缀来表达,比如返回值是java.util.ArrayList,这里就变为Ljava/util/ArrayList<TK;>;。
^泛型异常
,用于描述用泛型表达的异常,如果异常不是泛型,则该部分描述不会生成。比如throws K就会被描述为^TK;。
第三行:
// Stack: 1, Locals: 2
Stack
,表达的是调用栈(call stack),用于描述在调用栈上
最多
有多少个对象。为什么会有个这个栈呢?是因为“局部变量”这个概念对于虚拟机来说,是不存在的,所以在某个方法被调用前,需要把该方法要用到的变量都加载到一个全局调用栈内。方法被虚拟机唤起的时候,只需要按顺序传入变量类型,然后自动从调用栈里按需取得变量。
每次操作执行完成后,栈被清空,所以,栈深等同为变量最多的操作的变量数。
Locals
,用于描述使用到的本地变量,读者可能会疑惑,该方法里明明只用到了一个形参K,为什么会有两个变量呢?这是因为java默认给方法注册了一个this,作为本地变量。
懂得了字节码的真相,也就懂得了Java泛型的实现原理。
Java的方法泛型没有记录在方法体内部,而是在方法签名内做了实现。同样,可以在字节码里找到类\接口签名,类字段(成员变量)签名等等。
换言之,Java的泛型是由
编译时擦除和签名
来实现的。
Java这样的设计,是为了兼容性的考虑,低版本的字节码和高版本基本上只有签名上的不一样,不影响功能本体,所以,可以不做任何改动就在高版本的虚拟机里运行。
反射获取泛型信息
上一节中提到了如下的一些泛型信息不会被擦除:
1、泛型接口、类、方法定义上处的所有泛型
2、成员变量声明处的泛型
可以得出推论,这些泛型信息应当能够被反射获取。
对这些能被反射获取的内容,按照泛型的分类来进行讨论:
1、泛型接口和泛型类。它们对应的反射对象都是java.reflect.Class,该类提供了三个方法:
public Type getGenericSuperclass(){...}
public Type[] getGenericInterfaces() {...}
public TypeVariable<Class<T>>[] getTypeParameters() {...}
分别对应:获取超类的完整类型,获取接口的完整类型,以及获取自身的类型变量。
java.lang.reflect.Type是一个空接口,在使用标准JDK的情况下,一般来说,泛型的实现类是:sun.reflect.generics.reflectiveObjects.ParameterizedTypeImpl。
它提供了获取原始类型和泛型类型的方法。
java.lang.reflect.TypeVariable是Type的子接口,它提供的方法就比Type要详细一些,这些多出来的方法包括:
Type[] getBound(),获取上界;
D getGenericDeclaration(),获取泛型定义;
String getName(),获取泛型参数名,也就是<T>中的T。
2、声明为泛型的字段。它对应的反射对象是java.reflect.Field,提供了一个方法:
public Type getGenericType() {...}
该方法的使用方式和上文一致。
3、泛型方法。对应的反射对象是java.reflect.Method,提供了三个方法:
public Type getGenericReturnType() {...}
public Type getGenericParameterTypes() {...}
public Type getGenericExceptionTypes() {...}
分别对应返回值泛型,参数泛型和异常泛型。
注意!虽然这里可以获取到泛型的定义,但不论是哪一种方式,其获取到的泛型,都不会是具体的某一个类。给定一个泛型的定义<T>,能获取到的只有T这个关键字。
这是因为,Java目前的泛型实现已经在原理上(泛型擦除)堵死了“反射获取泛型的确定类型”的可能性。
泛型的原理和基本概念到这里已经讲解得差不多了,后面会介绍一下Java泛型的历史,以说明为什么Java的泛型为什么有这么多的“缺陷”。