稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

Kotlin Generics Revisited

在前面的文章中学习Kotlin泛型的基本知识,并且又用了一篇文章来复习了一下Java语言的泛型,有了这些基础我们就可以继续深入的学习Kotlin的泛型了。看它是如何解决Java泛型的遗留问题,再学习一下它的高级特性,最后再总结泛型的最佳实践。

本文是作为前面文章的延续和深化,为了更好的阅读效果,建议先回顾一下Java泛型基础,和Kotlin泛型基础

泛型类型参数界限(Upper bounds)

我们在前面讲解Java泛型基础时提到了在声明泛型的时候是可以指定类型参数的界限的,比如用Caculator<T extends Number>可以指定在使用时可以传入的类型参数要是Number或者Number的子类。

在Kotlin中也是可以指定泛型类型参数的界限的,也是用继承符号:来表示,🌰如:

1
class Calculator<T : Number> { ... }

与Java一样,也可以指定多个界限,要使用where关键字

1
2
3
4
5
6
7
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的关键字更容易理解和记忆。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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只写一个就行了,🌰如:

1
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,🌰如:

1
2
3
4
5
6
7
8
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,🌰如:

1
2
3
4
5
6
7
8
9
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修饰的,如:

1
2
3
4
5
public interface Game<T> {
    public T save(T x) {}
    @NotNull
    public T load(@NotNull T x) {}
}

这时在Kotlin里面就要用到『绝不为空类型』& Any来声明泛型

1
2
3
4
5
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中,用下划线替代不使用的参数是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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)
}

参考资料

Comments