类型擦除
类型擦除
问题的提出
有如下代码段:
List<Integer> integers = new ArrayList<>();
List<String> strings = new ArrayList<>();
System.out.println(integers.getClass() == strings.getClass());
那么最后的输出是True还是False呢?
答案是False。这就使我产生了一个大大的疑惑:明明是两种完全不同的集合类型,为什么会是True呢?
问题分析
在上述例子中,明明是两种完全不同的集合类型,最终它们的Class却相等,仿佛集合的类型不存在一样,这就是类型擦除的一种体现。
在Java中,编译器会在编译阶段进行类型检查。若传入了错误类型,就会编译失败。但是,一旦通过了编译,编译器就会将泛型参数擦除。运行阶段时,在JVM看来已经没有了泛型类型的对象,只有擦掉泛型参数后的类型(原始类型:Raw Type)。以上述例子为例,泛型类型被擦除后,就变成了以下的样子:
List integers = new ArrayList();
List strings = new ArrayList();
那么泛型参数是简单粗暴地直接删除,还是做了什么操作呢?实际上,有两种情况:
-
若泛型参数有上界,如如下所示,则会被替换为它的第一个上界。
替换前:
public class NumberHandler<T extends Number>{ public final T number; public NumberHandler(T Number) { this.number = number; } }
替换后:
public class NumberHandler{ public final Number number; public NumberHandler(Number number){ this.number = number; } }
-
若泛型参数没有上界,如下所示,则会被统一替换为
Object
。替换前:
public class ArrayList<E>{ Object[] elementData; public E get(int index){...} public boolean add(E e){...} }
替换后:
public class ArrayList{ Object[] elementData; public Object get(int index){...} public boolean add(Object e){...} }
不难发现,类型擦除后的代码与Java在1.5版本之前还未引入泛型的代码一致。其实,这就是类型擦除的目的:高版本兼容低版本。为了保证已有代码和类文件合法,Java选择了简单粗暴的方式来向下兼容。
弊端
虽然类型擦除的方式能实现向下兼容,但是也带来了许多弊端。
-
泛型类型不支持基本类型,只支持引用类型。因为泛型最终会被擦除成为Object,而Object又无法存储基础类型,自然泛型就无法支持基本数据类型。
List<int> intList= new ArrayList<>(); // 编译错误
-
运行时,只能对原始类型进行类型检测,无法判断带有泛型的类型。因为
List<String>
等类压根就不存在,也没有List<String>.class
等。if(obj instanceof List<String>){} // 编译错误 if(obj instanceof T){} // 编译错误
-
无法实例化泛型类型参数。因为在运行时无法确定其具体类型,也无法知道T是否存在无参构造器。
T data = new T(); // 编译错误
但是,也可以通过Java的反射机制来规避这个问题。
public static <E> void append(List<E> list, Class<E> cls) throws Exception { E elem = cls.newInstance(); // OK list.add(elem); }
-
不能实例化泛型数组。若允许实例化泛型数组,可能会引发很多类型转换异常。
// 编译错误 public static <T> T[] randomTwo(T... t) { T[] array = new T[2]; /*********DO SOME THING***********/ return array; } // 或者这样也是不行的 List<Integer>[] arrayOfLists = new List<Integer>[2]; // compile-time error
-
丧失了某些面向对象的特点. 例如以下代码.
// Compile-Time Error public class GenericTypes { public static void method(List<String> list) { System.out.println("invoke method(List<String> list)"); } public static void method(List<Integer> list) { System.out.println("invoke method(List<Integer> list)"); } }
在上述代码中, 我们使用了两个不同的方法参数类型
List<String>
和List<Integer>
, 满足了Java对于方法重载的要求(即: 被重载的方法必须改变参数列表), 但是为什么编译不通过呢?根据Java类型擦除的内容,
List<String>
和List<Integer>
类型最终都会变为Raw Type. 此时, 这两个重载方法的参数列表和方法名就完全相同了, 就不满足Java对于方法重载的要求了.但是上述仅仅只是一部分原因, 或者是浅层的, 表面的. 我们再来看下面这个例子.
// Pass public class GenericTypes { ```java // Pass public class GenericTypes { public static String method(List<String> list) { System.out.println("invoke method(List<String> list)"); return ""; } public static Integer method(List<Integer> list) { System.out.println("invoke method(List<Integer> list)"); return 0; } public static void main(String[] args) { method(new ArrayList<String>()); method(new ArrayList<Integer>()); } } // Output // invoke method(List<String> list) // invoke method(List<Integer> list)
注: 这个例子在不同前端编译器下可能有不同行为. 经笔者测试, 在javac 21中已经无法通过编译. 报错:
java: name clash: method(java.util.List<java.lang.Integer>) and method(java.util.List<java.lang.String>) have the same erasure
.仅只是改变了两个方法的返回值, 却通过了编译, 能够顺利执行! 但是如果仅仅从Java的角度来看, 不同的返回值类型似乎并不是Java方法重载的要求. 所以第一个例子中得出的结论就被我们推翻了.
实际上, 我们应当从字节码的角度来看. 在同一个Class文件中, 实际上是可以存在同名且同参数列表的方法的, 因为在Java中, 方法签名仅仅包括<方法名, 参数列表>, 但在Class文件中, 方法签名包括<返回值, 方法名, 参数列表>. 在第二个例子中, 虽然类型擦除会导致参数list都变为Raw Type, 但是其方法返回值不同, 因此可以合法地存在同一个Class文件中. 而在第一个例子中, 三个维度都相同, 所以不允许存在同一个Class文件中. 这也是第一个例子编译不通过的根本原因.
看见了吗? 如果没有类型擦除的特性, 那么上述两段代码都是正确的, 也是符合面向对象的特性.
一些小细节
类型擦除以后, 原始泛型信息真的消失了吗? 其实并不然, 从编译后的Class文件中我们可以看到, 对应的泛型类或泛型方法中都有一个Signature属性.
其中就存储了"消失"的泛型信息.
总结
泛型的实现通常有两种方法: 类型擦除式泛型(Java)和具现化式泛型(C#). 由于Java必须兼容老版本JDK编译的Class文件, 所以不得不采用前者实现"伪泛型". 通过这种方案实现泛型, 不需要改动老版本的字节码, 不需要改动JVM, 能向前兼容, 仅仅只需要在Javac编译器上做出更改即可, 但对Java使用者来说, 带来了非常大的不便.