再次深入解析Kotlin泛型

公众号「稀有猿诉」 原文链接 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)
}

参考资料

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

原创不易,「打赏」「点赞」「在看」「收藏」「分享」 总要有一个吧!

相关推荐
无限大.10 分钟前
c语言200例 067
java·c语言·开发语言
余炜yw12 分钟前
【Java序列化器】Java 中常用序列化器的探索与实践
java·开发语言
攸攸太上12 分钟前
JMeter学习
java·后端·学习·jmeter·微服务
Kenny.志15 分钟前
2、Spring Boot 3.x 集成 Feign
java·spring boot·后端
不修×蝙蝠17 分钟前
八大排序--01冒泡排序
java
sky丶Mamba32 分钟前
Spring Boot中获取application.yml中属性的几种方式
java·spring boot·后端
数据龙傲天1 小时前
1688商品API接口:电商数据自动化的新引擎
java·大数据·sql·mysql
带带老表学爬虫1 小时前
java数据类型转换和注释
java·开发语言
model20052 小时前
android + tflite 分类APP开发-2
android·分类·tflite
千里码aicood2 小时前
【2025】springboot教学评价管理系统(源码+文档+调试+答疑)
java·spring boot·后端·教学管理系统