逆变本质,你爸爸终究还是你爸爸——关于Kotlin泛型型变的一些思考

607 阅读9分钟

  先说结论,泛型协变的父子关系是 is,是身份上的父子关系。而泛型逆变后的父子关系是 like,是行为上像的关系。

一般解释

  说到泛型协变,最常用的解释就是,类型A是类型B的子类型,那么Generic<A>也是Generic<B>的子类型。 逆变正好相反,Generic<B>是Generic<A>的子类型。

  前面一种情况好理解,爸爸和儿子都穿上马甲,套了马甲的爸爸仍旧是(套了马甲的儿子的)爸爸,符合直觉。但是逆变中父子关系逆转,这又该怎么理解呢?我看了许多文章,这个时候就开始给我扯什么协变中out修饰的是返回类型啊,是生产者,逆变中in修饰的是消费者,只能出现在参数位置之类的。

  OK,道理我懂,可是最让我纠结的为啥儿子套了马甲就变成爸爸了,这个问题却没人正面回答我。

从家庭伦理关系开始推导

  这里我们引用《Kotlin核心编程》中的一段代码,好好捋一捋父子关系。

  问题的背景是需要给列表中的元素排序,就要用到Comparator<T>接口,mutableList里面可能是double,也可能是Int,难道写完Comparator<Double>还要写Comparator<Int>吗,明明里面算法都一样。这时它们都是Number的子类,可不可以只写Comparator<Number>,这完全OJBK。 现在的关系是爸爸Number有大儿子Double,小儿子Int。

val numberComparator = Comparator<Number>{
	n1, n2 -> n1.toDouble().compareTo(n2.toDouble())
}

val doubleList = mutableListOf(2.0, 3.0)
doubleList.sortWith(numberComparator)

val intList = mutableListOf(2, 5)
intList.sortWith(numberComparator)

这么写完全行得通。看看代码,doubleList直觉上应该接受Comparator<Double>及其子类,现在Comparator<Number>被接受了,说明Comparator<Number>就是Comparator<Double>的儿子!

等等,好像有哪里不对,总觉得哪里怪怪的。

觉得奇怪很正常,因为我们谈到父子关系,总是默认以为是身份上的父子关系。但是逆变中的父子关系并不是这样,逆变中的父子指的是两个类的行为看上去像父子,但在实际身份上并不是。

那么什么时候我们认为行为上像父子呢,父类具有包容性(毕竟父爱无疆嘛),在参数位置我们声明为父类类型,具有更大的灵活性,因为父类可以承接它各有不同的子类,而子类已经学到了父类的全部本事(继承),所以完全可以承担父亲能承担的工作,一些情况下可以做的更好(类型判断,强制转换子类型),此时父亲负责揽活,儿子们负责干活。当发生岗位上的角色替换时,我们默认认为是儿子顶替了父亲。这种顶替行为看上去像一种父子间才会发生的事,所以叫行为上像父子。但实际身份上未必是父子,正如逆变。 因此,当发生这种替换行为时,我们称被替换的那个类为行为上的父亲,上岗的那个类是行为上的儿子。

就像这个例子,Comparator<Number>顶替了本该Comparator<Double>和Comparator<Int>上岗的岗位,就变成了行为上的儿子。

继续看代码, sortWith的定义,Comparator<Number>顶替上岗后真正干活的是马甲下面的Number吗?

public fun <T> MutableList<T>.sortWith(comparator: Comparator<in T>):Unit{
	if(size > 1) java.util.Collections.sort(this, comparator)
}

emmmm,其不用看这个代码,在上面的代码中就看到实际上是统统转为Double来比较的,这个例子不好,等下换个。
这里in关键字修饰的泛型参数,可以用在函数参数列表中,对应的变量在函数体中就可能发生类型强制转换这种修改,in表示允许修改。

再看一个更有说服力的例子,来自于bennyhuo的教程,谢谢垃圾分类

open class Waste //未分类垃圾,可以放入一般垃圾桶Dustbin<Waste>,身份上的父亲

class DryWaste : Waste()//干垃圾, 身份上的儿子

class Dustbin<in T : Waste> {
    fun put(t: T) {
        when(t){
          is Waste -> TODO()
          is DryWaste -> TODO()
          else -> TODO()
        }
    }
}

fun contravariant(){
    val dustbin: Dustbin<Waste> = Dustbin<Waste>()//一般垃圾桶
    val dryWasteDustbin: Dustbin<DryWaste> = dustbin//类型声明成干垃圾桶,一般垃圾桶来顶替,一般垃圾桶是行为上的儿子

    val waste = Waste()
    val dryWaste = DryWaste()

    dustbin.put(waste)
    dustbin.put(dryWaste)//一般垃圾桶可以放未分类垃圾,也可以放干垃圾,统统当作未分类垃圾处理,put函数此时接受Waste类型的参数,
    //DryWaste是Waste的子类,自然可以承接

//    dryWasteDustbin.put(waste) //这里会报错,此时put要求参数必须是DryWaste或其子类型
    dryWasteDustbin.put(dryWaste)
}

