Kotlin泛型及型变

555 阅读10分钟

泛型,是对程序的一种抽象。通过泛型,我们可以实现代码逻辑复用的目的,Kotlin 标准库当中很多源代码也都是借助泛型来实现的。

从型变的位置来分类的话,分为使用处型变和声明处型变。

从型变的父子关系来分类的话,分为逆变和协变。逆变表示父子关系颠倒了,而协变表示父子关系和原来一致。

型变的口诀:泛型作为参数,用 in;泛型作为返回值,用 out。在特殊场景下,同时作为参数和返回值的泛型参数,我们可以用 @UnsafeVariance 来解决型变冲突。

星投影,就是当我们对泛型的具体类型不感兴趣的时候,直接传入一个“星号”作为泛型的实参。

掌握泛型基础

从生活中举例,例如电视遥控器,小米就有 1S、2S、3S、4S 电视遥控器:

// 小米1S电视机遥控
class TvMi1SController {
    fun turnOn() {}
    fun turnOff() {}
}

// 小米2S电视机遥控
class TvMi2SController {
    fun turnOn() {}
    fun turnOff() {}
}

// 小米3S电视机遥控
class TvMi3SController {
    fun turnOn() {}
    fun turnOff() {}
}

// 小米4S电视机遥控
class TvMi4SController {
    fun turnOn() {}
    fun turnOff() {}
}

...
省略几千种不同的遥控器

如果为每一个型号的电视机都创建一个对应的遥控器类,然后在里面重复编写“开机”“关机”的方法,工作量会很大,而且没有意义。这个时候,我们其实需要一个万能遥控器,而借助 Kotlin 的泛型就可以很容易地实现了。

//          T代表泛型的形参
//               ↓
class Controller<T> {
    fun turnOn(tv: T) {}
    fun turnOff(tv: T) {}
}

fun main() {
//                                泛型的实参
//                                   ↓
    val mi1Controller = Controller<XiaoMiTV1>()
    mi1Controller.turnOn()

//                                  泛型的实参
//                                     ↓
    val mi2Controller = Controller<XiaoMiTV2>()
    mi2Controller.turnOn()
}

这段代码定义了一个“万能遥控器类”Controller,它当中的字母 T 代表了,这个遥控器可以控制很多种型号的电视,至于我们到底想要控制哪种型号,在使用的时候,只需要把 T 替换成实际的电视机型号即可。在上面的 main 函数当中,我们是传入了“XiaoMi1S”“XiaoMi2S”这两个型号。可见,使用泛型的好处就在于可以复用程序代码的逻辑,借助这个特性,我们可以在程序的基础上再做一次抽象。这样,通过这个Controller,不管将来有多少型号的电视机,我们都可以用这一个类来搞定。

另外,在定义泛型的时候,还可以为它的泛型参数增加一些边界限制,比如说,强制要求传入的泛型参数,必须是 TV 或者是它的子类。这叫做泛型的上界。

//               差别在这里
//                   ↓
class Controller<T: TV> {
    fun turnOn(tv: T) {}
    fun turnOff(tv: T) {}
}

和 Kotlin 的继承语法一样,使用冒号来表示泛型的边界。注意,当定义了边界之后,如果我们传入 Controller 的类型不是 TV 的子类,那么编译器是会报错的。

还有一点也需要注意,由于函数是 Kotlin 当中的一等公民,所以也可以用两个简单的函数 turnOn() 和 turnOff(),来解决前面所说的“遥控器的问题”:

//     函数的泛型参数
//   ↓             ↓
fun <T> turnOn(tv: T){ ... }
fun <T> turnOff(tv: T){ ... }

fun turnOnAll(mi1: XiaoMiTV1, mi2: XiaoMiTV2) {
//      泛型实参自动推导
//          ↓
    turnOn(mi1)
    turnOn(mi2)
}

在 fun 关键字的后面加上用尖括号包起来的 T,就可以为函数增加泛型支持。

型变(Variance)

型变为了解决泛型的不变性问题。从父子类关系来看,有逆变与协变。

逆变(Contravariant)

继续举例遥控器:

open class TV {
    open fun turnOn() {}
}

class XiaoMiTV1: TV() {
    override fun turnOn() {}
}

class Controller<T> {
    fun turnOn(tv: T)
}
fun foo(tv: TV) {}

fun main() {
// 要求父类,可以传入子类
    foo(XiaoMiTV1())
}

