Kotlin语法基础篇十二:泛型的协变、逆变、类型投影、星投影

前言

本来计划用一篇文章来将泛型中所有的知识都讲解完,写到中间发现还是分两篇来写比较好。在深入浅出泛型的文章中我们已经将泛型的基础知识详细的介绍完了,这篇文章就算是Kotin泛型的进阶篇吧~

坚持写博客真的需要一定的耐心,既然决定要把Kotlin的语法知识写成一个专栏,那我就要坚持把它写完,也算是巩固和学习了。这篇文章给大家介绍Kotlin中泛型的协变、逆变、类型投影、星投影,这部分的知识内容可能不太好理解。因为在实际开发的过程中,我们很少会涉及到自己去写这部分代码。很多时候都是我们在源码里看到,但是又不太理解其中的含义。笔者尽可能用一些简而易懂的语言去讲解这篇文章,希望大家都能牢牢的掌握这部分的知识,下面我们开始本篇文章的讲解。

1.协变和逆变的约定

在开始学习协变和逆变之前,我们有必要先来了解一个约定。当我们定义一个泛型类或者是泛型接口时。如果将泛型定义为一个方法的参数时,我们称该泛型在in位置。将泛型作为方法的返回值时,我们称该泛型在out位置。如下代码示例: 类似同样的约束在Java中是这么描述的:我们称只能从中读取 的对象为生产者 ,并称那些你只能写入 的对象为消费者。有了这个约定我们就可以继续下面的学习。

2.泛型的协变

在上一篇文章中我们介绍了泛型的类型擦除机制。例如集合List<String>List<Any>它们的类型参数只会在编译期间保留,在运行期间他们都会被识别成一个List。我们知道StringAny的子类但是List<String>并不是List<Any>的子类。为了让List<Sring>成为List<Any>的子类,我们需要使用特殊的关键字out来修饰泛型。例如我们所熟悉的List接口:

csharp 复制代码
public interface List<out E> : Collection<E> { }

正是因为泛型E在接口声明时使用了out关键字来修饰,我们才可以让下面这段示例代码顺利的通过编译器的验证:

kotlin 复制代码
fun main() {
    
    val listAny = mutableListOf<Any>()
    
    val listString = mutableListOf<String>()
    
    // List<String> 是 List<Any> 的子类
    listAny.addAll(listString)
}

但同时我们也向编译器保证了,我们只会将泛型E作为方法的返回值,该类型参数是只读的。如果我们强行将该泛型作为方法的参数,Kotlin编译器则会提示语法错误。例如我们将上面的示例BaseClass中的泛型T加上out关键字来修饰: 在一个泛型类或者接口中,如果AB的子类,同时Class<A>又是Class<B>的子类,我们就称类Class在该泛型上是协变的。 那么现在问题来了。泛型为什么需要协变呢?泛型协变的好处是什么呢?其实最主要的目的还是为了防止类型转换的隐患,考虑我们有如下场景的代码:

kotlin 复制代码
fun main() {
    val baseClass = BaseClass<Student>()
    val student = Student()
    baseClass.setData(student)
    
    // sync data
    syncData(baseClass)           
}

private fun syncData(baseClass: BaseClass<Person>) {
    val teacher = Teacher()
    baseClass.setData(teacher)
}

在上面这段代码中,syncData()方法接收了一个baseClass参数。假设泛型T在类BassClass上是协变的情况下,我们又允许该泛型出现在in位置,这样我们将BaseClass<Student>类型的参数,传递给syncData()方法就是合法的。而在syncData()方法内部我们将一个teacher对象传递给了setData()方法,StudentPerson类型,Teacher也是Person类型,这也是合法的,但是这种写法很明显会造成类型转换的异常。因为可能在你没有察觉的地方,有人这么去写了。所以如果我们声明了泛型是协变的,该泛型就应该是只读的,只能放在out位置。

但是在一些特殊的情况下,我们又可以将一个协变的类型参数放在in位置。比如我们很确定的是,我们只会用这个参数进行一些类型上的判断或者是属性的读取,而不会涉及到类型的转换,我们就可以这么使用。例如List接口中的contains()方法:

kotlin 复制代码
public interface List<out E> : Collection<E> {
    override val size: Int
    override fun isEmpty(): Boolean
    override fun contains(element: @UnsafeVariance E): Boolean
    override fun iterator(): Iterator<E>
    override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
}       

