类型擦除

问题的提出

有如下代码段:

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属性.

image-20231011111930623

其中就存储了"消失"的泛型信息.

总结

泛型的实现通常有两种方法: 类型擦除式泛型(Java)和具现化式泛型(C#). 由于Java必须兼容老版本JDK编译的Class文件, 所以不得不采用前者实现"伪泛型". 通过这种方案实现泛型, 不需要改动老版本的字节码, 不需要改动JVM, 能向前兼容, 仅仅只需要在Javac编译器上做出更改即可, 但对Java使用者来说, 带来了非常大的不便.

文章作者: Serendipity
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 闲人亭
Java杂谈 Java Java基础
喜欢就支持一下吧