《徐徐道来话Java》(2):泛型和数组,以及Java是如何实现泛型的

时间:2024-01-14 15:15:56

数组和泛型容器有什么区别

  要区分数组和泛型容器的功能,这里先要理解三个概念:协变性(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语言规范》里,明确的规定了具象化类型的定义:

完全在运行时可用的类型被称为具象化类型(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的泛型为什么有这么多的“缺陷”。