@月黑风高食肉虎 噗噗虎的技术博客

泛型的协变、逆变、不变


听潘总说,新版Go语言要上泛型了,哈哈!说到泛型最终还是离不开它的协变、逆变和不变的问题,譬如在Java里StringObject的子类型,那么List<String>List<Object>的子类型吗?最近正好在读《kotlin in Action》里关于泛型的一章,我觉得这章写得很好,非常简单易懂,因此在这里做个笔记。

要回答上述这个问题,我们首先要定义两个术语:子类型(subtype)和父类型(supertype)。

子类型(subtype)与父类型(supertype)

根据《kotlin in Action》中的定义:

A type B is a subtype of a type A if you can use the value of the type B whenever a value of the type A is required. For instance, Int is a subtype of Number, but Int isn’t a subtype of String.

子类型(subtype)指,如果你可以在任何必须使用类型A的值的地方使用类型B的值,那么我们就说类型B是类型A的子类型。譬如,(在kotlin里)IntNumber的子类型,但不是String的子类型。

The term supertype is the opposite of subtype. If A is a subtype of B, then B is a supertype of A.

父类型(supertype)与子类型(subtype)相反,就是说如果A是B的子类型,那么B就是A的父类型。

根据字面意思,这很好理解,简单来说我们可以把它们理解成为父类(superclass)和子类(subclass),但两者并不完全相同,主要原因有两点。

第一点是因为,在kotlin里有nullable和非nullable类型的区别,譬如非nullable类型Int是nullable类型Int?的子类型(subtype)但不是子类(subclass)。

这点很好理解,因为一个nullable的变量通常可以被赋予两种类型的值——null和非null的值。那么在同类型的非nullable的类型变量下,你只能赋予非null的值。 所以你可以把一个非nullable的变量赋予给同类型的nullable的变量,这也就符合了子类型(subtype)的定义——我可以在nullable类型的地方用同类型的非nullable的值。

举个例子就是,譬如:

var nullableNumber: Number? = ... // Number 的 nullable 变量
var nonNullNumber: Number = ...   // Number 的 非nullable 变量

nullableNumber = nonNullNumber    // 你可以这么赋值,但是你不能
nonNullNumber = nullableNumber    // 这行会编译不过

第二点,就是为了让我们能更好地讨论前面的问题:如果StringObject的子类型,那么List<String>List<Object>的子类型吗?

理解了子类型(subtype)的定义之后,这个问题可以转化成:如果StringObject的子类型,那么我可以在需要用List<Object>的地方用List<String>吗? 那么我们就尝试着来回答这个问题。

泛型协变(Covariance)

简单来说,上面的问题的答案是:可以,但这是有代价的。

我们把上述情况,就是如果StringObject的子类型,那么我可以在需要用List<Object>的地方用List<String>的情况,叫做协变(Covariance)。 考虑以下示例:

// process类
class Process {
  fun execute() { ... }
}

// 循环执行List中所有的Process
fun executeProcess(processList: List<Process> ) {
  for (process in processList) {
    process.execute();
  }
}

// Process类的子类,SpecialProcess
class SpecialProcess : Process {
  fun doSthSpecial() { ... }
}

// 循环执行doSthSpecial,执行完成后执行execute
fun executeSpecialProcess(specialProcessList: List<SpecialProcess>) {
  for (specialProcess in specialProcessList) {
    specialProcess.doSthSpecial()
  }
  // 然后执行normal execute
  executeProcess(specialProcessList) // 可以正常调用,因为泛类型List<SpecialProcess>发生了协变,而方法定义中,这边参数只定义了List<Process>。
  // 想想如果👆这行报错说不接受List<SpecialProcess>类型,你会不会觉得不符合常理?
}

但如果像上面那样,所有需要用List<Process>的地方都能发生协变,那是不安全的。 考虑以下代码:

// broken process 类
class BrokenProcess : Process {
  // ...
}

// 往list里新加一个 broken process
fun appendNewBrokenProcess(processList: List<Process>) {
  processList.add(BrokenProcess()) // 这行会编译不过,但我们先假设这行能 work,稍后解释为什么。
}

// 如果有一个special process的list
val specialProcesses : List<SpecialProcess> = ...

// 先调用appendNewBrokenProcess方法往列表里增加一个broken process
// 注意special process list在下面这个方法的参数里发生了协变
appendNewBrokenProcess(specialProcesses)

// 然后我们去执行这个special process的list
executeSpecialProcess(specialProcesses) 
// 👆这个调用会抛ClassCastException,因为special process list里面多了个非SpecialProcess类型的BrokenProcess

从上面的示例可以看到,这一段代码并不安全,尤其是appendNewBrokenProcess这个方法,因为它使List<SpecialProcess>发生了协变,并且修改了发生协变的对象,往它里写入了 不应该被接受的类型的新元素(specialProcesses列表应该只接受SpecialProcess,但它却加了个BrokenProcess),从而导致了后面的executeSpecialProcess方法调用出cast exception的错。

所以我们说,如果随随便便让泛型发生协变,虽然编译期不会有问题,但并不能保证代码在运行时的安全性。你可能会说,我不在乎,只要能编译过能跑就行。 可是你要知道这是Java/kotlin啊,是讲究工程性的强类型的语言啊,编译期查错可一直是这些强类型语言优势之一啊,怎么能这么轻言放弃!沦落为二流的语言呢?! 这个时候我们就得通过付出一些代价,来获得强类型语言应该要有的,编译期就能保证的安全性。