在这里有一个电视机的父类,叫做 TV,另外还有一个子类,叫做 XiaoMiTV1。它们两者是继承关系。由于它们是父子的关系,当函数的参数需要 TV 这个父类的时候,我们是可以传入子类作为参数的。

现在问题来了,Controller XiaoMiTV1 和 Controller TV 之间是什么关系呢?让我们来设想一个买遥控器的场景:

//                      需要一个小米电视1的遥控器
//                                ↓
fun buy(controller: Controller<XiaoMiTV1>) {
    val xiaoMiTV1 = XiaoMiTV1()
    // 打开小米电视1
    controller.turnOn(xiaoMiTV1)
}

假如传入的泛型实参是 TV

fun main() {
//                             实参
//                              ↓
    val controller = Controller<TV>()
    // 传入万能遥控器,报错
    buy(controller)
}

Kotlin 编译器会报错,报错的内容是说“类型不匹配”,需要的是小米遥控器Controller XiaoMiTV1,你却买了个万能遥控器Controller TV。为了让代码通过编译,我们需要主动告诉编译器一些额外的信息,具体的做法有两种。

第一种做法,是修改泛型参数的使用处代码,它叫做使用处型变。具体做法就是修改 buy 函数的声明,在 XiaoMiTV1 的前面增加一个 in 关键字:

//                         变化在这里
//                             ↓
fun buy(controller: Controller<in XiaoMiTV1>) {
    val xiaoMiTV1 = XiaoMiTV1()
    // 打开小米电视1
    controller.turnOn(xiaoMiTV1)
}

第二种做法,是修改 Controller 的源代码,这叫声明处型变。具体做法就是,在泛型形参 T 的前面增加一个关键字 in:

//            变化在这里
//               ↓
class Controller<in T> {
    fun turnOn(tv: T)
}

使用以上任意一种方式修改后,代码就能够通过 Kotlin 编译了。这样修改之后,我们就可以使用Controller TV来替代Controller XiaoMiTV1,也就是说,Controller TV 是Controller XiaoMiTV1的子类。

在这个场景下,遥控器与电视机之间的父子关系颠倒了。“小米电视”是“电视”的子类,但是,“万能遥控”成了“小米遥控”的子类。这种父子关系颠倒的现象,我们就叫做“泛型的逆变”。上面这两种修改方式,就分别叫做使用处逆变和声明处逆变。

协变(Covariant)

举例,模拟生活中点外卖场景。普通的食物、肯德基的食物,它们两者之间是父子关系。

open class Food {}

class KFC: Food() {}

饭店的角色:

class Restaurant<T> {
    fun orderFood(): T { /*..*/ }
}

在上面的 Restaurant 泛型参数处,我们传入不同的食物类型,就代表了不同类型的饭店。接下来,就是我们的点外卖方法了:

//                      这里需要一家普通的饭店,随便什么饭店都行
//                                     ↓
fun orderFood(restaurant: Restaurant<Food>) {
    // 从这家饭店,点一份外卖
    val food = restaurant.orderFood()
}

fun main() {
//                  找到一家肯德基
//                        ↓
    val kfc = Restaurant<KFC>()
// 需要普通饭店,传入了肯德基,编译器报错
    orderFood(kfc)
}

编译器提示最后一行代码报错,报错的原因同样是:“类型不匹配”,我们需要的是一家随便类型的饭店Restaurant,而传入的是肯德基Restaurant,不匹配。

我们必须额外提供一些信息给编译器,让它知道我们是在特殊场景使用泛型。具体的做法还是有两种。

第一种做法,还是修改泛型参数的使用处,也就是使用处型变。具体的做法就是修改 orderFood() 函数的声明,在 Food 的前面增加一个 out 关键字:

//                                变化在这里
//                                    ↓
fun orderFood(restaurant: Restaurant<out Food>) {
    // 从这家饭店,点一份外卖
    val food = restaurant.orderFood()
}

第二种做法,是修改 Restaurant 的源代码,也就是声明处型变。具体做法就是,在它泛型形参 T 的前面增加一个关键字 out:

//            变化在这里
//                ↓
class Restaurant<out T> {
    fun orderFood(): T { /*..*/ }
}

在做完以上任意一种修改以后,代码就可以通过编译了。这也就意味着,在这种情况下,我们可以使用Restaurant KFC替代Restaurant Food,也就意味着Restaurant< KFC可以看作是Restaurant< Food的子类。

食物与饭店它们之间的父子关系一致了。这种现象,我们称之为“泛型的协变”。上面两种修改的方式,就分别叫做使用处协变和声明处协变。

