协变和逆变——死磕自己,绕晕大家

1,212 阅读10分钟

定义

引子:可替换原则

假设有两个类型T和U,那么它们的关系只可能是以下四种:

  • T比U大(T是U的祖先)
  • T比U小
  • 两者一样大
  • 两者无关 那么当T比U大或者两者一样大时,我们可以保证这么写是合法的:
U u = ...;
T t = u;

这是面向对象的基本特性。 可替换原则又可表述为:赋值兼容特性(assignment compatibility),也就是说可替换性表现为赋值操作是合法的。赋值操作有显式的等号,也有隐式的参数传递:

  • 显式赋值 A = B
  • 接收方法的返回值 A = func(),其中func()的返回值声明是B类型
  • 方法调用时的参数传递 func(B),其中func()的入参声明是A类型

接下来我们看看协变和逆变的定义。

协变和逆变的定义

假设前面的T和U是有继承关系的,现在有一种操作(术语就叫映射吧)将T和U转化成了更复杂的复合类型T'和U',那么:

  • 如果新类型的大小关系和T与U的大小关系一样,则称该操作是协变的
  • 如果新类型的大小关系和原关系相反,则称操作是逆变的
  • 如果新类型无关了,则称该操作是不变(或抗变)的

这个定义比较抽象,下面给个例子帮助理解:

设T是Animal,U是Cat,显然后者比前者小。 令这里的操作是“数组化”,那么T'和U'就分别是Animal[]Cat[]。在Java中Cat[]是可以被赋值给Animal[]的,所以后者比前者小。因此我们说,Java的数组是协变的。如果Animal[]可以被赋值给Cat[],那就说数组是逆变的;如果两个方向都不能赋值,就说数组是不变的。

既然可替换方向有三种,那我们怎么选呢?下一节我们将看到,要考虑的主要是类型安全问题。

小结

本节我们了解了可变性(就是协变、逆变、不变的一种)的定义。关键点就是:1. 可替换;2. 复合类型。

接下来我们分情况讨论常见的复合类型。

需要考虑协变和逆变的几种复合类型:

我们将按照由易到难的顺序逐一讨论。

数组

先说结论:

Read-only data types (sources) can be covariant; write-only data types (sinks) can be contravariant. Mutable data types which act as both sources and sinks should be invariant.

  • 只读的数据可以是协变的
  • 只写的数据可以是逆变的
  • 可读写的数据应该是不变的

为啥呢?举个数组的例子:

  • covariant: a Cat[] is an Animal[];
  • contravariant: an Animal[] is a Cat[];
  • invariant: an Animal[] is not a Cat[] and a Cat[] is not an Animal[].

如果我们要一个Animal[]数组,那么采用协变是安全的,因为即使数组里面有Cat,把它当成Animal也是OK的,代码太常见,省略。但是采用逆变就不对了:如果我们要读一个Cat[],但是有可能拿到的是一个Animal类,显然是有问题的:

Cat[] cats = new Animal[10]{...}; // 假如某语言的数组支持逆变,可以这么写
cats[0].miao(); // 挂了

如果我们要一个Animal[]数组,采用协变则是有问题的。看看这段程序:

Animal[] animals = new Cat[10]; // 协变,没毛病
animals[0] = new Dog(); // 凉了

Cat数组里面加入一个Dog时,显然类型对不上了。主流语言的数组往往都是协变的,因此编译器允许这么写,但是运行时Java会抛出ArrayStoreException,C#会抛出ArrayTypeMismatchException.

而采用逆变是没有问题的。

Cat[] cats = new Animal[10]{...}; // 假如某语言的数组支持逆变,可以这么写
cats[0] = new Dog(); // 没有类型错误,就是这么任性

综上:

要想保持绝对的类型安全,可读写的数据类型应该是不变的。而主流语言的数组实现时照顾了读,在写的时候可能会出现运行时异常。那么为啥不把数组设计成不可变的?

Java和C#一开始是没有泛型的。那么要提供一些通用的操作数组的方法,比如:

boolean equalArrays(Object[] a1, Object[] a2);
void shuffleArray(Object[] a);

如果数组是不变的,那就没法操作String[]等其它数组。所以为了方便使用,大家不约而同地选择让数组是协变的。这显然带来了写的问题。它们的处理方式是,在创建数组时记下最初的真正数据类型,后面写入新元素时,运行时一定会检查新元素的类型是否匹配,如果不匹配就抛异常。

这么做显然是有点问题的,但编程语言需要保持对老代码的兼容性,所以数组的行为就这样保留下来。至于有什么问题,主要是以下两点:

  1. 把本来在编译时能发现的类型错误推迟到了运行时才能发现
  2. 每次写数组都要悄悄地检查类型,影响性能

后面有了泛型之后,上面两个方法又有了泛型的实现,从而解决了数组的问题:

<T> boolean equalArrays(T[] a1, T[] a2);
<T> void shuffleArray(T[] a);

方法

先看例子1:

假设我们有个方法(不考虑具体编程语言):

static Cat makeCat() { return new Cat(); }

那么把它赋值给如下方法是类型安全的:

Supplier<Animal> s = makeCat;

因为我们期待s执行的结果是一个Animal,而makeCat返回的是Cat,不影响使用。

所以方法的返回值可以是协变的。

再看例子2: 假设我们有两个方法:

