泛型,是对程序的一种抽象。通过泛型,我们可以实现代码逻辑复用的目的,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 这样的注解,来让编译器忽略这个型变冲突的问题。
为方便理解,拿泛型来模拟真实世界的场景,建立类比的关系。例如:
用万能遥控器,类比泛型;
用买遥控器的场景,类比逆变;
用点外卖的场景,类比协变、星投影。