星投影(Star-Projections)

所谓的星投影,其实就是用“星号”作为泛型的实参。

当我们不关心实参到底是什么的时候,用星号作为泛型实参.

举例,开发一个“找饭店”的功能,借助泛型,可以写出这样的代码:

fun <T> findRestaurant(): Restaurant<T> {}

如果我们并不关心找到的饭店到底是什么类型,不管它是肯德基还是麦当劳的话,那么,我们就完全可以把“星号”作为泛型的实参,比如这样:

class Restaurant<out T> {
    fun orderFood(): T {}
}

//                        把星号作为泛型实参
//                               ↓
fun findRestaurant(): Restaurant<*> {}

fun main() {
    val restaurant = findRestaurant()
    // 注意这里
    val food: Any? = restaurant.orderFood() // 返回值可能是:任意类型
}

在上面的代码当中,我们没有传递任何具体的类型给 Restaurant,而是使用了“星号”作为 Restaurant 的泛型实参,因此,我们就无法知道饭店到底是什么类型。相应的,当我们调用 restaurant.orderFood() 的时候,就无法确定它返回的值到底是什么类型。这时候,变量 food 的实际类型可能是任意的,比如 String、Int、Food、KFC,甚至可能是 null,因此,在这里我们只能将其看作是“Any?”类型。

那么,对于上面的这种 food 可能是任意类型的情况,可以让 food 的类型更加精确一些。为 Restaurant 的泛型类型加上边界的话,food 的类型就可以更精确一些。

//                   区别在这里
//                       ↓
class Restaurant<out T: Food> {
    fun orderFood(): T {}
}

fun findRestaurant(): Restaurant<*> {}

fun main() {
    val restaurant = findRestaurant()
    //       注意这里
    //          ↓
    val food: Food = restaurant.orderFood() // 返回值是:Food或其子类
}

当为 Restaurant 泛型类型增加了上界 Food 以后,即使我们使用了“星投影”,也仍然可以通过调用 restaurant.orderFood(),来拿到 Food 类型的变量。在这里,food 的实际类型肯定是 Food 或者是 Food 的子类,因此我们可以将其看作是 Food 类型。

到底什么时候用逆变,什么时候用协变?

官方解释:Consumer in, Producer out !

大概意思就是:消费者 in,生产者 out。

举例:

//              逆变
//               ↓
class Controller<in T> {
//                 ①
//                 ↓
    fun turnOn(tv: T)
}

//               协变
//                ↓
class Restaurant<out T> {
//                   ②
//                   ↓
    fun orderFood(): T { /*..*/ }
}

注释①,泛型 T最终会以函数的参数的形式,被传入函数的里面,这往往是一种写入行为,这时候,我们使用关键字 in。

注释②的地方,泛型 T最终会以返回值的形式,被传出函数的外面,这往往是一种读取行为,这时候,我们使用关键字 out。

通俗解释逆变与协变的使用场景:传入 in,传出 out。或者也可以说:泛型作为参数的时候,用 in,泛型作为返回值的时候,用 out。

val或private var修饰的泛型也可以用协变out,因为这时候不可变,不产生消费,只提供生产。

特殊场景:函数传入参数的时候,并不一定就意味着写入,但是编译器不知道,所以需要通过注解@UnsafeVariance来告诉编译器放弃检查。举例:

//                   协变    
//                    ↓      
public interface List<out E> : Collection<E> {
//                                泛型作为返回值
//                                       ↓    
    public operator fun get(index: Int): E
//                                           泛型作为参数
//                                                 ↓    
    override fun contains(element: @UnsafeVariance E): Boolean
//                                        泛型作为参数
//                                              ↓   
    public fun indexOf(element: @UnsafeVariance E): Int
}

Kotlin 官方源码当中的 List,也就是这里的泛型 E,它既作为了返回值类型,又作为了参数类型。在正常情况下,如果我们用 out 修饰 E,那编译器是会报错的。但我们其实很清楚,对于 contains、indexOf 这样的方法,它们虽然以 E 作为参数类型,但本质上并没有产生写入的行为。所以,我们用 out 修饰 E 并不会带来实际的问题。所以这个时候,我们就可以通过 @UnsafeVariance 这样的注解,来让编译器忽略这个型变冲突的问题。

为方便理解,拿泛型来模拟真实世界的场景,建立类比的关系。例如:

用万能遥控器,类比泛型;

用买遥控器的场景,类比逆变;

用点外卖的场景,类比协变、星投影。