可以看到,在函数执行的最底层,还是身份上的父类型形参(Waste)承接身份上子类型实参(DryWaste),Dustbin在类的层面上顶替Dustbin,行为得像个儿子。运行到类里面的方法时,还是Waste形参揽活,DryWaste实参干活(dustbin.put(dryWaste)),一旦类型判断转换,DryWaste就能发挥它独特作用。

  可以这么讲,逆变场景,身份上的父亲穿上马甲(泛型类)行为上像儿子,进了屋里(运行到泛型类的函数里)脱了马甲,你爸爸还是还是你爸爸。最终还是爸爸占位揽活,他自己或他儿子干活。

dryWasteDustbin: Dustbin = dustbin能够赋值成功,还是因为in关键字,编译器检查发现T只出现在函数参数列表里,既然T可以是DryWaste,那么换成更加包容的Waste,再往put里传参数,条件更宽松了,当然是可以承接住的。

关于in,out更准确的解释,还是建议看官方文档或别的大佬的文章,毕竟我是个毕业没多久的菜鸟。

这篇文章,不是详解泛型型变的,只是记录解答下我学习时对所谓父子关系的疑惑,如有更准确的见解,还请不吝赐教。

题外话 如果实在清闲可以看看解闷(也许不能)

  这是我在掘金的第三篇文章。我之前是从来没有写博客的习惯的,源于我学生时代从来就没有记笔记的习惯,再加上平时成绩一直都还不错,就更觉得记笔记没必要。浪费大量精力,自己懂了就好,干嘛非要记下来。
  那么为什么我又开始试着写博客了呢,好吧,我承认是有功利性的目的。
  从2020年12月份从外包公司辞职,我的第一份工作只做了4个多月,现在脱产学习。
  实在不想混了,大学没能贯彻落实自己最初的目标,相当于混了4年。外包公司,技术气氛不太好,能教给我的东西我没啥兴趣。作为一个计算机专业的学生,平时考试还行,但实际动手太少,没啥项目经验。大学课业之外我花时间最多的,是自学3D建模,最后发现实在是个体力活,过程十分枯燥,只有在最后模型捏好后才有些许成就感,角色模型做不好看,没有美术功底,又想学素描,可这样距离我最初的目标更远了,我是想做动画来着。后来又想转型TA,计算机图形学里的各种数学我啃不下来。大学期间也曾想过做Android开放,可就是不喜欢Java,下不了决心。


  好不容易决定当程序员了,又对使用哪门语言十分纠结,学了半年py,性能太差不满意,复习C++,网上各种黑,我也觉得性价比不高,就抛了,Java则从来就没有考虑过。为了规避Java,自学了一段时间Flutter和Dart,不是想要的那种感觉。那段时间Go逐渐火了起来,如果不是我先接触了Rust,很可能我现在就是一名Go程序员了。一直迷茫着游荡着,直到我遇到了Rust,那是初恋和真爱的赶脚,不顾网上对它难度的渲染,不顾当时的时机,我义无反顾的啃了起来。那个大三暑假,开始Rust从入门到放弃。

  在那个周边同学都在为考研或者找工作复习奔波的时候,我像水面的落叶一般随性学习着对找工作没多大帮助的事。我也不知道自己以后要做什么工作,只是在跟着心里的声音走,那时我隐隐觉得,我想做一个互联网产品。于是我去学flutter,去看产品经理相关的入门书籍,看经济学心理学相关的书籍,好像完全没有个方向。
  就这样我混完了大学时光,在身边的同学纷纷拿到大厂offer时,我破罐破摔地随便找了家外包公司。因为直到那时,我依然不明确自己要做什么。20年的1月初,我暂停实习,开始自学Android,接着就是新冠肺炎。我从kotlin入手,后面又补习了Java的知识。毕业后在这家公司做车载Android。

  做出一个决定时,不可能所有条件都称心如意,kotlin让我找到了这之间的平衡,Kt许多炫酷的特性和语言的设计思想,总能让我联想到Rust,让我在做Android开发时不必一直忍受Java冗长的语法,甚至可以学下Vertx用kt做后台。迷茫的时间终于过去了,也许可以做一些事情。

  水的太多了,就此打住。感谢父母的支持,尤其是在不能理解和家境拮据情况下依旧支持我,感激无言。