协变与逆变

Posted on By ᵇᵒ

定义

  • 当 A ≦ B 时,如果有 f(A) ≦ f(B),那么 f 叫做协变(covariance);
  • 当 A ≦ B 时,如果有 f(B) ≦ f(A),那么 f 叫做逆变(contravariance);
  • 如果上面两种关系都不成立则叫做不变(invariance)

栗子

定义一个父类 Fruit 和两个子类 Apple、Banana,而 Fruit 又继承自 Food

public class Food {}

public class Fruit extends Food {}

public class Apple extends Fruit {
    public void killPersonWithToxicSeed() {}
}

public class Banana extends Fruit {}

对象

    public void foo() {
        Fruit fruit = new Fruit();
        Apple apple = new Apple();
        fruit = apple;
        apple = fruit; // 编译错误: 不兼容的类型: Fruit无法转换为Apple
    }

Java 中对象是默认支持协变、不支持逆变的。 这很好理解,子类除了继承父类所有公开的属性和方法外,还可以定义父类没有的属性和方法,也就是说子类的功能 ≥ 父类的功能, 那么我们把一个子类实例对象赋值给一个父类变量是完全合理的,因为子类能实现所有父类的调用,这就是协变。 就像公司招人一样,我们都倾向于招一个 overqualified 而不是能力不足的。

反之,如果把一个父类实例对象赋值给一个子类变量则称之为逆变,对象是不支持逆变的。 就拿示例中的父类 Fruit 和子类 Apple 来说,我们都知道苹果籽有毒,吃多了会致人死亡,但不是所有的水果籽都有毒, 把一个 Fruit 实例赋值给一个 Apple 变量,无法保证该实例能够调用方法 killPersonWithToxicSeed。 更有甚者,我们结合上面的协变,先把子类 Banana 的一个实例对象赋值给 Fruit 变量,然后再把 Fruit 变量赋值给 Apple 变量, 如果对象支持逆变的话,我们会发现香蕉等于苹果了,这显然是不正确的。

数组

    public void foo() {
        Fruit[] fruits = new Fruit[9527];
        Apple[] apples = new Apple[9527];
        fruits = apples;
        apples = fruits; // 编译错误: 不兼容的类型: Fruit[]无法转换为Apple[]
    }

与对象一样,Java 中数组也是默认支持协变而不支持逆变的。 但数组协变其实是有些问题的:

    public void foo() {
        Fruit[] fruits = new Apple[9527];
        fruits[0] = new Banana();
    }

如上所示,我们分配了一个 Apple 数组,并通过数组协变将其赋值给一个 Fruit 数组,再通过对象协变将一个 Banana 实例对象赋值给 Fruit 数组的其中一个元素。 这段代码是可以成功编译的,但会运行出错 ArrayStoreException。 事实上足够智能的编译器都会提前警示: Storing element of type ‘com.y4n9b0.playground.Banana’ to array of ‘com.y4n9b0.playground.Apple’ elements will produce ‘ArrayStoreException’

出错的原因也不难理解,分配的数组实例对象是固定为 Apple 的,插入一个非 Apple 及其子类的对象肯定会类型不匹配。 其实取消数组协变可以在编译期就避免该问题,至于为什么要将数组设计为支持协变,应该是 Java 早期没有泛型(since Java 1.5)的妥协,可以参考知乎这个问答: java中,数组为什么要设计为协变?

很多设计缺陷,都是对历史的妥协,大家在工作中应该都有不少的体会。有时候不是我们不愿意做正确的事儿,实不能也:)

集合

    public void foo() {
        List<Fruit> fruits = new ArrayList<>();
        List<Apple> apples = new ArrayList<>();
        fruits = apples;        // 编译错误: 不兼容的类型: List<Apple>无法转换为List<Fruit>
        apples = fruits;        // 编译错误: 不兼容的类型: List<Fruit>无法转换为List<Apple>
        fruits.addAll(apples);  // 可以添加,这里实际上使用了对象的协变,与集合协变/逆变无关
    }

Java 中集合是默认不支持协变和逆变的。 但是我们可以通过通配符(Wildcards)手动支持协变和逆变:

    public void foo() {
        List<? extends Fruit> fruits = new ArrayList<>(); // 上界通配符,支持协变
        List<Apple> apples = new ArrayList<>();
        fruits = apples; // 协变成功
        apples = fruits; // 编译错误: 不兼容的类型: List<Fruit>无法转换为List<Apple>

        Fruit fruit = fruits.get(0);    // 可以读取
        fruits.add(new Apple());        // 编译错误: fruits 只能读,不能修改
        fruits.add(new Food());         // 编译错误: fruits 只能读,不能修改
    }

    public void bar() {
        List<Fruit> fruits = new ArrayList<>();
        List<? super Apple> apples = new ArrayList<>(); // 下界通配符,支持逆变
        fruits = apples; // 编译错误: 不兼容的类型: List<Apple>无法转换为List<Fruit>
        apples = fruits; // 逆变成功

        apples.add(new Apple());        // 可以添加,add/set Apple 及其子类 
        apples.add(new Food());         // 编译错误: apples 只能 add/set Apple 及其子类
        apples.add(new Banana());       // 编译错误: apples 只能 add/set Apple 及其子类
        fruits.add(new Banana());       // 可以添加。这里挺有意思,因为 Banana 不是 Apple 的子类,apples 不能直接添加 Banana;但是 fruits 可以添加 Banana,又因为 fruits 赋值给了 apples,所以通过 fruits 添加 Banana 可以达到 apples 添加 Banana 的目的。Surprise mother fxxker!
        Apple apple = apples.get(0);    // 编译错误: apples 只能修改,不能读取赋值给 Apple 变量
        Object object = apples.get(0);  // 可以读取赋值给 Object 变量
    }
  • <? extends T> 上界通配符,不能修改,只能读取。集合元素类型是 T 及其任意子类(派生类),所以读取出来的元素可以赋值给 T 及其父类。若想成功添加元素,必须保证被添加元素是当前集合元素类型或其子类,才能协变成功;上界通配符这里的元素类型是指 T 及其任意子类,如果我们把 T 及其任意子类看成一个类型的集合,由于子类可以无限派生,这个类型集合理论上是无限大,没有办法保证被添加的元素能协变成这个类型集合里的任意元素(类型),所以不能修改。不能修改也有很多好处,诸如避免多线程加锁。
  • <? super T> 下界通配符,可以修改,仅能读取赋值给 Object。集合元素类型是 T 及其任意父类,所以可以写入 T 及其子类(对象协变);而读取出来的元素仅可以赋值给 Object 是因为 Object 是所有类的基类,把任何东西赋值给 Object 都是 ok 的,apples.get(0) 一定是某个东西对吧?

PECS 原则

PECS(Producer Extends Consumer Super)原则:

  • 生产者,频繁往外读取内容的,适合用上界 extends。
  • 消费者,经常往里插入的,适合用下界 super。

kotlin

kotlin 基于 Java,所以协变/逆变是一样的,只不过语法、关键字不一样,记住 out(协变)、in(逆变)就行了。