强行将一个声明为out的泛型放在in位置,需要使用@UnsafeVariance注解来标记。这代表你告诉编译器,你确定要这么使用,但是随着带来的类型转换的风险也需要你自己去承担。

3.泛型的逆变

泛型的逆变,如果单单是从定义上来看,感觉很难理解。那么什么是逆变呢?如果类A是类B的子类,而Class<B>又是Class<A>的子类,那么我们就称该类在该泛型上是逆变的。如下图所示:

要想声明某个类在某个泛型上是逆变的,我们使用关键字in。下面我们就来看一个具体的示例:

首先我们声明了一个泛型类型的接口Transform<T>,并在该泛型接口中声明了一个transform方法。该方法接收泛型作为参数,并返回一个了Int类型的值。我们知道StudentPerson的子类,但是Trasform<Person>并不是Transfom<Student>的子类。所以在上面的示例中我们将Tranform<Person>类型的参数传递给transformAction方法的时候,编译器提示了语法错误。而关键字in正是用来处理这种场景的。当我们给泛型接口Transfrom<T>的泛型T添加上关键字in的时候,编译器不再报错了。

同时我们也向编译器保证了,我们只会将泛型T放在in位置(方法的参数),而不会放在out位置(方法的返回值)。还是一样的思考,此时我们如果将泛型T放在out位置,又会带来怎样的安全隐患呢? 我们将上面的泛型接口Transfrom稍作更改,我们看一下如下场景的示例:

和协变一样,我们也可以使用注解将in关键字修饰的泛型放在out位置。而随之而来的类型转换风险也需要我们自己去承担。如我们在上图中用箭头标记的1 处,我们在匿名类内部返回了一个Teacher对象,这显然是合法的,因为Teacher类是Person类的子类。当我们使用注解躲过了编译器的报错提示后,我们在transfrom方法中却犯下了错误,如图中标记的3 处。我们将一个student对象传递给了transfromAction方法,可是在声明时我们明确定义了transform方法返回一个Teacher类型的对象。显然运行这段代码,会报如下类型转换的异常:

arduino 复制代码
Exception in thread "main" java.lang.ClassCastException: class com.study.myapplication.bean.Teacher cannot be cast to class com.study.myapplication.bean.Student (com.study.myapplication.bean.Teacher and com.study.myapplication.bean.Student are in unnamed module of loader 'app')
at com.study.myapplication.bean.PersonKt.transformAction(Person.kt:31)
at com.study.myapplication.bean.PersonKt.main(Person.kt:26)
at com.study.myapplication.bean.PersonKt.main(Person.kt)

Teacher cannot be cast to class Student,我们无法将Teacher转化成Student。事实上这些风险在设计之初,设计这些语法规范的开发人员已经帮我们考虑了这种安全隐患,我们只需要严格遵守规则就好。in位置只能作为方法的参数来使用,out位置只能作为方法的返回值来使用。如果你强行使用注解来逃避编译器的检查,那随之而来的类型转换的风险就需要我们自己来承担。关于泛型的逆变,在Kotlin内置的API中就有一个很好的示例,如Comparable接口:

kotlin 复制代码
public interface Comparable<in T> {
    public operator fun compareTo(other: T): Int
}

open class Person(private val age:Int) : Comparable<Person> {
    override fun compareTo(other: Person): Int {
        return if (this.age == other.age) 0
        else if (this.age > other.age) 1
        else -1
    }
}

class Student(private val age:Int) : Person(age)

class Teacher(private val age:Int) : Person(age)

fun main() {
    val student = Person(20)
    compare(student)
}

fun compare(student: Comparable<Student>) {
    val jack = Student(18)
    val result = student.compareTo(jack)
    println("result = $result")
}

// 输出 
result = 1

如上示例代码:我们使用逆变让Comparable<Person>变成了Comparable<Student>的子类,在compare()方法中我们成功的比较了两个学生在年龄上的大小。

4.类型投影 -> 使用处形变

有时我们在声明一个泛型类的时候,该泛型类在该泛型上既不是协变的也不是逆变的。例如:Kotlin中的内置数组Array:

kotlin 复制代码
class Array<T>(val size: Int) {
    fun get(index: Int): T { ...... }
    fun set(index: Int, value: T) { ...... }
}

这造成了一些不灵活性。假设我们在实际开发中定义了一个copy()函数,我们想实现将一个数组中的值copy到另外一个数组中去:

