Kotlin在Java的基础上,同样对泛型语法进行了拓展,所以很多Kotlin开发者,看着源码中的一堆in、out和*,感觉非常不知所措。其实,只要了解了Java泛型,那么Kotlin泛型就迎刃而解了。
首先,我们来想想,我们为什么需要泛型。
泛型是面向对象编程的一个非常重要的方面,它的出现,是多态的核心实现,简单的说,就是可以在不同的对象类型之间,使用相同的代码逻辑,从而实现复用。
为了充分了解泛型,以及泛型的实例场景,我们下面来构建一个面向对象的例子。
abstract class Person(open val name: String) {
abstract fun talk(): String
}
class Parent(override val name: String) : Person(name) {
override fun talk(): String = name
}
class Son : Person("ryan") {
override fun talk(): String = "hahaha"
}
fun doTalk(family: MutableList<Person>) {
family.forEach { println(it.talk()) }
}
这里定义了一个Person类,作为基类,他的子类——Father、Son,就是具体的实例,新建一个方法doTalk,用来输出具体的实现。
fun main() {
val family = arrayListOf<Person>()
family.add(Parent("Father"))
family.add(Son())
doTalk(family)
}
这样,我们就可以构建一个family的List,指定List的类型为Person,这样Father、Son,这些子类就都可以加入到这个List。
上面就是一个比较简单的泛型的使用实例。
泛型不变性
Father和Son都可以作为子类,加入到Person的List中,这就是泛型,但是让我们再看下下面的代码。
val parents = mutableListOf<Parent>()
parents.add(Parent("Father"))
parents.add(Parent("Mother"))
doTalk(parents)
创建一个类型为Parent的List,再传入doTalk函数,这时候,编译器报错了。
为什么呢?从编译器来看,doTalk需要的是一个List类型的参数,但是传入的是List类型,确实类型不一致,但是,Parent是Person的子类,从语义上来说,doTalk函数也是可以接受Parent类型的List的。
这就是泛型的不变性。即使参数中的类型是父子关系,但是编译器依然不能识别,它只能识别具体的类型。
泛型的型变
正是由于存在泛型的不变性,所以我们在支持某些场景的泛型参数时,就需要通过「泛型的型变」来拓展「泛型的不变性」。
Kotlin,或者说Java的泛型,实际上是一种伪泛型,即泛型只在申明时检查泛型是否有效,在编译时,泛型类型会被擦除,这是因为Java的历史原因所导致的,由于它为了兼容没有泛型的老Java版本,从而做出的妥协。
❝
不管是如何型变,它们的作用都是扩大泛型参数的类型范围。
❞
协变
泛型的协变,是泛型型变的一种方式。
协变的使用很简单,我们给参数加上out前缀即可,代码如下。
fun doTalk(family: MutableList<out Person>) {
family.forEach { println(it.talk()) }
}
加上out关键字之后,参数类型就变成了「Person类及其子类」,也就是说,只要是Person的子类,都可以作为参数传进来。
那么这样处理之后,上面的方法就可以执行了。但是,协变之后的泛型,就变成可读而不可写类型了。
例如我们在协变泛型参数上进行写操作,代码如下。
fun doTalk(family: MutableList<out Person>) {
family.add(Son()) // Error
family.forEach { println(it.talk()) }
}
这样就会报错,因为被out修饰之后,参数失去了写属性,变为只读属性了,这就是协变的副作用。
那么原因是什么呢?
我们来思考下,为什么它是可读的,通过out修饰之后,我们能保证,加入List的数据都是Person的子类,所以,List读取出来的实例类型,不管是哪个子类,都可以转为Person,也就是基类,所以可以通过它来调用基类的函数。
如果把参数写成Java的方式,可能更好理解一些。
void doTalk(List<? extends Person> family) {}
可以发现,泛型的协变,实际上是控制了类型的上限,但返回的具体类型,是不确定的(?代表未知类型),这就是为什么在协变后的参数中,无法执行写指令的原因,因为参数的类型,可能是List,也可能是List,所以无法确定是哪一种类型,自然无法写入。
逆变
逆变是泛型型变的第二种方式,与协变类似,逆变也是将某一个泛型类型,拓展了其父类类型,例如下面这个方法。
fun work(worker: MutableList<Son>) {
worker.forEach { println(it.talk()) }
}
这个方法接收一个List类型的参数,那么假如我们要传递一个List类型的参数,就会报错,原因跟协变是一样的。
这个时候,就需要使用逆变关键字in,将参数类型拓展为「Son类及其父类」。
fun work(family: MutableList<in Son>) {
family.forEach { println(it.talk()) }
}
val family = arrayListOf<Person>()
family.add(Parent("Father"))
family.add(Son())
work(family)
这样参数就可以传进去了,但是,逆变的副作用,是会导致泛型参数失去读属性,而只能使用写属性。
fun work(family: MutableList<in Son>) {
family.add(Son())
family.forEach { println(it.talk()) } // Error
}
同样的,我们将它转化为Java中的代码,这样更好理解一些。
void work(List<? super Son> family) {}
泛型的逆变,实际上是控制了类型的下限,即Son及其父类。对List进行add操作时,新实例son一定符合条件,但是get时,只会获取到Any或者Object类型,所以,拿到Object类型后,你可以根据业务来进行强转。
星型投影
星型投影,其实就是Java中的「?」通配符,用于在泛型的使用中,去除泛型的依赖,这么说有点抽象,简单的说,就是当你不关心具体的泛型类型时,就可以使用「?」或者「*」来忽略泛型的约束。下面举个例子。
class Push<T> {
fun pushMsg(msg: String): T {}
}
fun <T> getPush(): Push<T> {}
这是泛型版本的方法,我们可以获取指定泛型的Push,同时,你也可以用out来做泛型协变,让它可以返回子类。
那么这个时候,如果我不关心泛型的类型呢?
fun getPush(): Push<*> {}
fun main() {
val push = getPush()
val pushMsg = push.pushMsg("xys")
}
通过「*」,我们就可以不用指定泛型的具体类型,因为我不关心泛型类型,不过要注意的是,星型投影之后返回的类型,就成了「Any?」或者「Object」,因为泛型类型已经没有了。
❝
但是我们依然可以使用协变来限制投影的上限,当我们加上上限后,就可以限制返回数据的上限类型了——out T : CommonPush
❞
实际使用
我们在设计泛型API时,通常会有两种使用方式,一种是将泛型作为参数,另一种是将泛型作为返回值,这两种模式,实际上就对应「生产者-消费者」模型。下面我们就借助这个模型,来完整的演示下。
❝
官方文档中的说法是——Consumer in, Producer out !
❞
生产者
首先,设计一个生产者。
class Producer<T> {
fun produceSth(): T {
// TODO
}
}
fun main() {
val producer: Producer<Person> = Producer()
val sth: Person = producer.produceSth()
}
这是我们泛型最基本的使用,创建Person类型的生产者,它生产出来的东西,全是Person类型。
下面,我们来对泛型协变,这样就可以创建Son类型的生产者。
fun main() {
val producer: Producer<out Person> = Producer<Son>()
val sth: Person = producer.produceSth()
}
但是协变之后,生产出来的类型,依然是Person类型。
那么在Kotlin中,可以将这种在使用时的协变,变为申明时的协变,代码如下。
class Producer<out T> {
fun produceSth(): T {
// TODO
}
}
fun main() {
val producer = Producer<Son>()
val sth: Person = producer.produceSth()
}
在申明时标记协变,这样后续在使用时,就不用再标记了,你可以创建子类的生产者,生产基类的对象。
在Kotlin中,集合类大量使用了协变,如下所示。
public interface List<out E> : Collection<E> {
// Query Operations
override val size: Int
override fun isEmpty(): Boolean
override fun contains(element: @UnsafeVariance E): Boolean
override fun iterator(): Iterator<E>
// Bulk Operations
override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
// Positional Access Operations
/**
* Returns the element at the specified index in the list.
*/
public operator fun get(index: Int): E
这里泛型就是作为返回值传入。
消费者
同样的,我们创建一个消费者。
class Consumer<T> {
fun consumeSth(t: T) {
// TODO
}
}
fun main() {
val consumer: Consumer<Son> = Consumer<Person>()
consumer.consumeSth(Son())
}
同理,创建一个Person类型的消费者,它只能消费Son类型的参数。
我们再给它增加逆变,让它可以接受Son的基类。
fun main() {
val consumer: Consumer<in Son> = Consumer<Person>()
consumer.consumeSth(Son())
}
但是逆变之后,同样只能接受Son类型的参数,但是可以创建Person类型的消费者。
类似的,逆变也可以在申明处标记。
fun main() {
val consumer = Consumer<Person>()
consumer.consumeSth(Son())
}
class Consumer<in T> {
fun consumeSth(t: T) {
// TODO
}
}
那么逆变,在实际代码中的例子,我们可以参考下Comparable接口的设计。
public interface Comparable<in T> {
public operator fun compareTo(other: T): Int
}
这里的泛型就是作为参数传递,所以使用了逆变。
泛型的实例化
由于Java会在编译期进行泛型擦除,所以我们无法对泛型来做类型判断,比如下面的代码。
fun <T> test(param: Int) {
if (param is T) {// Error
}
}
T是无法进行类型判断的,因为它已经被擦除了,这和在Java中使用instanceof判断是一样的,在Java中,我们通常会再传入一个Class类型的参数来处理这个问题。而在Kotlin中,有更简单的方法来处理,那就是通过inline配合reified关键字来处理。
inline fun <reified T> test(param: Int) {
if (param is T) {
}
}
这样T就可以当做正常的类型来处理了,不过这种实例化的方式是有限制的。
- 函数必须是内联函数,因为只有内联函数才会在编译时进行替换
- 加上reified关键字让编译器在该泛型使用时进行实例化
在实战中,我们就可以利用泛型来进一步简化代码,例如:
inline fun <reified T> startActivity(context: Context) {
context.startActivity(Intent(context, T::class.java))
}
向大家推荐下我的网站 https://xuyisheng.top/ 点击原文一键直达
专注 Android-Kotlin-Flutter 欢迎大家访问
本文原创公众号:群英传,
< END >
作者:徐宜生