泛型的协变与逆变

深入理解协变与逆变

Posted by John Mactavish on July 26, 2020

Definition

首先是泛型的定义,懒得复读没意义的内容了,直接上 Google 到的 第一项

A generic type is a type with formal type parameters. A parameterized type is an instantiation of a generic type with actual type arguments. A generic type is a reference type that has one or more type parameters. These type parameters are later replaced by type arguments when the generic type is instantiated (or declared ).

Example (of a generic type): 

interface Collection<E>  { 
  public void add (E x); 
  public Iterator<E> iterator();
}

The interface Collection has one type parameter E.
The type parameter E is a place holder that will later be replaced by a type argument when the generic type is instantiated and used. The instantiation of a generic type with actual type arguments is called a parameterized type.

Example (of a parameterized type): 

Collection<String> coll = new LinkedList<String>();

The declaration Collection<String> denotes a parameterized type, which is an instantiation of the generic type Collection, where the place holder E has been replaced by the concrete type String.

一句话概括就是 A parameterized type (such as Collection<String>) is an instantiation of the generic type (such as Collection<E>). 至于泛型(Generics)嘛,就是 refers to the technique of writing the code for a class without specifying the data type(s) that the class works on.

Covariance and Contravariance

对于一个 generic type,比如 List[T],如果对 A 及其子类型 B,满足 List[B] 也是 List[A] 的子类型, 那么就称这一现象为 covariance(协变);如果 List[A]List[B] 的子类型,即与原来的父子关系刚好相反, 则称为 contravariance(逆变)。如果一个类型 T 支持协变或逆变,则称这个类型为 variance(可型变), 否则称为 invariant(不可型变的)。

那么协变与逆变有什么讲究吗?看下面这个摘自深入理解 TypeScript的例子:

先约定如下的标记:

  • A ≼ B 意味着 A 是 B 的子类型。
  • A → B 指的是以 A 为参数类型,以 B 为返回值类型的函数类型。
  • x : A 意味着 x 的类型为 A。

假设我有如下三种类型:

Greyhound ≼ Dog ≼ Animal

Greyhound (灰狗)是 Dog (狗)的子类,而 Dog 则是 Animal (动物)的子类。 由于子类型通常是可传递的,因此我们也称 Greyhound 是 Animal 的子类。

问题:以下哪种类型是 Dog → Dog 的子类呢?

  • Greyhound → Greyhound
  • Greyhound → Animal
  • Animal → Animal
  • Animal → Greyhound

让我们来思考一下如何解答这个问题。首先我们假设 f 是一个以 Dog → Dog 为参数的函数。 它的返回值并不重要,为了具体描述问题,我们假设函数结构体是这样的: f : (Dog → Dog) → String。

现在我想给函数 f 传入某个函数 g 来调用。我们来瞧瞧当 g 为以上四种类型时,会发生什么情况。

  1. 我们假设 g : Greyhound → Greyhound, f(g) 的类型是否安全? 不安全,因为在 f 函数体内调用它的参数(g 函数)时,提供给 g 的可能是一个不同于灰狗但又是狗的子类,例如 GermanShepherd (牧羊犬);
  2. 我们假设 g : Greyhound → Animal, f(g) 的类型是否安全? 不安全。理由同(1);
  3. 我们假设 g : Animal → Animal, f(g) 的类型是否安全? 不安全。因为 f 有可能在调用完 g 之后,让 g 的返回值,也就是 Animal (动物)狗叫,但是并非所有动物都会狗叫;
  4. 我们假设 g : Animal → Greyhound, f(g) 的类型是否安全? 是的,它的类型是安全的。首先,f 可能会以任何狗的品种来作为参数调用,而所有的狗都是动物。其次,它可能会假设结果是一条狗,而所有的灰狗都是狗。

如上所述,我们得出结论:

(Animal → Greyhound) ≼ (Dog → Dog)

返回值类型很容易理解:灰狗是狗的子类。但参数类型则是相反的:动物是狗的父类!

