本文出自神农班,神农班宗旨及班规:https://mp.weixin.qq.com/s/Kzi_8qNqt_OUM_xCxCDlKA
在前一篇文章 【C++】C++ 中的泛型——template 浅析 中对 C++ 中的泛型进行了研读,它的核心思想是基于一套模板,根据传入的不同参数,从而在编译时生成对应的代码从而实现。而 Java 中的泛型实际上采用了一种完全不同的思路,今天就让我们对 Java 的泛型进行一系列研究。
Java 在 JDK5 中引入了泛型,关于泛型的意义在前一篇文章中已经进行了讨论。
我们可以通过在类/接口名后通过 <>
来声明一个泛型类/接口,其中以逗号分隔的方式填入泛型参数。例如下面是一个简单的泛型类,它具有两个泛型参数 K 和 V 分别代表 key 和 value 的类型,并在类中声明了对应类型的变量。
class Pair<K, V> { K key; V value; }
我们也可以在方法的返回值前通过 <>
来声明一个泛型方法,其中的泛型参数用逗号分隔开,例如:
public <T> boolean equal(T a, T b) { return a.equals(b); }
虽然 Java 中的泛型看上去与 C++ 的泛型非常相似,但实际上还是有非常大的不同的,它具有一些额外的限制。
Java 中的泛型参数是不能使用如 int
、 double
、 char
等基本类型的,如果需要在泛型类中使用它们必须使用它们的包装类 Integer
、 Double
、 Character
等,例如这样使用 ArrayList
就是非法的:
ArrayList<int> list = new ArrayList<>();
简单来说,Java 中的泛型只能对 Object
及其子类提供支持,这样的设计与 Java 中泛型的原理有关,我们后面会进行讨论。这样的设计往往会导致一些额外的性能消耗,比如我们使用一个 ArrayList<Integer>
,就会造成很多额外的装箱拆箱带来的性能浪费。
对于不使用上下界通配符(后面会提到)的泛型类/方法来说,我们在使用这些泛型参数对应的对象时,它们的父类都会被理解为 Object
,当我们使用时就只能使用 Object
类所具有的方法与成员变量。
例如下面这样的简单的泛型加法就是 Java 中泛型无法实现的:
public <T> T add(T a, T b) { return a + b; }
因为编译器认为 a
、 b
的父类都是 Object
,而 Object
之间无法通过 『+』进行加法。如果想要实现具体类的方法调用,可以通过 instanceof
配合强转实现。
Java 中的泛型是无法进行实例化的,包括了基于 new-instance
指令的对象创建以及基于 new-array
指令的数组创建都是无法实现的,我们无法构建一个如 T[]
的数组,例如如下的一个泛型 Array 就无法通过编译:
class Array<T> { T[] datas; public Array(int size) { this.datas = new T[size]; } }
上述做法会在创建泛型数组处发生错误: Type parameter 'T' cannot be instantiated directly
,说明无法直接创建泛型对象。
如果想实现泛型数组,可以通过 Object[]
数组来存放,JDK 中的 ArrayList<>
等都是通过这种方式实现。
由于 Java 泛型存在着很多限制,为了解决其中的部分限制,Java 中的泛型提供了一系列通配符
<? extends Type>
表示了上界通配符,其中的 Type
是任意一种类型,上界代表了 Type
是 T 的上边界。也就是T 必须是 Type
或它的子类,同时,在泛型的使用过程中对该泛型的对象也将支持 Type
类所提供的方法。
abstract class Human { abstract void printInfo(); } class Student extends Human { @Override void printInfo() { System.out.println("I'm a student"); } void teach() { System.out.println("I'm teaching"); } } class Teacher extends Human { @Override void printInfo() { System.out.println("I'm a teacher"); } } public <T extends Human> void printInfo(T human) { human.printInfo(); }
通过上界通配符, printInfo
这样的泛型方法就可以成功实现了,它调用了 Human
类中的 printInfo
方法。
如果我们想要使用其真正类型的方法,可以通过强转实现:
public static <T extends Human> void printInfo(T human) { human.printInfo(); if (human instanceof Teacher) { ((Teacher)human).teach(); } }
<? super Type>
表示上界通配符,它指明了 Type
是 T 的下边界,也就是 T 需要为 Type
或它的父类。它只能针对 ?使用,不能指定具体的如 T
的泛型参数。
?
代表了无界通配符,也就是不对传递的泛型参数进行限制,可以传入任何类型。它与直接使用泛型的区别在于 T 指一个具体的类型,可以在类中存在 T a
这样一个 T 类型的变量,但不能有 ? a
。同时
下界通配符只能针对 ?
使用, T
这样的泛型参数不能使用
。
Java 的泛型在实现原理上与 C++ 泛型有着很大的不同,C++ 中的泛型的核心是一种元编程的思想,通过模板类/模板函数根据具体的使用实例化处具体的类/函数。而 Java 中的泛型实际上是通过 类型擦除 机制实现的。
例如我们看到如下的这样一个类:
class Generic<T> { T instance; public Generic(T instance) { this.instance = instance; } public static void main(String[] args) { Generic<Integer> gen = new Generic<>(3); } }
我们可以查看它编译出的字节码:
// ... 其他信息 { T instance; // descriptor 指定类型为 Object descriptor: Ljava/lang/Object; flags: Signature: #10 // TT; public Generic(T); descriptor: (Ljava/lang/Object;)V flags: ACC_PUBLIC Code: stack=2, locals=2, args_size=2 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: aload_1 6: putfield #2 // Field instance:Ljava/lang/Object; 9: return LineNumberTable: line 4: 0 line 5: 4 line 6: 9 Signature: #15 // (TT;)V public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=3, locals=2, args_size=1 0: new #3 // class Generic 3: dup 4: iconst_3 5: invokestatic #4 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 8: invokespecial #5 // Method "<init>":(Ljava/lang/Object;)V 11: astore_1 12: return LineNumberTable: line 9: 0 line 10: 12 } Signature: #18 // <T:Ljava/lang/Object;>Ljava/lang/Object; SourceFile: "Generic.java"
可以发现,上面的 instance
对象在 FiledInfo 中的 descriptor
变为了 Object
,也就是说它运行时实际上是一个 Object
的对象,它的泛型信息在编译的过程中被 擦除
了。
并且可以看到下面的 Code 属性中对构造函数的调用实际上也是调用了 (Ljava/lang/Object;)V
的构造函数,也就是说构造函数中的类型信息同样是被擦除了。
Java 在编译过程中可以拿到泛型的类型信息,因此会对类型进行检查,但在程序运行时,这种类型检查变不复存在了,其类型被擦除为了 Object
。
这就意味着我们可以在运行期间通过反射向一个 ArrayList<String>
中插入 ArrayList<Integer>
,这是非常危险的。
我们接着看看在使用通配符的情况下,泛型信息在类型擦除后会怎样:
import java.util.ArrayList; import java.util.List; class Generic<T extends List> { T instance; public Generic(T instance) { this.instance = instance; } public static void main(String[] args) { Generic<ArrayList<Integer>> gen = new Generic<>(new ArrayList<>()); } }
生成的字节码如下:
// ... T instance; descriptor: Ljava/util/List; flags: Signature: #11 // TT; public Generic(T); descriptor: (Ljava/util/List;)V flags: ACC_PUBLIC Code: stack=2, locals=2, args_size=2 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: aload_1 6: putfield #2 // Field instance:Ljava/util/List; 9: return LineNumberTable: line 7: 0 line 8: 4 line 9: 9 Signature: #16 // (TT;)V public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=4, locals=2, args_size=1 0: new #3 // class Generic 3: dup 4: new #4 // class java/util/ArrayList 7: dup 8: invokespecial #5 // Method java/util/ArrayList."<init>":()V 11: invokespecial #6 // Method "<init>":(Ljava/util/List;)V 14: astore_1 15: return LineNumberTable: line 12: 0 line 13: 15 } Signature: #19 // <T::Ljava/util/List;>Ljava/lang/Object; SourceFile: "Generic.java"
可以发现,使用了上界通配符后,其擦除后的类型变为了它的上界,也就是 Ljava/util/List;
。
对于无上界通配符的泛型实际上它们的上界就是 Object
,这说明 Java 的类型擦除机制会使得泛型在编译后被擦除为其上界对应的类型
。
比较奇怪的一点是,既然泛型信息被擦除了,那么我们使用 Gson 的时候不是仍然可以通过 TypeToken
对泛型参数的类型进行获取么?这是怎么实现的呢?泛型信息真的被完全擦除了么?
我们来做个实验:
import java.util.List; class Generic { List<String> mList; }
我们的 Generic
类中有一个 List<String>
成员变量,我们查看编译后生成的字节码:
// ... Constant pool: // ... #7 = Utf8 Ljava/util/List<Ljava/lang/String;>; // ... { java.util.List<java.lang.String> mList; descriptor: Ljava/util/List; flags: Signature: #7 // Ljava/util/List<Ljava/lang/String;>; // ... } SourceFile: "Generic.java"
可以发现, mList
的 FieldInfo 中的 descriptor
确实被擦除为了 List
,但它的 Signature 指向了常量池的 #17 引用,也就是 Ljava/util/List<Ljava/lang/String;>;
,这说明 泛型的信息实际上没有完全被擦除,我们仍然可以通过一些特殊的方式来获取泛型参数的类型
。
根据参考资料指出:
根据 Java 5 开始的新 class 文件格式规范,方法与域的描述符增添了对泛型信息的记录,用一对尖括号包围泛型参数,其中普通的引用类型用『La/b/c/D;』的格式记录,未绑定值的泛型变量用『Txxx;』的格式记录,其中 xxx 就是源码中声明的泛型变量名。
Java 中为 Class
类提供了 getGenericSuperclass
方法可以获取到泛型擦除后到父类的类型,它返回的 Type
实际类型为 ParameterizedType
。我们只需要通过它的 getActualTypeArguments
方法即可获取它真正的泛型参数类型数组。Gson 的 TypeToken
正是基于这套机制实现。
例如如下的这段代码:
class Generic<T> { public static void main(String[] args) { Type superclass = new Generic<Map<String, List<String>>>() {}.getClass().getGenericSuperclass(); Type actualClass = ((ParameterizedType) superclass).getActualTypeArguments()[0]; System.out.println(actualClass); } }
通过这种方式就可以获取到其泛型信息 java.util.Map<java.lang.String, java.util.List<java.lang.String>>
。
不过并不是所有泛型信息都会被保留下来,大致规则为:
使用一侧的泛型信息编译后就获取不到了。
Java 中的泛型在 JDK5 中加入,它的核心原理是基于编译时 类型擦除
机制的,它会在编译的过程中将泛型相关信息进行擦除,变为其对应的上界的类。实际上其原先的泛型信息并不会被全部擦除,对于 声明一侧
的信息仍会被保留在 Class 文件的 Signature 字段中,我们可以通过 getGenericSuperclass
方法获取 ParamterizedType
,之后调用其 getActualTypeArguments
获取泛型参数的信息。
显然 Java 的泛型由于类型擦除机制带来了非常多的限制,那么为什么当时 Java 的泛型没有采用另一种实现方式呢?正是因为 Java 中泛型引入时间较晚,因此为了对 JDK5 之前的代码保证兼容,因此 Java 没有采用 C++ 中的这种生成代码的原理来实现泛型。
下面是我整理的一些 C++ 中的模板与 Java 的泛型的异同:
Object
或对应的上界。 super
等关键字实现类似的效果。 List<Integer>
中插入 String
对象。 ArrayList
采用了 Object[]
) Java 不能实现真正泛型的原因是什么?
聊一聊-JAVA 泛型中的通配符 T,E,K,V,?
Java获得泛型类型