Kotlin学习系列之:协变和逆变

x33g5p2x  于2022-03-08 转载在 其他  
字(4.2k)|赞(0)|评价(0)|浏览(365)

协变和逆变并不是Kotlin独有的概念,像Java、C#都有这样的概念,对于Java中协变和逆变的理解可参考笔者的另一篇文章。对于Java中协变和逆变的表示,可以通过这样一条PECS规则来概括:Producer Extends, Consumer Super.即仅仅是从该结构中读取数据,该结构就类似于一个生产者(Producer),此时使用"? extends Type";若仅仅是往该结构中放入数据,该结构就类似于一个消费者(Consumer),此时使用"? super Type"。此篇主要来介绍Kotlin中的协变(covariance)和逆变(contravariance)。

在此之前,我们先定义一些具有继承关系的类,旨在帮助我们解释清楚概念:

abstract class Fruit

open class Apple : Fruit()

class Pear : Fruit()

// 红富士苹果
class FujiApple : Apple()

协变(covariance)

我们先声明这样的一个泛型类:

class Producer<T>(private val t: T) {
    fun produce(): T {
        return t
    }
}

main方法中:

fun main() {
    // 苹果园
    val appleProducer: Producer<Apple> = Producer(Apple())
    // 梨园
    val pearProducer: Producer<Pear> = Producer(Pear())
}

上面的代码没有任何毛病。但是如果我们现在想要表示一个综合性果园,既苹果又生产梨,那么用现有的结构就无法表示了。这个时候我们就需要修改Producer类:

class Producer<out T>(private val t: T) {
    fun produce(): T {
        return t
    }
}
fun main() {
    // 苹果园
    val appleProducer: Producer<Apple> = Producer(Apple())
    // 梨园
    val pearProducer: Producer<Pear> = Producer(Pear())
    // 综合性果园
    val fruitProducer: Producer<Fruit> = Producer(Apple())
    val fruitProducer2: Producer<Fruit> = Producer(Pear())
}

可以看过,我们在Producer类中添加了out关键字后,下面的两行代码也就OK了。

如果我们在Producer类中添加这样的一个consume方法:

class Producer<out T>(private val t: T) {
    fun produce(): T {
        return t
    }

    fun consume(t: T){

    }
}

这个时候呢,编译就会报错:

从这里的提示我们可以这样去理解:所谓的’in’ position,就是方法的参数位置;所谓的’out’ position,就是方法的返回值位置。

那么也就是说,一旦用out关键字来使得类支持协变时,该泛型参数只能出现在方法的返回值上(构造器除外)。这实际上和前面提到的PECS是吻合的,只允许从Producer类中取出数据,而不允许向其中放入数据。

对比Java中协变的使用,Kotlin中这个方式叫做声明处协变(Declaration-site variance),Java中的方式叫做使用处协变(Use-site variance)。

总结一下:在Kotlin中使用out关键字来表示协变,并且是声明处协变;使用了协变之后,也就意味着该协变类型只能出现在方法的返回值处(构造器例外)。

逆变(contravariance)

理解了Kotlin中的协变之后,逆变的理解也就简单了,既然有out关键字,那自然有in关键字,Kotlin中用该关键字来表示逆变。

class Consumer<in T> {
    fun consume(t: T){
        
    }
}
val consumer: Consumer<Apple> = Consumer<Apple>()
val consumer2: Consumer<Apple> = Consumer<Fruit>()

相应地,如果添加了这样的produce()方法,编译器也会报类似的语法错误。那么逆变也可以总结一下:在Kotlin中使用in关键字来表示逆变,并且是声明处逆变;使用了逆变之后,也就意味着该逆变类型只能出现在方法的参数处(构造器例外)。

类型投影(Type Projections)

也叫做使用处变型(use-site variance)。为什么会有这种变型?我们一起来看一个类:Array。

