Java虚拟机中的泛型——类型擦除

前言

上面我们已经简单的介绍了Java中的泛型的基本的用法了。按理来说,已经足够我们昏头昏脑的去使用泛型了。一开始我们会感觉泛型是多么的简单,然后用着用着就会发现,泛型到底是个什么东西,越来越不能理解泛型了。此时,我们就需要进一步的去理解泛型,而不是将泛型局限在简单的加一个<T>上面。

类型擦除

我们先来一个经典的测试题。

@Test
public void test() {
    List<String> list1 = new ArrayList<>();
    List<Integer> list2 = new ArrayList<>();

    System.out.println(list1.getClass() == list2.getClass());
}

请问上面的输出的什么?之前我们说反射的时候已经提及到了getClass是获取对应的类的唯一的那个反射对象。之前我们也说过List<String>List<Integer>是完全没有关系的,但是他们的唯一的父类是List>?>。所以说上面的输出是false??答案是错误的,上面的输出的结果是true

我们发现输出的结果是class java.util.ArrayList,而ArrayList.class输出的结果也是这样。怎么会这样,我们的泛型信息呢?怎么丢掉了??其实这就是类型擦除,泛型将在Java虚拟机中被擦除,也就是无论你在代码中写了什么泛型,Java虚拟机是不知道什么叫泛型的。

之前我们也说过了,泛型是JDK5之后才被引入的,之前的Java代码都是没有泛型的。比如我们使用ArrayList的时候,会这样使用。

@Test
public void test1() {
    List list = new ArrayList();
    list.add("sher");
    list.add(33);
    list.add(new Date());
    list.forEach(System.out::println);
}

这是之前我们使用ArrayList的方式,没有任何的泛型信息。ArrayList中可以存放任何类型的数据。嗯?可以存放任何类型的数据,那这岂不是更好了??但是我们似乎没有必要去存放各种类型不同的数据,如果按照上面这样写的话,我们add的都只能是Object类型的对象。当我们使用get的时候返回的数据的类型也都是Object,我们还需要对其中的不同的对象进行转型,这很不方便而且非常不安全。因此,我们是非常的建议使用泛型的。但是我们之前都是没有泛型的,现在引入了泛型,这对Java之前的生态是不是有很大的影响呢?比如说之前的代码都不可以运行了。这当然是不可取的,Java可不是python,Java是一门非常向前兼容的语言。那么我们必然是可以讲ArrayList<String>传给之前接受ArrayList类型的方法。(里面存放的是Object类型的数据)。那么ArrayList相当于一个ArrayList<Object>吗?当然不是,至少ArrayList<String>是不可以赋值给ArrayList<Object>的。

    @Test
    public void test2() {
        List list = null;
        List<String> list1 = null;
        List<Object> list2 = null;

        list = list1; // Right
        list2 = list1; // Error
    }

当我们定义一个泛型类型的时候,他会被编译成为一个原始的类型。比如说我们之前说的Entry<K, V>就会变成了。

public class Entry {
    private Object key;
    private Object value;

    public Entry(Object key, Object value){
        this.key = key;
        this.value = value;
    }

    public Object getKey() {return key;}
    public Object getValue() {return value;}
}

每一个K V都会被替换称为Object。不过这也不是必然的,如果我们的Entry的声明是这样的话。

public class Entry<K extends Comparable<? super K> & Serializable,
                    V extends Serializable>

编译之后就会变成如下的形式。编译之后的类型将会被类型上限说取代。

public class Entry {
    private Comparable key;
    private Serializable value;
}

为什么keySerializable丢失了呢?那是因为,如果类型有多个限定的话,将会被第一个限定取代。

但是有了类型擦除并不是代表着不安全,其实很安全,因为编译器会为我们做安全检查还有自动转换。我们无法将其他的类型的对象插入到List<String>中,但是也是有例外的时候。我们说过运行的时候是没有泛型的,此时我们就可以通过反射放入我们想要的对象。比如说如下的方式。

@Test
public void test3() throws Exception {
    List<String> list = new ArrayList<>();
    Class<? extends List> clazz = list.getClass();
    Method add = clazz.getMethod("add", Object.class);
    add.invoke(list, new Date());

    String s = list.get(0);
    System.out.println(s);
}

上面的代码编译器没有给我们任何的警告,因为他自以为类型是安全的,list中只有String类型的变量,但是我们通过反射的方式放入了一个Date对象。因为运行期间泛型被擦除了,add方法变成了public add(Object e),所以我们可以放入任何的对象。(我们通过反射寻找这个方法的时候也是Object.class)。

@Test
public void test3() throws Exception {
    List<String> list = new ArrayList<>();
    Class<? extends List> clazz = list.getClass();
    Method add = clazz.getMethod("add", Object.class);
    add.invoke(list, new Date());

    System.out.println(list.get(0)); // Error
    Date date = (Date) list.get(0); // Error
    Date date1 = (Date) (Object) list.get(0); // Right
    System.out.println(date1);

    Object o = list.get(0);
    o = (Date) o;
    System.out.println(o);
}

需要或许数据是不可以直接进行转型的,需要转为Object类型才行。因为我们使用get方法的时候是默认进行了转型的。

需要注意的是,由于类型擦除的缘故,我们是没有办法在静态方法或者是静态字段中使用泛型的。

桥方法

