码农日记
首发于码农日记

仔细说说Java中的泛型

最近学习kotlin,在泛型这一块出现了一点困惑,仔细研究了一番发现之前对于Java中的泛型理解得并不深刻,甚至还有不少的问题,因此重新对这一块做了更加细致的探究,也有了不少新的收获。


协变、逆变与泛型通配符

泛型的基本用法是很简单的,在这里就不赘述了,这里首先着重了解几个概念,协变与逆变,以及泛型通配符的使用规则。

基本概念

首先看一下什么是协变、逆变以及不变。逆变与协变用来描述类型转换(type transformation)后的继承关系,其定义:如果A、B表示类型,f(X)表示类型转换,≤表示继承关系(比如,A≤B表示A继承于b),那么这里有三个关系

  1. 当A≤B时有f(B)≤f(A)成立,那么说f(X)是逆变(contravariant)的
  2. 当A≤B时有f(A)≤f(B)成立,那么说f(X)是协变(covariant)的
  3. 当上两者都不成立的时候,那么说f(X)是不变(invariant)的


在Java中,数组是协变的,比如下面的代码是没有问题的

public static void main(String[] args) {
    Number[] nums = new Integer[10];
}


但是Java中的泛型却是不变的。比如下面的代码都是错误的

public static void main(String[] args) {
    //ArrayList<Integer> ints = new ArrayList<Integer>();
    //ArrayList<Number> nums = ints;
    //ArrayList<Number> numbers = new ArrayList<Number>();
    //ArrayList<Integer> integers = numbers;
}

至于为什么不支持协变,这个还是很好解释的。以第一句代码为例,如果这句话是成立的,那么这个容器本来应该是盛放Integer的,但是现在也可以盛放任意的Number了,比如说Double或者Float,那么在取出元素的时候,就有可能导致ClassCastException,所以为了避免这样的情况出现,就不允许这样的赋值。至于逆变就更好解释了,在Java中,父类对象本来就是不支持赋值给子类的引用的。


泛型通配符

我们有时候有确实很需要使用到协变与逆变的特性,比如说,我们想要写一个方法,他需要对某一组Fruit的对象做某个操作,那么我们可以写出下面的代码。

class Fruit{}
class Apple extends Fruit{}
class Banana extends Fruit{}

public void test(List<Fruit> fruits){
}


但是这里就出现了一个问题,比如我们现在需要处理的是`List<Apple>`,但是这里却发现这个需要处理的对象并不能够作为参数传给方法。我们可以采取两个办法,第一就是重新创建一个泛型参数为Fruit的容器A,将需要处理的列表元素依次复制到A,然后将A作为参数传给test方法,但是这样就非常麻烦。第二,就是使用泛型通配符来修改参数列表。


泛型通配符常用的有两种,一种是上界通配符 `<? extends T>`,另一种是下界通配符`<? super T>`,当我们需要协变或者逆变的时候,泛型通配符就可以派上用场了。


`<? extends T>`通配符指定了泛型的上界,在这里我们可以将参数的`List<Fruit>`改成`List<?extends Fruit>`,这就意味着,我们可以接受任意的一个泛型参数是Fruit或者Fruit子类的List作为参数了。我们在调用的时候传入一个List<Fruit>或者是List<Apple>都是没有问题的。


但是我们在方法内部可能会发现一个问题,那就是我们没有办法向fruits里面添加元素了。这是为什么呢?这里我们可以从反方向去思考一下,如果我们可以添加元素,那么可能会出现什么情况呢?如果我们可以添加元素,那么我们添加一个Fruit,或者是一个Apple甚至Banana都是合理的,但是我们却并不知道这个列表的泛型参数到底是什么,我们只知道他一定是从Fruit派生出来的,仅此而已。也许这是一个`List<Apple>`,但是我们却可以向里面添加一个Banana,这两个类之间并没有继承关系,所以在取出来的时候,就可能会出现ClassCastException。为了避免这种情况,在使用`<? extends T>`通配符的时候,我们只能去访问期中的元素,而不能去更改。


但是有时候我们又需要更改其中的元素呢?那这个时候就应该使用`<? super T>`通配符了。


`<? super T>` 通配符指定了元素的下界,比如说`List<? super Fruit>`,他可以接受任意一个泛型参数为Fruit或者Fruit的父类的List。这有什么用呢?他和`<? extends T>`通配符刚好相反,当我们需要对其中的元素进行操作的时候,就应该使用这个通配符。举个例子,我需要写一个复制的方法,他可以把一个fruit的集合复制到另一个fruit的集合。那么这个复制的代码应该如下。