static void useCat(Cat cat) {...}
static void useAnimal(Animal animal) {...}

那么,下面的赋值是类型安全的(Mammal意为哺乳动物):

Consumer<Mammal> c = useAnimal;

因为c在执行时会传入Mammal或其子类,而useAnimal只要求Animal就行了。

相反,下面的赋值则是有问题的:

Consumer<Mammal> c = useCat;

因为c在执行时会传入Mammal或其子类,而useCat是要求Cat的,这可就保证不了了。

所以,方法的输入参数往往是逆变的。(为什么是往往,后面会解释)

综上:

方法的输入参数是逆变的,返回值是协变的。

如下面的Java示例,主流语言对于方法确实是这么处理的。

/**
 * 在本例中,传到此方法的输入一定是个Integer,因此我们用个Number接受显然也没问题。
 * 而调用者期待此方法返回的是Number或其子类,因此我们返回个Long也没问题。
 */
Long numberToLong(Number number) {
    return number.longValue();
}

@Test
public void test() {
    // 虽然函数签名类型不一样,因为参数支持逆变,返回值支持协变,仍然合法
    Function<Integer, Number> function = this::numberToLong;

    // 调用function.apply时,输入只能是个Integer或其子类,而result只能是Number或其父类
    // 这个不叫协变或逆变,仅仅是面向对象的特性(赋值兼容性)
    Object result = function.apply(1);

    System.out.println(result);
}

严格地说,方法在Java中不是一等公民Function在Java中只是个接口。这里举例仅用来说明方法确实是有协变和逆变的行为。函数式语言则是有专门的方法类型的。

进一步:子类复写父类方法时的协变和逆变

子类复写父类方法时,方法签名也不用完全一样。如图所示:

按照上面的分析,只要方法参数是逆变的,返回值是协变的,就是安全的。这其实也暗合了Liskov替换原则的几条规则。

然而这么玩实在太灵活了,主流语言中只有Scala严格实现了这个理论,其它语言则做了一些限制或变形。具体列举如下:

编程语言参数类型返回值类型
C++, Java, C#不变协变
Scala逆变协变
Eiffel协变协变

埃菲尔头铁语言居然打破了Liskov替换原则,法国人就是任性。

最后,方法里面还有一种高阶方法(high order functions)也要考虑可变性,过于烧脑,暂且按下不表。

泛型类或接口

最后我们来看泛型问题。

在C#设计者看来,泛型接口可以被视作方法的变形。 How?我们看前面的各种Supplier,Consumer,Function实例,都可以理解为一个对象,里面只有一个apply方法。所以接口就可以理解为方法的变形啦!这么理解有什么好处呢?就可以把方法的可变性规则推广到接口了,妙啊!

假设我们有个泛型接口,就一个方法(再次,不要关注具体语言):

public interface Iterator<E> {
    E next();
}

由于这个泛型参数E是出现在方法返回值位置的,那么整个Iterator<E>接口就可以是协变的。也就是说以下这种赋值理论上是合法的:

Iterator<String> strItor = ...
Iterator<Object> itor = strItor;

我们再看另一个接口:

public interface Comparable<T> {
    public int compareTo(T o);
}

泛型参数T出现在方法参数位置,所以Comparable<T>接口可以是逆变的。也就是说这种赋值理论上是合法的:

Comparable<Object> comp = ...
Comparable<String> c = comp;

好了,对理论的讨论到此结束,接下来要看具体语言如何支持泛型类或接口的可变性。主要有两种流派:

声明时指定

如前所述,C#是要在声明时指定接口泛型的可变性的。如果不声明,就是不可变的。绝大多数语言:Scala,Kotlin,都是这种行为。

Eric Lippert还在此文中讨论了使用哪种记号表示协变和逆变,最终还是觉得inout最直观。接口的签名长这样:

interface IFoo<out T, in U> { …

接口的泛型参数是协变还是逆变是不可以随便声明的。编译器会检查泛型参数的位置以及各种复杂的传递规则,确保声明是一致的。

Kotlin和C#的记号一样,而Scala选择了 +-符号。

使用时指定

没错,就是Java了。Java的泛型根本就是不可变的。只不过可以通过通配符支持协变和逆变:

List<Number> numbers = new LinkedList<>();
List<? super Integer> a = numbers; // 逆变
a.add(1); //合法
Object a1 = a.get(1); // get只能得到Object

List<Integer> integers = new LinkedList<>(); 
List<? extends Integer> b = integers; // 协变
Integer object = b.get(1); //合法
b.add(1); // 写不了

在声明方法签名时,Joshua Bloch在《Effective Java》中总结了PECS原则(Producer Extends, Consumer Super),此处不再赘述。

不过大家有没有想过,为啥声明时指定时的原则是:参数逆变,返回值协变;而到了PECS,参数既可能逆变,又可能协变?

因为从使用者角度来说,参数可能是用来读的(生产者),也可能是用来写的(消费者);而在声明一个泛型类时,这个类如何使用泛型自己是知道的。其实,只要我保证我的方法参数只读不写,我也可以强行规定本泛型类是协变的!见Kotlin的@UnsafeVariance. 所以“参数逆变,返回值协变”并非一直正确。

另外,为啥只有Java这么玩呢? 还是因为这么搞虽然灵活,但太难理解了,会造成很多困惑。具体参见:比较

参考资料