公众号「稀有猿诉」 原文链接 Kotlin Generics Revisited
在前面的文章中学习Kotlin泛型的基本知识,并且又用了一篇文章来复习了一下Java语言的泛型,有了这些基础我们就可以继续深入的学习Kotlin的泛型了。看它是如何解决Java泛型的遗留问题,再学习一下它的高级特性,最后再总结泛型的最佳实践。
本文是作为前面文章的延续和深化,为了更好的阅读效果,建议先回顾一下Java泛型基础,和Kotlin泛型基础。
泛型类型参数界限(Upper bounds)
我们在前面讲解Java泛型基础时提到了在声明泛型的时候是可以指定类型参数的界限的,比如用Caculator<T extends Number>可以指定在使用时可以传入的类型参数要是Number或者Number的子类。
在Kotlin中也是可以指定泛型类型参数的界限的,也是用继承符号:来表示,如:
Kotlin
class Calculator<T : Number> { ... }
与Java一样,也可以指定多个界限,要使用where关键字:
Kotlin
class Calculator<T> where T : Number, T : Runnable, T : Closable { ... }
fun <T> copyWhenGreater(list: List<T>, threshold: T): List<String>
where T : CharSequence,
T : Comparable<T> {
return list.filter { it > threshold }.map { it.toString() }
}
注意:面向对象的继承体系是基类在上面,子类在下面,所以上界的意思是以某个类A为根的继承树,这颗树都可以当成A来使用;下界的意思是从根A到以某个类C为止的一个路径,这个路径上都是C的基类,C都可以当成它们来用。
更优雅的泛型变化(Variance)
与Java一样,Kotlin的泛型也是不可变的Invariant,比如虽然String是Any的子类,但List<String>并不是List<Any>的子类。泛型变化Variance的目的就是让两个泛型产生与类型参数协同的变化,比如类型C是类A的子类,那么使用它的泛型<C>也应该是<A>的子类,能使用<A>的方,传入<C>一定要是允许的,并要能够是安全的。
使用点变化(Use-site variance)
基于面向对象的基本特性,只有向上转型(Upcasting)是安全的。具体就分为两种场景,从一个生产者中读取对象时,只要生产者的输出声明的T是基类(T是一个上限),无论生产者输出的是T还是它的子类,对于使用者来说(当T来用)就是安全的。这时生产者的泛型要能够进行协变,在Java中用上界界限通配符<? extends T>来进行协变,具体使用时传入T的子类的泛型也是合法的;同理,向一个消费者中写数据时,消费者声明为T的某个基类(这时T是一个下限),向其传入T,对于使用者来说就是安全的。这时消费者的泛型要能进行逆变,在Java中使用下界界限通配符<? super T>来进行逆变,具体使用时传T的基类的泛型也是合法的。
Kotlin中提供了非常容易理解和使用的关键字out来进行协变(covariance)和in进行逆变(contravariance) ,可以实现Java中的界限通配符一样的功效。Java界限通配符的规则是PECS(Producer Extends Consumer Super),out正好可以更形象的描述一个生产者,而in可以更形象的描述一个消费者,所以Kotlin的关键字更容易理解和记忆。
Kotlin
open class Animal
class Dog : Animal()
class MyList<E> {
fun addAll(from: MyList<out E>) {}
fun getAll(to: MyList<in E>) {}
}
fun main() {
val animals = MyList<Animal>()
val dogs = MyList<Dog>()
animals.addAll(dogs)
dogs.getAll(animals)
}
这种泛型变化是发生在调用者调用时,因此也叫做『使用点变化』(Use-site variance)。在Kotlin中也被称作类型映射,因为相当于是用<out T>把T给映射成了一个T的生产者,只能调用其get方法;用<in T>映射成一个T的消费者,只能调用set方法。并且呢,对于同一个函数中既有生产者和消费者时,in和out只写一个就行了,如:
Kotlin
fun copy(from: Array<out Any>, to: Array<Any>) { ... }
声明点变化(Declaration-site variance)
Java界限通配符的一个大问题是只能用于方法的参数但不能是返回值,也就是只能是『Use-site variance』。但in和out没有这个限制,因此它们可以用于返回值。只要给类和接口的泛型声明为out或者in就能让类型参数在其所有的方法产生variance,这就是『declaration-site variance』。
但是要遵守out进行协变,也就是说out是用于生产者的,只能作为方法的返回值,或者保证不能set,如:
Kotlin
interface Source<out T> {
fun nextT(): T
}
fun demo(strs: Source<String>) {
val objects: Source<Any> = strs // This is OK, since T is an out-parameter
// ...
}
同理,用in进行逆变,只能用于消费者,只能作为方法的参数,或者保证不get,如:
kotlin
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, you can assign x to a variable of type Comparable<Double>
val y: Comparable<Double> = x // OK!
}
小结一下,Kotlin使用关键字in和out让泛型的协变和逆变变得容易理解得多了,因为它们能够非常清楚的表达出消费者和生产者,只需要记住一个泛型的生产者要用out来修饰,而一个泛型的消费者要用in来修饰就不会出错,这比Java中的界限通配符简单太多了。
星号映射(Star projections)
除了use-site variance是一种类型映射外,还有星号映射。首先来说星号是无界泛型,也就是说不指定具体的类型参数,意思是任意类型的泛型,换句话说Foo<*>是任何其他泛型的基类(Foo<String>, Foo<Number>等)。但根据不同的上下文,Foo<*>会映射为不同的具体意义的泛型类型:
- 对于Foo<out T : TUpper>,这里的T是一个受上界TUpper限制的协变类型参数,那么Foo<*>就等同于Foo<out TUpper>。
- 对于Foo<in T>,这里T是逆变类型参数,Foo<*>等同于Foo<in Nothing>。这意思是无法向Foo<*>中写。
- 对于Foot<T : TUpper>,这里T是一个被上界TUpper限定的不可变类型参数,那么Foo<*>,在读时(作为生产者)等同于Foo<out TUpper>,在写时(作为消费者)等同于Foo<in Nothing>。
如果泛型是多元的,那么每个类型参数可以进行不同的映射。比如说如果一个类型是这样声明的interface Function<in T, out U>,那么会有这样的映射:
- Function<*, String> 意思是Function<in Nothing, String>
- Function<Int, *> 意思是Function<Int, out Any?>
- Function<*, *> 意思是Function<in Nothing, out Any?>
换句话来理解,就是当不指定具体的类型参数,用星星就代表着不知道具体的类型参数,那么视具体的上下文不同星号会被解释不同的意思。不过这玩意儿可读性较差,除非必不得已,否则还是能不用就用它。
注意:在Kotlin中,根基类是Any它是所有其他类的基类(the root of Kotlin class hierarchy)。而Nothing是不能有实例的类,可以用它来表示不存在的对象(a value that never exists)。比如说,如果 一个函数返回值类型声明为Nothing,那它就不会返回(always throws an exception),注意是不会返回(never returns) ,并不是没有返回值,没有返回值要声明为类型Unit。
绝不为空类型(Definitely non-null type)
为了保持对Java的互通性,Kotlin还支持把泛型类型参数声明为『绝不为空类型』definitely non-null type。可以用& Any来声明,如<T & Any>来声明T是『绝不为空类型』。
这是为了保持与Java的相互调用,有些Java的类和接口是用注解@NonNull修饰的,如:
Java
public interface Game<T> {
public T save(T x) {}
@NotNull
public T load(@NotNull T x) {}
}
这时在Kotlin里面就要用到**『绝不为空类型』& Any来声明泛型**:
Kotlin
interface ArcadeGame<T1> : Game<T1> {
override fun save(x: T1): T1
// T1 is definitely non-nullable
override fun load(x: T1 & Any): T1 & Any
}
注意,在纯Kotlin代码中是用不到这个特性的。只有当涉及Java的@ NonNull时才需要『绝不为空类型』。
下划线操作符
当编译器能推断出泛型的类型参数时是可以省略掉类型参数的,比如val names = listOf("James", "Kevin"),这里得到的类型是List<String>,但我们并没有显示的指定类型参数,这是因为编译器从listOf的参数中就能推断出类型参数是String,所以listOf的返回就是List<String>。
但有些时候,泛型类型太复杂了,没有办法推断出所有的类型,比如有多元泛型参数时。但根据指定的某一个参数,可以推断出剩余的参数时,这时就没有办法完全省略类型参数,剩余的参数却又可以推断出来,写了又浪费。这时就可以用下划线操作符来代表那些可以推断出来的参数。这里的下划线用法跟在lambda中,用下划线替代不使用的参数是一样的。
Kotlin
abstract class SomeClass<T> {
abstract fun execute() : T
}
class SomeImplementation : SomeClass<String>() {
override fun execute(): String = "Test"
}
class OtherImplementation : SomeClass<Int>() {
override fun execute(): Int = 42
}
object Runner {
inline fun <reified S: SomeClass<T>, T> run() : T {
return S::class.java.getDeclaredConstructor().newInstance().execute()
}
}
fun main() {
// T is inferred as String because SomeImplementation derives from SomeClass<String>
val s = Runner.run<SomeImplementation, _>()
assert(s == "Test")
// T is inferred as Int because OtherImplementation derives from SomeClass<Int>
val n = Runner.run<OtherImplementation, _>()
assert(n == 42)
}
参考资料
- Generics: in, out, where
- Kotlin Generics
- Understanding Kotlin generics
- Kotlin generics explained with code examples
- 深入解析Kotlin 泛型
- Kotlin(六)深入理解Kotlin泛型
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
原创不易,「打赏」 ,「点赞」 ,「在看」 ,「收藏」 ,「分享」 总要有一个吧!