这里我们遇到了和前面一样的问题,IntAny的子类,但是Array<Int>并不是Array<Any>的子类,编译器是不允许这么操作的。这里我们就可以引出类型投影 的概念了,也就是在使用处,我们可以使Array<Int>成为Array<Any>的子类型,对于这样的使用场景我们就称之为类型投影 。这样我们就可以顺利的通过编译器的检查,因为我们这么做是合法的。

kotlin 复制代码
fun main() {
    val arrayFrom = arrayOf(0, 1, 2)
    val arrayTo = Array<Any>(3) { }
    copy(arrayFrom, arrayTo)
}

fun copy(from: Array<out Any>, to: Array<Any>) {
    assert(from.size == to.size)
    for (i in from.indices) {
        to[i] = from[i]
    }
}

一旦我们使用out来投影一个类型。那么该类在使用处只可以调用其get()方法,而不能调用set()方法。因为在copy()函数中我们可以给from数组存放一个String,这随时可能造成类型转换异常。intAny的子类,String也是Any的子类,我们在from: Array<Any>数组中存放一个String这显然也是合法的。但是我们在外部已经明确指明了arrayFrom数组的类型参数是Int。当我们使用out来投影Array,那么Array的使用将是受限的,同时也帮我们规避了类型转换的风险。

同样的我们也可以使用in来投影一个类型,假设我们有一个fill()函数如下:

kotlin 复制代码
fun main() {
    val arrayFill = Array<Any>(3) { }
    fill(arrayFill, "str")
}

fun fill(dest: Array<in String>, value: String) {
    dest[0] = value
    val str = dest[0]
    println(str)
}

我们在使用处让Array<Any>成了Any<String>的子类,这样我们在main()函数中才可以成功的将Array<Any>传递给fill()函数。在fill()函数中,我们只能设置一个String类型给到dest数组,我们取出来的自然也是个String,而这里的限制就是虽然我们将Array<Any>传递给了fill()数组,但是在使用处,我们只能存放String类型的数据。

5.星投影 -> 子类型的投影

掌握了协变和逆变以及类型投影的知识,我们再来看星投影就比较好理解了。关于星投影我们也可以称之为子类型的投影。

kotlin 复制代码
interface Factory<out T: Person> {
    fun create() : T
}

Factory接口中,T是一个拥有上界Person的协变类型参数,这意味着当T未知时,我们可以安全的从中读取Person的值。

kotlin 复制代码
interface Factory<in T: Person> {
    fun setInfo(info: T)
}

同样的,在此种情形下,T是一个拥有上届Person的逆变类型参数,这意味着当T未知时,没有什么可以以安全的方式写入Factory<in T: Person>。关于星投影这里笔者不打算扩展来讲解了,只要我们能熟练的掌握协变和逆变以及类型投影的知识,其在用法上的规则都是一致的。

总结

到这里总算是将泛型的知识完整的介绍完了。泛型所涉及的知识相对来说还是比较难理解的,但是再难的知识也是从简单开始的,只要我们熟练的掌握了泛型的基础知识,这些晦涩难懂的知识也会迎刃而解。好了,关于泛型的知识,我们就讲解到这里。下篇文章笔者打算介绍Kotlin语法知识中的by关键字,我们下期再见~

相关推荐
沐怡旸3 分钟前
【Android】Dalvik 对比 ART
android·面试
旺仔小拳头..4 分钟前
HTML的布局—— DIV 与 SPAN
前端·html
T___T4 分钟前
从原生 CSS 到 Stylus:用弹性布局实现交互式图片面板
前端·css
Zyx20075 分钟前
Stylus 进阶:从“能用”到“精通”,打造企业级 CSS 架构(下篇)
前端·css
黄毛火烧雪下6 分钟前
Angular 入门项目
前端·angular
用户4099322502127 分钟前
快速入门Vue3,插值、动态绑定和避坑技巧你都搞懂了吗?
前端·ai编程·trae
CondorHero8 分钟前
Environment 源码解读
前端
残冬醉离殇10 分钟前
别再傻傻分不清!从axios、ElementPlus深入理解SDK与API的区别
前端
CodeSheep19 分钟前
稚晖君官宣,全球首个0代码机器人创作平台来了!
前端·后端·程序员
向上的车轮23 分钟前
Actix Web 入门与实战
前端·rust·actix web