用合适的术语来描述这个奇怪的表现,可以说我们要求一个函数类型满足,返回值类型是协变的, 而参数类型是逆变的。返回值类型是协变的,意思是 A ≼ B 就意味着 (T → A) ≼ (T → B) ; 参数类型是逆变的,意思是 A ≼ B 就意味着 (B → T) ≼ (A → T) ( A 和 B 的位置颠倒过来了)。

你也许会好奇,上面节选的部分讨论的是函数类型,那对于我们一开始讨论的 List[T] 又如何呢。 换句话说,List[Dog] 能否为 List[Animal] 的子类?这个问题比函数类型的稍微复杂一点。

前面我们设想情景用的是这样的:

f : (Dog → Dog) → String

现在我们则是要考虑

f : List[Dog] → String

对于不支持高阶函数的面向对象语言,f 的参数不是一个函数,不可以调用, 但是它是一个对象,拥有可以调用的方法,我们因此转而考虑 List[Dog] 的方法的类型签名。 假如 List[T] 拥有方法 g : T → T,记作 List[T]{g : T → T}, 那么对于具体类型(parameterized type) Dog 则有

f : List[Dog]{g : Dog → Dog} → String

问题转化为了上面的函数类型问题,讨论结果与 f : (Dog → Dog) → String 一致。 即 List[Animal]{g : Animal → Animal}List[Greyhound]{g : Greyhound → Greyhound}, 都不可以作为 f 的参数,T 应为不可型变的。

但是考虑另一个情景:

f : List[Dog]{g : () → Dog} → String

即假如 List[T] 拥有方法 g : () → T, 显然此时 List[Greyhound]{g : () → Greyhound} 可以代替 List[Dog]{g : () → Dog} 作为 f 的输入参数, f 调用参数的 g 方法,把实际返回值 Greyhound 当作 Dog 来对待是没有问题的。事实上, 不变的列表(immutable List[T])确实有这样的方法 g,它就是 get(获取List[T]中的元素)。 此时,List[T] 中的 T 可以支持协变。同理可得,List[Animal]{h : Animal → Unit} 可以代替 List[Dog]{h : Dog → Unit}。但是如果 List[T] 同时有方法 g 和 h 就会出大问题。因为我们无法确定 函数 f 会怎样使用输入参数的 g 和 h 方法。可变列表就是这样一个例子, 假设有

generic type MutableList[T]{get : E → T, add : T → R}

(E,R 为其他类型,按理说应该写作 MutableList[T,E,R],这里省略了)

和情景

f : MutableList[Dog]{get : Int → Dog, add : Dog → Bool} → String

那么当输入参数为 MutableList[Animal]{get : Int → Animal, add : Animal → Bool} 时,f 使用 参数的方法 get 的返回值会出错(f 按照自己的类型签名期待一个 Dog 的返回值,实际 get 只保证返回 Animal); 当输入参数为 MutableList[Greyhound]{get : Int → Greyhound, add : Greyhound → Bool} 时,f 调用 参数的方法 add 时会出错(add 使用 Greyhound 类型的参数,但 f 按照自己的类型签名只保证传给 add 的是 Dog)。

不难发现,上面的这个例子之所以出问题是因为 MutableList[T] 的 get 和 add 方法同时使用一个类型参数 T, 从而可以在群内定义新方法

add_and_get = add compose get :: T → T (假设 R==E 或者丢弃 R 再随便传入一个 E)

这就回到了一开始讨论的 List[T]{g : T → T} 中 T 不可型变的问题上去了。我们可能会灵机一动,那提供两个类型 参数又怎么样呢?定义

Fun[T1,T2]{g : E → T1, h : T2 → R}

那么试一试就会发现这里 T1 是协变的, 同时 T2 是逆变的。

