Java泛型的协变、逆变和不变

96
JarryWell
2018.07.11 20:41 字数 2104

背景

平时在看一些开源框架源码时总发现他们会或多或少的用到泛型来定义数据类型。这可以理解,毕竟牛逼的开源框架大都是为了解决一类普遍问题而存在的;但看不懂的是,有时参数或者返回值会出现诸如<? extends T><? super T>这样带通配符的泛型参数,这种通配符的泛型是什么意思?如果直接用指定的T会有什么问题?这样做是为了解决什么问题?这是我的疑惑。咨询公司完全做Java开发的服务端同学后,也未能完全解惑。于是查找资料后引出今天的主题----Java泛型的协变(<? extends T>)、逆变(<? super T>)和不变(T)。

举例

  1. RxJava框架
    在定义一个Observable后,最终会通过subscribe()来订阅一个Observer, 而subscribe()参数的定义就使用了逆变(<? super T>),如下所示:
/**
 * 参数observer用到了逆变<? super T>
 */
public final void subscribe(Observer<? super T> observer) {
    try {
        //....
        subscribeActual(observer); //实际发起的订阅
        //...方法
    } catch (Throwable e) {
        //...
    }
}

map操作符的参数Function泛型分别使用协变和逆变实现,如下所示:

/**
 * 参数mapper是一个Function接口类型,第一个参数用到了逆变<? super T>,
 * 第二个参数用到了协变<? extends R>
 */
public final <R> Observable<R> map(Function<? super T, ? extends R> mapper) {
    //...
    return RxJavaPlugins.onAssembly(new ObservableMap<T, R>(this, mapper));
}
  1. java集合框架Collections的工具方法copy()分别使用协变和逆变定义了两个集合的类型,如下所示:
/**
 * 目的列表使用的是逆变,源列表使用的是协变
 */
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    //...
    for (int i=0; i<srcSize; i++) {
        dest.set(i, src.get(i));
    }
    //...
}

3.java8中Stream的超级接口collect(),其参数定义使用了不变和逆变,如下所示:

<R> R collect(Supplier<R> supplier,
              BiConsumer<R, ? super T> accumulator,
              BiConsumer<R, R> combiner);

面对上面这些源码的定义,不禁让人产生疑惑!!!

概念

假设Orange类是Fruit类的子类,以集合类List<T>为例:

  1. 型变:用来描述类型转换后的继承关系(即协变、逆变和不变的统称)。比如:List<Orange>List<Fruit>的子类型吗?答案是No,两者并没有关系,并不能相互读写数据。因此,型变是处理如List<Orange>(List<? extends Orange>)和List<Fruit>子类型关系的一种表达方式。
  2. 协变(covariance):满足条件诸如List<Orange>List<? extends Fruit>的子类型时,称为协变。
  3. 逆变(covariance):满足条件List<Fruit>List<? super Orange>的子类型时,称为逆变。
  4. 不变(invariance):表示List<Orange>List<Fruit>不存在型变关系。

注:子类(subclass)和子类型(subtype)不是同一个概念。

先回答文章开头提出的几个问题:

  1. 带通配符的泛型是什么意思?
    ----这是因为Java泛型本身不支持型变,因此引入通配符来解决泛型类型的类型转换问题,这是Java型变的通用表达式(<? extends T>表示类型转换的上界;<? super T>表示类型转换的下界)。

  2. 如果直接用指定的T会有什么问题?
    ----直接使用T作为参数的类型不会有任何问题,但这会限制函数接口调用的灵活性,导致框架的通用性降低。

  3. 这样做是为了解决什么问题?
    ----综上两点,型变处理的最终目的是在保证了运行时类型安全的基础上,并提高参数类型的灵活性。

型变

看一个不使用型变的例子:

/**
 * 1.定义一个String类型的List
 */
List<String> value1 = new ArrayList<String>();

/**
 * 2. 这里编译器报错,因为两者没有型变关系,无法直接赋值,后续操作会导致类型不安全
 */
List<Object> value2 = value1; //error


/**
 * 3.假如上面第2步编译通过了,那么此时add()这个整型数据1到value2中
 * 是没问题的,因为他的类型是Object。但是读取时会碰到困难,如第4步。
 */