上面我们也基本明白了,类型擦除是简单的而且安全的,但是只是绝大多数情况是如此。不过编译器做的事情远比我们想的多,有时会合成桥方法。这只是泛型的实现的细节,我们并不需要去了解他。但是如果我们想要更加深入的了解Java中的泛型,还是有必要去了解一下编译器干了什么的。

public class WordList extends ArrayList<String> {
    public void add(String e) {
        return isBadWord(e) ? false : super.add(e);
    }
    ......
}

如果我们是这样使用的话。

WordList words = ...;
ArrayList<String> strings = words; // It is right
strings.add("C++")

上面的代码是完全没有任何问题的,第三行调用的什么函数??按道理来说应该是WordListadd方法,但是真的可以调用到么?首先是调用ArrayList<String>中的add(Object e)(ArrayList<String>中是没有add(String str)的),这是根据多态,然后应该调用。。。emm,多态??哪儿来的多态?子类中只有一个add(String e),这没有多态啊。所以不会调用子类的函数。但是经过实验,确实是调用了子类的函数了的。那么是如何实现的呢?肯定是通过多态实现,因为父类想要调用子类的函数只有多态这一条路。

为了做到这一点,编译器在WordList中添加一个桥方法,声明如下。

public void add(Object e) {
    add((String) e);
}

这样就可以实现多态了。不过这个函数的多态调用通过了一个桥一样的东西,这就是所谓的桥方法。

当返回类型改变的时候,同样会生成桥方法。

public String get(int i) {
    return super.get(i).toLowerCase();
}

使用同样的方法,应该调用的是ArrayList中的Object get(int i)方法。但是WordList中没有对应的不同的函数,所以会在WordList中生成桥方法Object get(int i),这个桥方法中会调用WordList中的String get(int i)方法。

你可能会想,不对啊,我寻思String get(int i)不是可以重写Object get(int i)方法的吗?没有桥方法不就是可以实现多态的吗?其实我们想多了,没有泛型的时候也是会生成桥方法的。上面的返回类型不一致之所以可以调用就是因为生成了桥方法,所以说桥方法不是泛型的专属。

不过桥方法也会导致其他的问题,最显著的问题就是方法的冲突问题,这里就不多说了。

实例类型变量与数组

我们无法创建new T(...)或者new T[...]这样的表达式中使用类型变量。这些形式都是非法的,因为当类型T被擦除之后,编译器并不知道应该创建什么类型的变量。

public static <T> T[] repeat(int n, T obj) {
    T[] result = new T[n]; // Error
    for (int i = 0; i < n; i++) {
        result[i] = obj;
    }
    return result;
}

为了实现这个方法,我们需要提供类型的构战函数。

public static <T> T[] repeat(int n, T obj, IntFunction<T[]> ctor) {
    T[] result = ctor.apply(n);
    for (int i = 0; i < n; i++) {
        result[i] = obj;
    }
    return result;
}

@Test
public void test() {
    String[] hellos = repeat(3, "hello", String[]::new);
    System.out.println(Arrays.toString(hellos));
}

除此之外,我们还可以使用反射的机制来完成构造函数的工作。

public static <T> T[] repeat(int n, T obj, Class<T> clazz) {
    @SuppressWarnings("unchecked") T[] result = (T[]) Array.newInstance(clazz, n);
    for (int i = 0; i < n; i++) {
        result[i] = obj;
    }
    return result;
}

@Test
public void test2() {
    String[] worlds = repeat(5, "world", String.class);
    System.out.println(Arrays.toString(worlds));
}

不过需要注意的是,当我们使用ArrayList<T>的时候,我们是可以直接new的。

public static <T> List<T> repeat(int n, T obj) {
    List<T> result = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        result.add(obj);
    }
    return result;
}

@Test
public void test3() {
    List<String> sher = repeat(3, "sher");
    System.out.println(sher);
}

当我们没有什么强有力的理由去使用原生数组的话,我们是没有必要绕道去想解决方法的,我们可以直接使用ArrayList,这样简单清晰的多。

另外的一个话题是实例化类型变量的数组。

ArrayList<String>[] lists = new ArrayList<String>[100];

这种方式是错误的。因为类型擦除了,我们创建的是一个原始的ArrayList数组,可以添加任何类型的数据的ArrayList对象,比如说ArrayList<Integer>ArrayList<Data>,因为这些东西在运行期间都是一样的。不过,ArrayList<String>[]这种语法是正确的,如果我们真的要使用这种看着都很难受的东西的话,我们需要这样使用。

@Test
public void test4() {
    @SuppressWarnings("unchecked")
    ArrayList<String>[] lists = (ArrayList<String>[]) new ArrayList<?>[5];
    System.out.println(Arrays.toString(lists));
}

不过还是要说的是,我们大可不必这么写,我们完全可以这么写。

ArrayList<ArrayList<String>> list = new ArrayList<>(5);
System.out.println(list);

这种写法才是我们推荐的,也是很多API所使用的写法。

总结

上面基本上就是这次学习泛型的所有的东西了。其实还有一个细节是没有说的。在Java虚拟机中,类型擦除只是擦除那些实例化的变量,对于泛型类与泛型方法的完整信息在运行期间是可用的。不过这点确实是有点复杂,而且一点都不常用,所以此处直接略过了。


一枚小菜鸡