顺带一提,我们在这里借助泛型的函数类型(generic function type)来讨论泛型类(generic class), 而在一些融合了 OOP 与 FP 的语言(如 Scala)中函数(function)与类(class)之间确实有着微妙的关系。 Scala 中的函数类型表示为 (T1,T2…) => R,小括号里的是入参类型(最多可以有22个,最少为0), 右箭头右边的是返回结果类型;它背后其实等价于 trait FunctionN[T1,T2…, R] (N 对应 0~22); 入参类型 T1,T2… 都是逆变的,而结果类型 R 是协变的。而 trait FunctionN 其实定义了一个方法 apply:

trait Function2[-T1,-T2,+R] {
  def apply (v1: T1, v2: T2) : R
}

那么一个满足该 trait 的对象其实就是一个函数,函数调用就是对象的 apply 方法调用, 函数的类型就是 trait 中 apply 方法的类型。OOP 与 FP 的语法设计就在这里交汇。

Real World Cases

最后介绍一下几个实际语言中的实现吧。

我们上面的这个就是 Scala 中的实现了,再拿下来看看。

trait Function2[-T1,-T2,+R] {
  def apply (v1: T1, v2: T2) : R
}

类型参数的型变特性在 generic type 定义时给出了,类型参数前的负号(-T1)表示它(T1)逆变, 正号(+R)则表示协变。

再来看看 C# 与 Kotlin 中的实现(以 Kotlin 为例,例子来自 官方文档):

interface Source<out T> {
    fun nextT(): T
}

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // This is OK, since T is an out-parameter
    // ...
}

interface Comparable<in T> {
    operator fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
    x.compareTo(1.0) // 1.0 has type Double, which is a subtype of Number
    // Thus, we can assign x to a variable of type Comparable<Double>
    val y: Comparable<Double> = x // OK!
}

协变用 out 表示,逆变用 in 表示;初看有点特别,细想也很合理:协变和逆变两词没能表现它们之间的不同, 但是 out 和 in 却很直白,方便编程。

out: 输出(作为结果) in: 输入(作为参数)

所以如果有一个泛型参数标记为 out,则代表它是用来输出的,只能作为结果返回, 而如果有一个泛型参数标记为 in,则代表它是用来输入的,也就是它只能作为参数。

Java 的设计就差多了。首先数组与泛型容器在语法上是不统一的两部分;其次 Java 并不支持声明点型变(declaration-site variance,即在定义一个类型时声明它是 variance, 也称 definition-site),但是它支持使用点型变(use-site variance),即:

List<? extends Object> list = new ArrayList<String>();

static void getUperNumber(List<? extends Number> data) {
    System.out.println("data :" + data.get(0));
}

最后,数组既是可变的(mutable),又是协变的。记得前面的 MutableList[T] 的例子吗,这当然不安全。 不过,数组记得它内部元素的具体类型,并且会在运行时(Runtime)做类型检查。所以不安全的代码 虽然能通过编译(静态类型检查), 但运行时会报错,确切的说是插入不兼容元素时报错,因为它不支持逆变。既然一开始就插不进去, 也就用不着考虑取出时的情况了,插入时报错也算满足 LET IT CRASH 原则,还是有点安全保证的。

Number[] num = new Integer[20]; 
num[0] = 5.6;     //Error

另外讲一个有趣的事,Java 中不支持泛型数组。这是因为泛型是用擦除(Erasure)实现的, 运行时类型参数会被擦掉。无论你声明的的是 List<String>,还是 List<Integer> 或者原生类 List, 容器实际类型都是 List<Object>,这样一来数组就无法记得它内部元素的具体类型,这就会导致

public static void main(String[] args) {
    Box<Dog>[] dogBoxes = new Box<Dog>[3]; // 装着狗的箱子
    Object[] boxes = dogBoxes; // 数组协变,这样声明是可以的
    boxes[0] = new Box<Cat>(); // 运行时数组看来都是 Box<Object>, 禁止逆变的检查在此失效
    Dog dog = boxes[0].get(); // 报错被延迟到取出元素时,不合理的设计
} 

因此,Java 干脆不支持泛型数组,上面的代码便被编译器禁止了。


最后附上GitHub:https://github.com/gonearewe