value2.add(1);

/**
 * 4.但此时读出来的是什么类型呢,上一步add了一个整型数据1,此时如果用String类
 * 型的变量接返回值,肯定不合适,因此运行时会报类型转换异常!!
 */
String result = value1.get(0); //error

上面举例说明了在不使用型变的情况下,对泛型数据的操作会面临种种困难,虽然保证了运行时参数类型的安全,却限制了接口的灵活性(编译器检查),比如:如果我们只调用value2(List<Object>)的get()方法,不调用add()方法(只读取数据不写入数据),显然此时不会有类型的安全问题,那如何限制只能调用get()却不能add()方法呢?当然只能靠编译器限制了,让你调add()方法的时候编译都通不过就可以了。通配符就是干这件事的,通知编译器,限制我们对于某些方法的调用,以保证运行时的类型安全。

协变

对于上面不型变的例子,我们可以做如下调整,就可以达到协变的目的:

  /**
 * 2. 这里编译器不会报错
 */
List<? extends String> value2 = value1;

/**
 * 3.但此处编译器报错了,编译器限制了写入数据的操作
 */
value2.add(1); //error

但上面的简单例子太过简单,缺少继承关系,不能明显说明问题,下面仍以Orange类是Fruit类的子类来举例说明:

/**
 * 1.定义一个类型上界限定为Fruit的List,即协变
 */
List<? extends Fruit> fruits = new ArrayList<>();

/**
 * 2.编译器报错,不能添加任何类型的数据
 * 原因是:
 * List.add(T t)函数通过上面的类型指定后,参数会变成
 * <? extends Fruit>,从这个参数中,编译器无法知道需要哪个具体的Fruit子类型,
 * Orange、Banana甚至Fruit都可以,因此,为了保证类型安全,编译器拒绝任何类型。
 */
//fruits.add(new Orange());
//fruits.add(new Fruit());
//fruits.add(new Object());

/**
 * 3.此处正常!! 由于我们定义是指定了上界为Fruit,因此此处的返回值肯定至少是Fruit类型,
 * 而基类型可以引用子类型
 */
Fruit f = fruits.get(0);

通过上面代码的注释可以看出,协变限制了参数中带T的方法调用,比如上面的add(T t)方法(我们称之为消费者方法),而允许生产者方法的调用如T get(int position),以此来保证类型的安全。

逆变

协变的反方向是逆变,在协变中我们可以安全地从泛型类中读取(从一个方法中返回),而在逆变中我们可以安全地向泛型类中写入(传递给一个方法)。

/**
 * 1.定义一个Object的List,作为原始数据列表
 */
List<Object> objects = new ArrayList<>();
objects.add(new Object()); //添加数据没有问题
objects.add(new Orange()); //仍然没有问题,

/**
 * 2.定义一个类型下界限定为Fruit的List,并将objects赋值给它。
 * 此时编译不会报错,因为满足逆变的条件。
 */
List<? super Fruit> fruits = objects;

/**
 * 3.add(T t)函数,编译器不会报错,因为fruits接受Fruit的基类类型,
 * 而该类型可以引用其子类型(多态性)
 */
fruits.add(new Orange());
fruits.add(new Fruit());
fruits.add(new RedApple());

/**
 * 4.此处编译器报错,因为fruits限定的是下界是Friut类型,因此,
 * 编译器并不知道确切的类型是什么,没法找到一个合适的类型接受返回值
 */
Fruit f = fruits.get(0);

/**
 * 5.此处不会报错,因为Object是Fruit的最顶层基类,满足下界的限定
 */
//Object obj = fruits.get(0);

通过上面代码的注释可以看出,逆变限制了读取方法的调用,比如上面的T get(int position)方法(我们称之为生产者方法),而允许消费者方法的调用如add(T t),依次来保证类型的安全。

总结

extends限定了通配符类型的上界,所以我们可以安全地从其中读取;而super限定了通配符类型的下界,所以我们可以安全地向其中写入。
我们把那些只能从中读取的对象称为生产者(Producer),我们可以从生产者中安全地读取;只能写入的对象称为消费者(Consumer)。
因此这里就是著名的PECS原则:Producer-Extends, Consumer-Super。