public static void copy(List<? extends Fruit> src,List<? super Fruit> dst){
    src.forEach((fruit)-> dst.add(fruit));
}

首先前面的通配符是因为我们的源List的类型是不确定的,为了能够更加方便的接受参数,我们需要使用到这个通配符。为什么后者要使用`<? super T>`通配符呢?刚才已经说到,使用`<? extends T>`通配符的参数,是不可以添加元素的。那么为了添加元素,我们就可以使用`<? super T>`这个通配符。因为我们只需要知道这个List的泛型参数是Fruit或者Fruit的父类,那么我们就可以放心的将任意一个Fruit或者Fruit的子类的对象添加进去。那么这里可以不使用通配符么?当然可以,若是将`List<? super Fruit>`改成`List<Fruit>`,在这里也是没有问题的,只不过可以接受的参数一定就只能是List<Fruit>了。但是这里可以发现,如果我们要获取dst的元素,我们不能够把它当做Fruit这个类来使用,只能当做Object这个类来使用,这是因为我们不知道他具体是什么类型,只知道他的下界就是Fruit,所以只能往里添加,而不能拿出来用。


再回顾上面的定义,我们可以看到

  • List<? extends Fruit> list = new ArrayList<Apple>(); 根据定义可以看到它实现了泛型的协变
  • List<? super Fruit> list = new ArrayList<Object>(); 根据定义可以看到它实现了泛型的逆变


最后一个问题那就是,什么时候用`<? extends T>`,什么时候用`<? super T>`呢?有一个原则叫做PECS:,即producer-extends, consumer-super.根据字面意思结合上面的复制的例子可以看到,当我们需要往外取东西的时候,也就是作为生产者的时候,用extends;当我们需要往里面添加东西的时候,也就是作为消费者的时候,用super.又要写又要读咋办呢?那就不用通配符了呗。


泛型的实现原理

关于泛型的实现,这里主要说一说类型擦除。Java中的泛型又被称为伪泛型,它和所谓的真泛型比如说C++中的模板函数模板类的实现机制有很大的不同。这里我们可以对比一下两者的实现机制,Java的泛型停留在编译阶段,泛型参数很大的一个作用是在编译期进行类型检查,而在运行时会对泛型进行擦除,也就是说List<String>和List<Integer>在运行时实际上都是List<Object>,在JVM看来这两者并没有什么差别。而C++中的模板实现则大不一样。在C++中提供了函数模板和类模板,在编写代码的时候,我们可以指定模板的类型,然后编译的时候,编译器会根据指定的类型和模板,生成对应的模板函数和模板类,也就是说vector<int>和vector<string>在运行时是两个不同的类。这种实现会增加编译结果的结果,但是在运行时会更加的高效。


这里重点说说泛型擦除,首先看看下面的代码


public static void main(String[] args) {
    List<Integer> ints = new ArrayList<>();
    List<String> strings = new ArrayList<>();
    System.out.println(ints.getClass() == strings.getClass()); //true
    System.out.println(ints.getClass().getName()); //java.util.ArrayList
    System.out.println(strings.getClass().getName()); //java.util.ArrayList
}

在这里我们指定了两个不同泛型参数的ArrayList,在运行时输出他的class对象,可以看到他们的结果是一样的,说明他们的泛型参数在运行的时候都被擦除掉了。那么这里我们可以想到,既然他们在运行时的类型都是一样的,那么`List<Integer>`能不能添加一个String对象进去呢?答案是可以的,但是由于编译器在编译器会做类型的检查,这样的代码是无法通过编译的,但是我们可以在运行时通过反射动态添加元素进去。代码如下


public static void main(String[] args) {
    List<Integer> ints = new ArrayList<>();
    ints.add(1);
    Class clazz = ints.getClass();
    try {
        Method add = clazz.getMethod("add",Object.class);
        add.invoke(ints,"reflect");
    } catch (NoSuchMethodException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    } catch (InvocationTargetException e) {
        e.printStackTrace();
    }
    for (int i = 0; i < ints.size(); i++) {
        System.out.println(ints.get(i));
        //1
        //reflect
    }
}


在代码中我们定义了一个泛型参数为Integer的List,在运行时通过反射添加了一个字符串进去,最后打印内容,我们看到并没有任何的错误。这很不符合逻辑,为什么我们打印一个int,最后居然打印了一个字符串,归根结底还是因为运行时,他们都是Object,所以才可以进行操作。


