JAVA中的协变与逆变

JAVA中的协变与逆变

首先说一下关于Java中协变,逆变与不变的概念

比较官方的说法是逆变与协变描述的是类型转换后的继承关系。

定义A,B两个类型,A是由B派生出来的子类(A<=B),f()表示类型转换如new List();

协变: 当A<=B时,f(A)<=f(B)成立

逆变: 当A<=B时,f(B)<=f(A)成立

不变: 当A<=B时,上面两个式子都不成立

这么说可能理解上有些费劲,我们用代码来表示一下协变和逆变

class Fruit {}
class Apple extends Fruit {}
class Jonathan extends Apple {}
class Orange extends Fruit {}

@Test
public void testArray() {
    Fruit[] fruit = new Apple[10];
    fruit[0] = new Apple();
    fruit[1] = new Jonathan();
    try {
        fruit[0] = new Fruit();
    } catch (Exception e) {
        System.out.println(e);
    }
    try {
        fruit[0] = new Orange();
    } catch (Exception e) {
        System.out.println(e);
    }
}

Java中数组是协变的,可以向子类型的数组赋基类型的数组引用。

Apple是Fruit的子类型,所以Apple的对象可以赋给Fruit对象。Apple<=Fruit Fruit的数组类型是Fruit[],这个就是由Fruit对象构造出来的新的类型,即f(Fruit),同理,Apple[]就是Apple构造出来的新的类型,就是f(Apple)

所以上方代码中的Fruit[] fruit = new Apple[10]是成立的,这也是面向对象编程中经常说的

子类变量能赋给父类变量,父类变量不能赋值给子类变量。

上方代码中的try..catch中的在编译器中是不会报错的,但是在运行的时候会报错,因为在编译器中数组的符号是Fruit类型,所以可以存放Fruit和Orange类型,但是在运行的时候会发现实际类型是Apple[]类型,所以会报错

java.lang.ArrayStoreException: contravariant.TestContravariant$Fruit

java.lang.ArrayStoreException: contravariant.TestContravariant$Orange

不变

@Test
public void testList() {
    List<Fruit> fruitList = new ArrayList<Apple>();
}

这样的代码在编译器上会直接报错。和数组不同,泛型没有内建的协变类型,使用泛型的时候,类型信息在编译期会被类型擦除,所以泛型将这种错误检测移到了编译器。所以泛型是 不变的

泛型的协变

但是这样就会出现一些很别扭的情况,打个比方就是一个可以放水果的盘子里面不能放苹果。

所以为了解决这种问题,Java在泛型中引入了通配符,使得泛型具有协变和逆变的性质, 协变泛型的用法就是<? extends Fruit>

@Test
public void testList() {
    List<? extends Fruit> fruitList = new ArrayList<Apple>();
    // 编译错误
    fruitList.add(new Apple());
    // 编译错误
    fruitList.add(new Jonathan());
    // 编译错误
    fruitList.add(new Fruit());
    // 编译错误
    fruitList.add(new Object());
}

当使用了泛型的通配符之后,确实可以实现将ArrayList进行向上转型了,实现了泛型的协变,但是却再也不能往容器中放任何东西了,连Apple本身都被禁止了

因为,在定义了fruitList之后,编译器只知道容器中的类型是Fruit或者它的子类,但是具体什么类型却不知道,编译器不知道能不能比配上就都不允许比配了。类比数组,在编译器的时候数组允许向数组中放Fruit和Orange等非法类型,但是运行时还是会报错,泛型是将这种检查移到了编译期,协变的过程中丢失了类型信息。

所以对于通配符,T和?的区别在于,T是一个具体的类型,但是?编译器并不知道是什么类型。不过这种用法并不影响从容器中取值。

List<? extends Fruit> fruitList = new ArrayList();

Fruit fruit = fruitList.get(0);

Object object = fruitList.get(0);

// 编译错误

Apple apple = fruitList.get(0);

泛型的逆变

@Test

public void testList() {

List<? super Apple> appleList = new ArrayList();

// 编译错误

Fruit fruit = appleList.get(0);

// 编译错误

Apple apple = appleList.get(0);

// 编译错误

Jonathan jonathan = appleList.get(0);

    Object object = appleList.get(0);

    appleList.add(new Apple());

    appleList.add(new Jonathan());
    // 编译错误
    appleList.add(new Fruit());
    // 编译错误
    appleList.add(new Object());
}

可以看到使用super就可以实现泛型的逆变,使用super的时候指出了泛型的下界是Apple,可以接受Apple的父类型,既然是Apple的父类型,编辑器就知道了向其中添加Apple或者Apple的子类是安全的了,所以,此时可以向容器中进行存,但是取的时候编辑器只知道是Apple的父类型,具体什么类型还是不知道,所以只有取值会出现编译错误,除非是取Object类型。

泛型协变逆变的用法

当平时定义变量的时候肯定不能像上面的例子一样使用泛型的通配符,具体的泛型通配符的使用方法在Effective Jave一书的第28条中有总结:

为了获得最大限度的灵活性,要在表示生产者或者消费者的输入参数上使用通配符类型。如果每个输入参数既是生产者,又是消费者,那么通配符类型对你就没有什么好处了:因为你需要的是严格的类型比配,这是不用任何通配符而得到的。

简单来说就是PECS表示->producer-extends,consumer-super。

不要使用通配符类型作为返回类型,除了为用户提供额外的灵活性之外,它还会强制用户在客户端代码中使用通配符类型。通配符类型对于类的用户来说应该是无形的,它们使方法能够接受它们应该接受的参数,并拒绝那些应该拒绝的参数,如果类的用户必须考虑通配符类型,类的API或许就会出错。

一个经典的例子就是java.uitl.Collections中的copy方法

public static void copy(List<? super T> dest, List<? extends T> src) {

int srcSize = src.size();

if (srcSize > dest.size())

throw new IndexOutOfBoundsException("Source does not fit in dest");

    if (srcSize < COPY_THRESHOLD ||
        (src instanceof RandomAccess && dest instanceof RandomAccess)) {
        for (int i=0; i<srcSize; i++)
            dest.set(i, src.get(i));
    } else {
        ListIterator<? super T> di=dest.listIterator();
        ListIterator<? extends T> si=src.listIterator();
        for (int i=0; i<srcSize; i++) {
            di.next();
            di.set(si.next());
        }
    }
}

dest为生产者只从其中取数据,src为消费者,只存放数据进去。