源码分析实战

结合上文总结的PECS原则,来看文章开头提到的框架源码(这里就不贴重复的源码了),不难看出其含义了:

  1. RxJava中的subscribe(Observer<? super T> observer)函数由于并没有返回T类型的数据,因此是一个消费者方法,根据PECS原则,此处参数应使用逆变来提高灵活性。
  2. RxJava的map操作符函数map(Function<? super T, ? extends R> mapper),他最终的调用在MapObserver类中的onNext()中执行R v = mapper.apply(t),仍根据PECS原则,T仅做为传入参数类型,因此是个消费者参数,可以使用逆变;而R仅在返回值中出现,因此是个生产者参数,可以使用协变,来保证类型类型安全。
  3. java集合框架Collections的工具方法copy(List<? super T> dest, List<? extends T> src),它具体的实现是如下:
    for (int i=0; i<srcSize; i++) {
        dest.set(i, src.get(i));
    }

可以看出,src调用了get(i),这是一个生产者的过程,因此这里使用了协变参数;而dest调用了set(i, t),这是一个消费者的过程,因此这里使用了逆变参数。

数组的协变

Java中数组是协变的:可以向子类型的数组赋予基类型的数组引用,由于数组在Java中是完全定义的,因此内建了编译期和运行时的检查,具体参见如下代码注释:

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

/**
 * 创建了一个Apple数组,并将其赋值给一个Fruit数组引用,编译器和运行时都允许
 */
Fruit[] fruits = new Apple[10];

/**
 * 将子类对象放置到父类数组中,编译器和运行时都允许
 */
fruits[0] = new Apple();
fruits[1] = new Jonathan();

try {
    /**
     * 将Apple的父类对象放置到子类数组中,编译器允许,但运行时检查抛出异常
     */
    fruits[2] = new Fruit();
} catch (Exception e) {
    Log.i(TAG, "array exception!", e);
}

try {
    /**
     * 将Apple的兄弟对象放置到数组中,编译器允许,但运行时检查抛出异常
     */
    fruits[3] = new Orange();
} catch (Exception e) {
    Log.i(TAG, "array exception!", e);
}

自限定与协变

Java中一个常见的自限定写法是:

class Base<T extends Base<T>> {
    T element;
    
    T get() {
        return element;
    }
    
    void set(T t) {
        element = t;
    }
}

这种语法定义了一个基类,这个基类能够使用子类作为其参数、返回类型、作用域。

  1. 协变参数类型
    在非泛型代码中,参数类型不能随子类型发生变化。方法只能重载不能重写。在使用自限定类型时,方法接受子类型而不是基类型为参数:
/**
 * 自限定协变参数类型
 * 方法接受只能接受子类型而不是基类型为参数
 * @param <T>
 */
interface SetInterface<T extends SetInterface<T>> {

    void set(T arg);
}

/**
 * 具体的子类型
 * 避免重写基类的方法
 */
interface SubSetInterface extends SetInterface<SubSetInterface> {}


public void test5(SubSetInterface s1, SubSetInterface s2, SetInterface sb) {
    /**
     * 编译通过
     */
    s1.set(s2);

    /**
     * 只能接受具体的子类型,不能接受SetInterface基类型
     */
    //s1.set(sb); //error
}
  1. 协变返回类型
    继承自限定基类的子类,将产生确切的子类型作为其返回值.不过,这种实现java的多态性已经可以达到目的(基类引用子类):
/**
 * 自限定协变返回类型
 * @param <T>
 */
interface GetInterface<T extends GetInterface<T>> {

    T get();
}


/**
 * 具体的子类型
 * 避免重写基类的方法
 */
interface SubGetInterface extends GetInterface<SubGetInterface> {}



public void test4(SubGetInterface g) {
    GetInterface s1 = g.get();
    SubGetInterface s2 = g.get();
}

参考文档

https://www.jianshu.com/p/0c2948f7e656
https://www.cnblogs.com/en-heng/p/5041124.html
https://www.jianshu.com/p/2bf15c5265c5

Java通识