上面的打印看起来很奇怪,如果我们把打印的部分改成下面的代码,那么代码可以通过编译,但是运行时会出现ClassCastException.


for (int i = 0; i < ints.size(); i++) {
    int val = ints.get(i);
    System.out.println(val);
}


之所以会出现这种情况,是因为在赋值的时候,才会对元素进行强制类型转换,转换成泛型参数所指定的类型,所以这里赋值就会出现异常了。


桥方法

桥方法是个什么东西呢?顾名思义,桥方法是一座桥,它的作用是桥接,桥接什么呢?桥接泛型多态中的重载方法。我们经常把泛型具体化,比如下面的代码

public interface Container<T>{
    void setItem(T item);
    T getItem();

}
class StringContainer implements Container<String>{

    @Override
    public void setItem(String item) {

    }

    @Override
    public String getItem() {
        return null;
    }
}

我们刚才提到了类型擦除,在运行时,Container的泛型会被擦除掉,变成了Object,而StringContainer却要对接口的两个函数进行覆盖,事实上他们的类型是不一样的,所以是没有办法覆盖的,那么这个时候就出现了桥接方法这个东西。编译器在编译的时候,会把setItem和getItem的参数进行泛化,生成了对应的方法,这个方法就叫做桥接方法,桥接方法内部又调用了子类实际重写的方法,从而实现了多态。


上面的话有一点抽象,我们直接看字节码

class StringContainer implements Container  {

  // access flags 0x1
  public setItem(Ljava/lang/String;)V
   L0
    LINENUMBER 16 L0
    RETURN
   L1
    LOCALVARIABLE this LStringContainer; L0 L1 0
    LOCALVARIABLE item Ljava/lang/String; L0 L1 1
    MAXSTACK = 0
    MAXLOCALS = 2

  // access flags 0x1
  public getItem()Ljava/lang/String;
   L0
    LINENUMBER 20 L0
    ACONST_NULL
    ARETURN
   L1
    LOCALVARIABLE this LStringContainer; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1041
  public synthetic bridge getItem()Ljava/lang/Object;
   L0
    LINENUMBER 11 L0
    ALOAD 0
    INVOKEVIRTUAL StringContainer.getItem ()Ljava/lang/String;
    ARETURN
   L1
    LOCALVARIABLE this LStringContainer; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1041
  public synthetic bridge setItem(Ljava/lang/Object;)V
   L0
    LINENUMBER 11 L0
    ALOAD 0
    ALOAD 1
    CHECKCAST java/lang/String
    INVOKEVIRTUAL StringContainer.setItem (Ljava/lang/String;)V
    RETURN
   L1
    LOCALVARIABLE this LStringContainer; L0 L1 0
    MAXSTACK = 2
    MAXLOCALS = 2

通过上面的代码可以看到,运行时直接生成了四个方法,其中标记有synthetic bridge的方法就是桥方法,第一个标志表示它是编译器自动生成的,第二个标志表示它是桥方法,它和接口中的方法的类型是一致的,也就是它真正实现了接口中的方法,在它的内部可以看到,它先将参数做强制类型转换,又调用了自己定义的方法。以下面的代码为例


public static void main(String[] args) {
        Container<String> container = new StringContainer();
        container.setItem("2333");
        container.getItem();
}


上面的代码中,运行时container的泛型被擦除了,它调用的实际上是桥方法,桥方法内部再调用我们自己的方法,从而完成了多态。这里可以我们看到,对于getItem这个方法,实际上这里有两个,他们的参数列表是完全一样的。我们通常认为,方法签名只包含了方法名,实际上,在虚拟机的层面,方面签名还包含了方法的返回值,所以这两个方法是可以共存的。


运行时动态获取泛型参数

之前一直在强调类型擦除,难道说运行时就真的不能获取泛型了嘛?实际上是可以的。比如上面的代码,要动态获取泛型参数,就可以用下面代码实现


public static void main(String[] args) {
     Container<String> container = new StringContainer();
     Type[] types = container.getClass().getGenericInterfaces(); //如果是继承了泛型类,那么就是getGenericSuperClass()
     for (Type type:types){
         if(type instanceof ParameterizedType){
             System.out.println(((ParameterizedType) type).getActualTypeArguments()[0]); //class java.lang.String
         }
     }
 }

在一些json解析的框架中,比如Gson中,这个特性就会经常被用到,所以还是蛮有用的。关于泛型,暂且就写到这里了。

编辑于 2017-11-17

文章被以下专栏收录

    个人学习编程过程中的一些记录,主要涉及Java、Python、Android和一些算法问题。