《徐徐道来话Java》(2):泛型和数组,以及Java是如何实现泛型的
数组和泛型容器有什么区别
要区分数组和泛型容器的功能,这里先要理解三个概念:协变性(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,也可以称之为具体化,物化)?
完全在运行时可用的类型被称为具象化类型(refiable type),会做这种区分是因为有些类型会在编译过程中被擦除,并不是所有的类型都在运行时可用。 它包括: 1、非泛型类声明,接口类型声明; 2、所有泛型参数类型为无界通配符(仅用‘?’修饰)的泛型参数类; 3、原始类型; 4、基本数据类型; 5、其元素类型为具象化类型的数组; 6、嵌套类(内部类,匿名内部类等,比如java.util.HashMap.Entry),并且嵌套过程中的每一个类都是具象化的。 |
不论是在编译时还是运行时,数组都能确切的知道自己的所属的类型。但是泛型在编译时会丢失部分类型信息,在运行时,它又会被当作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语言规范》里有明确的说明:
If the element type of an array were not reifiable,the virtual machine could not perform the store check described in the preceding paragraph. This is why creation of arrays of non-reifiable types is forbidden. One may declare variables of array types whose element type is not reifiable, but any attempt to assign them a value will give rise to an unchecked warning . 如果数组的元素类型不是具象化的,虚拟机将无法应用在前面章节里描述过的存储检查。这就是为什么禁止创建(实例化)非具象化的数组。你可以定义(声明)一个元素类型是非具象化的数组类型,但任何师徒给它分配一个值的操作,都会产生一个unchecked warning。 存储检查:这里涉及到Array的基本原理,可以自行参阅《Java语言规范》或者参考5.1.1ArrayList相关章节 |
这不得不说,又是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:class java.lang.Object v2:class java.lang.Object v3:class java.lang.String Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String at capter3.generic.Generic3_2.main(Generic3_2.java:29) |
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;
}
}
代码涵盖了泛型的各种声明和使用情况。接下来使用反编译工具看看结果,可以注意到,接口、类、方法定义的位置,大部分泛型信息依然存在,字段中使用到泛型作为声明的位置,泛型同样存在,而在所有在局部代码快对泛型做引用的位置,泛型内容消失了:
abstract interface GI<T>{ }
class GC<T> implements GI<T>{ T m1; ArrayList<T> m2 = new ArrayList();
<K extends Exception> ArrayList<K> method(K p) throws Exception{ Exception k = p; ArrayList list = new ArrayList(); list.add(k); return list; } } |
可以注意到,在之前没有提及的位置,比如GC.m2成员变量的实例化位置,method方法体里的泛型信息全部被擦除。
为什么Java会这么设计?这也很好理解:
1、如果不保留泛型定义,那么除非拥有源码,不然无法使用泛型。
2、即使保留了泛型定义,定义位置的泛型信息并未初始化,也就是说,泛型参数没有绑定为特定的某个类,对使用者不具备意义。而且,泛型信息在运行时也会被处理为上界,对使用并不会有影响。
相信注意细节的读者已经发现了,之前提及的“会被保留泛型信息的位置”里,“异常抛出位置”的K被替换为了Exception,这不正说明它被擦除了?
事实上,如果通过反射来获取泛型信息的时候(方法将在下一小节详细讲解),会发现,依然可以得到异常的泛型信息。得出结论,作为抛出异常的泛型参数,没有消失。
这是为什么呢?
既然反编译工具没有记录下泛型信息,只能说明某些反编译工具没有解析二进制文件里的某些信息。这些信息是什么呢?这里要引入的一个概念,方法签名(Method Signatrue)。
下面列出的是上一个例子的部分字节码内容(也就是class文件反编译的原始内容):
// Method descriptor #31 (Ljava/lang/Exception;)Ljava/util/ArrayList; // Signature: <K:Ljava/lang/Exception;>(TK;)Ljava/util/ArrayList<TK;>;^TK; // Stack: 1, Locals: 2 java.util.ArrayList method(java.lang.Exception p) throws java.lang.Exception; 0 aconst_null 1 areturn Line numbers: [pc: 0, line: 40] Local variable table: [pc: 0, pc: 2] local: this index: 0 type: capter3.generic.GC [pc: 0, pc: 2] local: p index: 1 type: java.lang.Exception Local variable type table: [pc: 0, pc: 2] local: this index: 0 type: capter3.generic.GC<T> [pc: 0, pc: 2] local: p index: 1 type: K |
这段内容不长,也无需细看,如果稍微观察下,可以注意到第四行开始就是方法的定义部分,包括返回值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的泛型为什么有这么多的“缺陷”。