public class Array<T> {
    public operator fun get(index: Int): T
    public operator fun set(index: Int, value: T): Unit
  	//.......
}

依据我们关于协变逆变的理论,泛型类型T既出现在out位置上,又出现在in位置,那么也就意味着我们既不能将Array声明为协变类型,也不能声明为逆变类型。我们再看这样一个copy方法:

fun copy(srcArr: Array<Any>, destArr: Array<Any>): Boolean {
    if (srcArr.size > destArr.size) return false

    for (index in srcArr.indices) {
        destArr[index] = srcArr[index]
    }
  
  	return true
}

这样就实现了数组的浅拷贝。现在我们想实现两个String数组的拷贝:

fun main() {
		val srcArr: Array<String> = arrayOf("hello", "world", "welcome")
    val destArr: Array<Any> = Array(3) { "" }
    copy(srcArr, destArr)
}

此时会报类型不匹配,因为Array<T>中的T是不变的。此时对copy方法参数添加一个out关键字:

fun copy(srcArr: Array<out Any>, destArr: Array<Any>): Boolean {
    if (srcArr.size > destArr.size) return false
  
    for (index in srcArr.indices) {
        destArr[index] = srcArr[index]
    }
  
  	return true
}

此时就正常了。那么为什么呢?假定将Array<String>视为Array<Any>的子类型,那么会出现什么问题呢?

srcArr[0] = 1

这行代码在编译期间没有问题,但是在运行期就会出现异常。在Java里这种异常叫做java.lang.ArrayStoreException。

那么实际上换句话说,如果对srcArr没有set操作(没有出现在in position),那么这种运行期异常也就不复存在了,也就没有风险了。所以,我们在形参处,加入out关键字,使得发生使用处协变。这样:

如果还尝试如此去使用,编译器是不会允许你成功的。

同样地,对于使用处逆变:

fun setValue(array: Array<in String>, index: Int, value: String) {
  	array[index] = value
    println(array[0])
}

只不过呢,它是允许出现在out位置,但是get出来的类型是Any?:

总结:对于类型投影,我们可以理解成,如果类声明是不变(意味着泛型类型既能出现在out位置,也能出现在in位置)的,那么我们可以在使用处将其声明成协变或者逆变,相当于把这个类型投影出某一面(in或out)进行使用

星投影(Star Projections)

语法形式:<>,例如MutableList<>,即将泛型类型声明成*

如何理解星投影呢?我们可以将MutableList<*>和MutableList<Any?>进行一个对比:

  • MutableList<Any?>:表示你可以往这个集合里面放入任意类型的元素
  • MutableList<*>: 表示这个集合中只会放入某个特定类型的元素,但是我们此时此刻并不清楚这个特定类型是什么,类似于Java中的?,如Class<?>

这两者从代码层面上还有个直观的区别:

你可以使用MutableList<Any?>()来创建一个对象,但是不能够使用MutableList<*>()来创建一个对象,后者只能出现在引用声明处。

那么星投影对于泛型类型支持协变、逆变以及不变三类分别有什么影响呢?在此之前,我们先来介绍一个类:Nothing。

  • 没有Nothing类的实例
  • 用来代表不存在的事物
星投影对协变类型的影响
val star: Producer<*> = Producer<Int>(1)
star.produce()

这样produce()方法的返回值类型是Any?

星投影对于逆变类型的影响
val star2: Consumer<*> = Consumer<Int>()
star2.consumer()

那么就不能consume任何数据了。

星投影对于不变类型的影响

get()出来的类型是Any?,不能set()。

似乎星投影丢掉了很多信息,那么它的应用场景在哪呢?

fun printFirst(list: List<*>){
    if (list.isNotEmpty()) {
        println(list.first())
    }
}

即你对类型参数并不care,像这里的printFirst()方法,我们仅仅想打印第一个元素,至于你到底是什么类型,是不关心的。

参考文献:《kotlin docs》、《kotlin in action》

相关文章