导读:
为什么泛型擦除后仍可以获取类型信息,如何获取泛型类型,Java泛型与C++、Python中的有何区别,本文将为您揭开泛型的内幕。
读完该篇文章,您可以了解到:
1.为什么需要泛型
2.Java代码在编译后是如何保存泛型信息的
3.Java泛型与C++、Python中的有何区别
4.如何动态获取泛型类型
1、Java为什么需要泛型?
泛型最众所周知的应用就是容器类,通常而言,我们只会用容器来存储一种类型的队形,泛型的主要目的之一就是用来指定容器要持有什么类型的对象,由编译器来保证类型的正确性。
Java在泛型出现之前,只能通过Object来实现类型泛化,手动转换导致只有程序员和运行期间的JVM才知道这个Object究竟是什么类型的对象。编译期间也无法检查Object强转是否成功的,这样不可避免的在运行期抛出更多的ClassCaseException。
如果有泛型,除了可以解决以上问题,同时也可以让不同的类型对象作用于同一个方法了。
2、泛型
泛型的本质是参数化类型(Parameterized Type),即可用通过参数指定操作的数据类型。促成泛型出现最引人注明的一个原因就是为了创造容器类。
Java中的泛型:
- 泛型类:参数类型用于类中;
- 泛型接口:参数类型用于接口中;
- 泛型方法:参数类型用于方法中。
本文不详细讲解泛型的使用,感兴趣的朋友可以阅读这几篇文章:
这里我们来探索我们的主题,Java泛型在代码编译和执行过程中的内幕,在字节码里面是怎么体现的。
3、探索Java泛型的本质
在引入了泛型之后,为了能够让虚拟机解析、反射等各种场景正确获取到参数类型,JCP组织修改了虚拟机规范,引入了Signature
、LocalVariableTypeTable
新属性。
3.1、LocalVariableTypeTable
这里编写一个泛型类,探索其字节码:
1 | public class GenericClass<GT1> { |
编译之后,使用javap -v
命令查看GenericClass<GT1>
的反汇编信息,我们在实例构造器中的code中,发现有LocalVariableTypeTable:
这个是方法的实例构造器的一个可选属性,如果方法中使用到了泛型,则会出现这个属性。
更多关于LocalVariableTypeTable
的介绍:4.7.14. The LocalVariableTypeTable
Attribute
接下来我们细看下LocalVariableTypeTable
。
3.2、Signature
LocalVariableTypeTable
里面携带了一个具有类型的Signature签名。
对比可以发现,LocalVariableTable中也有Signature,不过LocalVariableTable中并不携带泛型信息。
这个Signature正是Java编译的时候生成的,用于标识对应的类、变量或者属性等的类型的签名。
这个Signature中除了原生类型,也保存了参数化类型(即泛型)的信息。
这样,即使在编译后,泛型的类型信息被擦除了,也能通过这个Signature获取到泛型的签名信息。
在Java中,不管使用到了具体的类型或者是泛型,都需要给定一个类型签名(Signature)。以下场景都会给定类型签名:
- 具有通用或者具有参数化类型的超类或者超接口的类;
- 方法中的通用或者参数化类型的返回值或者入参,以及方法的throw子句中的类型变量;
- 任何类型、类型变量、或者参数化类型的字段、形式参数或者局部变量;
Signature的的命名:
使用遵循 JVM规范第4.3.1节 的语法指定签名。常见的字母简写含义如下:
FieldType term | Type | Interpretation |
---|---|---|
B |
byte |
signed byte |
C |
char |
Unicode character code point in the Basic Multilingual Plane, encoded with UTF-16 |
D |
double |
double-precision floating-point value |
F |
float |
single-precision floating-point value |
I |
int |
integer |
J |
long |
long integer |
L ClassName ; |
reference |
an instance of class ClassName |
S |
short |
signed short |
Z |
boolean |
true or false |
[ |
reference |
one array dimension |
上面的:
Lcom/itzhai/jvm/executeengine/编译期优化/泛型/GenericClass<TGT1;>;
表示实例的类名。可以发现这个类最后面跟了一个泛型类型名称TGT1
在使用到了泛型的代码中,因为编译时并不知道最终执行的具体类型,所以会生成这个Signature用来表示泛型。Signature目前仅在Class的反射和编译阶段会用到。
3.2.1、Signature是怎么存储的
我们得重新复习下这篇文章了:Class文件十六进制背后的秘密,这篇文章里面,我们知道了Class文件的结构如下:
JVM规范 4.7. Attributes 的**Table 4.7-C. Predefined class
file attributes (by location)**表中,我们可以知道,Signature属性在ClassFile,field_info,method_info都可以存在,用于代表不同主体的类型签名信息。我们上面看到的是在method_info中的Signature。我们在上图标注一下,哪些地方会存储这个Signature:
3.3、擦除了泛型之后
上面GenericClass<GT1>
类的getParam1()
和setParam1()
方法编译完成后,得到的反汇编代码如下:
1 | public GT1 getParam1(); |
可以发现,方法中的泛型替换为了 Object,也就是进行类类型擦除,实际运行的时候,都是Object类型了。
3.4、通过反射从Signature中获取泛型信息
我们知道泛型在编译阶段编译器可以用来校验类型,一旦编译通过之后,会擦除泛型。到了运行期,泛型实际上是Object了,这个时候可以通过反射获取泛型信息。
3.4.1、获取泛型信息例子
我们尝试获取泛型:
1 | GenericClass<Double> temp = new GenericClass<>(); |
结果:
1 | [GT1] |
发现这里只是获取到了泛型的名称,并没有获取到实际的类型。
为什么???
原来GenericClass
Class文件里面只有
1 | Signature: #24 // <GT1:Ljava/lang/Object;>Ljava/lang/Object; |
可以看到,这里的Signature只是保留了GT1这个泛型名称,以及泛型擦除后的实际类型,并不能感知到运行期究竟创建了什么类型,所以这里直接输出了泛型的名称。
在进行类型擦除时,如果泛型类的类型参数没有指定上限(T extends xxx),那么会被擦除为Object类型,如果指定了上限,那么会被擦除为相应的上限类型。
怎么才能获取到泛型的具体类型呢?
1 | GenericClassTest genericClass = new GenericClassTest(); |
结果:
1 | class java.lang.Double |
成功获取到了实际类型。
原因是GenericClassTest
的ClassFile的attributes中包含了泛型的签名信息:
1 | Signature: #20 // Lcom/itzhai/jvm/executeengine/编译期优化/泛型/GenericClass<Ljava/lang/Double;>; |
这个签名信息是编译的时候可以根据GenericClassTest
类生成的。
4、Java泛型与C++泛型有什么区别?
下面举一个《Thinking is Java》中的例子来说明。
查看下面的一段C++的泛型代码:
1 |
|
C编写的泛型,当模板被实例化时,模板代码知道其模板参数的类型,C编译器将进行检查,如果泛型对象调用了一个当前实例化对象不存在的方法,则报一个编译期错误。例如上面的manipulate里面调用了obj.f(),因为实例化的HasF存在这个方法,所以不会报错。
而Java是使用擦除实现泛型的,在没有指定边界的情况下,是不能在泛型类里面直接调用泛型对象的方法的,如下面的例子:
1 | public class Manipulator<T> { |
通过没有边界的obj调用f(),编译出错了,下面指定边界,让其通过编译:
1 | public class Manipulator<T extends HasF> { |
上面的例子,把泛型类型参数擦除到了HasF,就好像在类的声明中用HasF替换了T一样。
5、Java泛型的弊端与改进思路
Java泛型中,当要在泛型类型上执行操作时,就会产生问题,因为擦除要求指定可能会用到的泛型类型的边界,以安全地调用代码中的泛型对象上的具体方法。这是对“泛化”概念的一种明显的限制,因为必须限制你的泛型类型,使他们继承自特定的类,或者特定的接口。在某些情况下,你最终可能会使用普通类或者普通接口,因为限定边界的泛型和可能会和指定类或接口没有任何区别。
某些编程语言提供的一种解决方法称为潜在潜在机制
或结构化类型机制
(鸭子类型机制:如果它走起来像鸭子,并且叫起来也像鸭子,那么你就可以将它当做鸭子对待。)
潜在类型机制
使得你可以横跨类继承结构,调用不属于某个公共接口的方法。因此,实际上一段代码可以声明:“我不关心你是什么类型,只要你可以speak()和sit()即可。”由于不要求具体类型,因此代码就可以更加泛化了。
两种支持潜在类型机制的语言:Python和C++。
下面一段选取自《Thinking is Java》的Python潜在类型机制的代码演示:
1 | class Dog: |
perform的anything参数只是一个标示符,它必须能够执行perform()期望它执行的操作,因此这里隐含着一个接口,但是从来都不必显示地写出这个接口——它是潜在的。perform不关心其参数的类型,因此我们可以向它传递任何对象,只要该对象支持speak()和sit()方法,否则,得到运行时异常。
Java的泛型是JDK5之后才添加的,为了兼容旧版本,因此没有任何机会可以去实现任何类型的潜在类型机制。
6、Java中对缺乏类型机制的补偿
对于潜在类型机制的一种补偿,可以使用的一种方式是反射,《Thinking is Java》提供了如下的案例,其中perform()方法就是用了潜在类型机制:
1 | class Mime { |
References
《Thinking in Java》
Java笔记 – 泛型 泛型方法 泛型接口 擦除 边界 通配符(1)