接下来我们就来聊一下这些“代价”。

inout关键字

如果你仔细去看kotlin的源代码会发现,在List的定义中的泛型里,有一个out关键字:

interface List<out E> : Collection<E>

并且,如果你更仔细看,你会发现在kotlin的List接口中(注意是kotlin的,不是Java的List接口),是没有add方法的。 因为kotlin把传统意义上的列表拆成了只读的kotlin.collections.List和可读可写的kotlin.collections.MutableList两个接口, 所以在MutableList中会有add方法,而在List中则没有。

interface MutableList<E> : List<E>, MutableCollection<E>

所以,在我们上述的代码中:

// 往list里新加一个 broken process
fun appendNewBrokenProcess(processList: List<Process>) {
  processList.add(BrokenProcess()) // 这行会编译不过,但我们先假设这行能 work,稍后解释为什么。
}

虽然List<SpecialProcess>可以进行协变,但协变之后因为List接口没有add方法,你是无法更改List<SpecialProcess>中的内容的, 这样就能保证了代码在运行期的安全,因为编译不能过啊,运行都运行不起来。

你说我可以把这边的List<Process>改成MutableList<Process>啊,这样就能add了吧。 是的,你可以改,但是回顾MutableList的定义你会发现,他定义的泛型E是没有带out关键字的,所以,就算你有个MutableList<SpecialProcess> 也不能赋值给MutaleList<Process>的,因为它不能发生协变(没有out)。

List接口中定义的泛型上增加的out关键字,其实就指明该List可以发生协变,既:

就是如果StringObject的子类型,那么我可以在需要用List<Object>的地方用List<String>的情况,叫做协变(Covariance)。

其实在kotlin中,除了out,还有个in,它正好与out相反,可以令指定的对象发生逆变(Contravariance),我们后面来解释什么是逆变。 如果两个都不指定,则不能发生变化,称作不变(invariance)。

与Java的相同与不同

kotlin中的inout叫做定义处变化(declaration-site variance),Java没有相同的东西,但是Java有个类似的东西,如果你还记得:

// java
interface Collection<E> ... {
  void addAll(Collection<? extends E> items);
}

对,这边的extends其实就和out差不多意思,但Java叫做使用处变化(use-site variance),也就是说,发生协变或逆变得情况实在泛型的使用处声明的,而非定义处。 kotlin正好与之相反,并且注意,kotlin也是支持使用处变化(use-site variance)声明的,这个时候,在kotlin中称作类型投影(Type projections)。

简单来说,在kotlin中,Array<out Any>等同于Java的Array<? extends Object>,而Array<in String>则等同于Array<? super String>。 在这种情况下,Java叫做使用处变化(use-site variance),而在kotlin中则称作类型投影(Type projections)。

kotlin中有而Java中没有的情况叫做定义处变化(declaration-site variance),就是说泛型能否使拥有泛型参数的类发生协变,可以在泛型变量定义时指定。

了解了这些之后,我们回来说说前文所提到的代价。

大家都知道,如果在Java中你有个Collection

  Collection<? extends String> strCollection = ...

你是无法使用这个Collectionadd的方法写入这个集合的,但你却可以使用get方法从集合里读出String对象。 在kotlin里也一样,out的泛型是只能读不能写,而in与之相反。在kotlin中之所以用inout来定义,是因为inout指定了泛型变量能出现的位置。 in指的是方法的传入位置(in position),out则指的是传出位置(out position)。

  fun somefunction(param: String) : String
                   ----- in -----   - out -

逆变的情况正好相反,我就不赘述了。我们记住PECS原则就行了:

PECS stands for Producer-Extends, Consumer-Super.

对kotlin来说,就是:

Consumer in, Producer out

逆变

逆变(Contravariance)正好与协变相反,用我们前文的问题来解释,逆变就是:如果StringObject的子类型,那么我可以在需要用List<String>的地方用List<Object>的情况,就称作逆变。

逆变比较常见的一个情况就是比较器:

interface Comparator<in T> {
  fun compare(e1: T, e2: T): Int
}

考虑以下代码:

  val anyComparator = Comparator<Any> {
    e1, e2 -> e1.hashCode() - e2.hashCode()
  }
  val strings: List<String> = ...
  strings.sortedWith(anyComparator) // 发生了逆变
  // 👆sortedWith接受参数类型Comparator<String>,但是在Comparator定义的时候在泛型参数上使用了in
  // 所以Comparator<Any>发生了逆变:String是Any的子类型,在需要使用Comparator<String>的地方使用了Comparator<Any>

再来看一个官方的示例,也是比较,有可比较接口:

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!
    // 👆发生了逆变:Double是Number的子类型,但是在这里Comparable<Number>变成了Comparable<Double>的子类型,因此x可以赋给y。
}

后记

看完了kotlin的这泛型这一章,突然觉得inoutPECS都好熟悉,好像以前哪里看到过,但又想不起来了。 想起来也会,关于泛型变化的这些内容,都是比较通用的,理应不拘束与单一的语言,所以可能我以前学某一门其他的语言的时候看到过。 但并不记得了。

几乎有泛型的语言,都会有类似的协变(Covariance)、逆变(Contravariance)和不变(Invariance)的问题,最好的学习方法应该还是花时间去阅读该门语言的官方文档。

不奢望此文能为大家理解起到帮助的作用,只求不误导就好了。