Kotlin的协变和逆变

前言

 最近在图书馆翻书,偶然看到郭神最新版的第一行代码,感慨颇深,想当初基本就是跟着这本书入门的,一晃眼好几年过去了。就想着说翻翻看,不翻还好,一翻又跪倒在郭神脚下了。大牛之所以是大牛,他就是能用最浅显最朴素的语言,把难懂的知识讲解给你听,没有那么多花里胡哨和炫技,而你也一听就能懂。

 翻到了Koltin的协变和逆变的时候,想起当初在学习这里看官方文档还是一些文章的时候,确实云里雾里,似懂非懂。而且郭神也很明确提到,他一开始学这方面知识的时候,查到的资料大都晦涩难懂,导致对这两个知识点产生了些畏惧。好家伙,这说的不就是我吗,但最终郭神还是能啃下来,而我就不行了。所以,借着这个机会想着把Kotlin简单的梳理一遍。

 就先从协变和逆变开始入手了。其实你可能感觉,我们平时在用Kotlin的时候,似乎基本都用不上这两个东西啊。没错,确实我自己也基本没用到过...但是你会发现,Kotlin本身内置的API使用了很多协变和逆变的特性(后面说),所以如果想对Kotlin有更深刻的了解,我们还是得学的。

先导知识

 一个泛型类或者泛型接口中的方法,它的参数列表是接受数据的地方,我们称之为in;而它的返回值是输出数据的地方,我们称之为out。这个很好理解对吧,但是到后面你可能就会乱了,反正我们先记住,这是基础,很重要。还有就是其实协变和逆变就是用来辅助泛型的,所以我们都是和泛型类或者泛型接口打交道。

interface MyClass<T> {
    fun method(params: T): T
}

这边方法内的入参params(第一个T)就是in所在位置,第二个T则是out所在位置。

我们定义三个类:

open class People(val name: String, val age: Int)
class Teacher(name: String, age: Int): People(name, age)
class Student(name: String, age: Int): People(name, age)

父类People,子类Teacher和Student,两个子类平级。

协变

 那么,这里引出一个问题:如果一个方法接收People类型的参数,那么我们任意传入Teacher和Student的实例都没问题吧?因为两个都是People的子类,老师和学生都是人。

 升级一下问题:如果这个方法接收的是List<People>类型的参数,那么我们还能传入List<Teacher>或List<Student>类型的实例吗?很显然,你一传,编译器就给你一巴掌,跟你说List<Teacher>并不是List<People>的子类,存在类型转换的安全隐患。事实也是,你List<A>怎么会是List<B>的子类呢,它两都是List。

 举个实际的例子,我们有个Data泛型类,内部封装了一个泛型value字段:

class Data<T> {
    private var value: T? = null
    
    fun set(value: T) {
        this.value = value
    }

    fun get(): T? {
        return value
    }
}

 接着我们来尝试一下上面的情况,第一步先创建一个持有Student的Data类实例,第二步再创建一个接收Data<People>类型参数的方法,我们在方法内重新创建一个Teacher的实例,设置给Data。

fun main() {
        val student = Student("Tom", 18)
        //创建一个持有Student的Data类
        val studentData = Data<Student>()
        studentData.set(student)

        //将持有Student的Data类实例传给接收Data<People>类型参数的方法
        handlePeopleData(studentData)

        //最后我们再把数据取出来看看
        val curStudentData = studentData.get()
    }

fun handlePeopleData(data: Data<People>) {
        //这里我们创建一个Teacher的实例,把data持有的数据替换掉
        val teacherData = Teacher("Jason", 38)
        data.set(teacherData)
    }

 很显然,第一步肯定没问题,第二步的话因为Data要求的泛型是People类,而我们创建的Teacher是继承People类的,所以也没问题。但是如果我们要将第一步创建的studentData传入到第二步创建的方法里,编译器就会报错:

Type mismatch.
Required:Data<People>
Found:Data<Student>

 就是我们料想的那样。那如果说,我们假设这里编译能通过,正常把studentData传到方法里面来了。那方法跑完,我们再回头看main()方法的最后一行,我们再通过get方法把Data持有的实力取出来,发现此时Data<Student>中持有的是Teacher的实例,那么就必然会产生类型转换异常了。

 所以Java是不允许使用这种方式来传递参数的。可是有时候我实际就是会遇上这种情况呢,我就是希望不做多余的转换类型的步骤,能达到这样的传递效果呢。那么,协变就上场了。

 我们定义一个泛型类MyClass<T>,类A和类B,其中A是B的子类型,而同时MyClass<A>又是MyClass<B>的子类型,那我们就称MyClass在T这个泛型上是协变的。有点绕,可以反过来理解,我们可以使用协变来达到让MyClass<A>成为MyClass<B>的子类型的目的。

 那么具体是怎么做的呢?我们刚刚也分析了,出现类型转换异常的痛点在于我们在方法内重新设置了不同类型的实例。那如果让Data在泛型T上只读呢,是不是就没有这个安全隐患了?没错,我们修改一下Data类的代码:

class Data<out T>(val value: T?) {

    fun get(): T? {
        return value
    }
}

 我们直接通过构造函数来传递泛型T类型的数据,使其无法修改,同时在泛型T的声明前面加上out关键字。还记得out指代的是什么位置吗,我们前面说过了,out就是指方法返回值的位置。也就是说,当我们加上out来限定当前泛型T的时候,我们只能在out指代的位置上获取到当前泛型实例(而无法在in指代的位置传入泛型实例,为了避免混乱,可以先不管in)。

 你可能会说,构造函数里不就传入了泛型T吗?那肯定得做初始化的操作,你一开始创建实例不给它东西,它持有空气呢,并且val关键字也限定了是只读的。那么我们再修改一下之前的代码:

fun main() {
        val student = Student("Tom", 18)
        //创建一个持有Student的Data类
        val studentData = Data<Student>(student)

        //将持有Student的Data类实例传给接收Data<People>类型参数的方法
        handlePeopleData(studentData)

        //最后我们再把数据取出来看看
        val curStudentData = studentData.get()
}

private fun handlePeopleData(data: Data<People>) {
        //只做读取逻辑
        val get = data.get()
 }

 这个时候我们惊奇的发现,编译器不报错了。那也就是说我们已经让Data<Student>成功的成为 Data<People>的子类型,那自然就能传给handlePeopleData()方法了,是不是很神奇。而方法内虽然泛型声明的是People类型,实际取出来的是一个Student的实例,但由于People是Student的父类,所以向上转型是完全没问题的。这就是协变给我们带来的便利所在。

 当然了,我们不能有了新欢就忘了旧爱,其实Java也有给我们提供了解决方案,那就是上界通配符<? extends T>。

List<? extends People> peoples = new ArrayList<>();
List<Student> students = new ArrayList<>();
peoples = students;

 很明显,我们能看到持有泛型为Student类型的List,可以赋值给泛型上界为People类型的List实例。这也很好理解,我们通过上界通配符<? extends People>规定了只要是持有泛型为People子类类型的List,都算是List<? extends People>的子类型,和Kotlin的out异曲同工。

 那么我们刚刚提到的Kotlin使用到协变的内置API其实一个典型的例子就是List。

public interface List<out E> : Collection<E> {
    // Query Operations

    override val size: Int
    override fun isEmpty(): Boolean
    override fun contains(element: @UnsafeVariance E): Boolean
    override fun iterator(): Iterator<E>

    // Bulk Operations
    override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean

    // Positional Access Operations
    /**
     * Returns the element at the specified index in the list.
     */
    public operator fun get(index: Int): E
}

 以上是我截取的部分Kotlin的List源码,可以看到官方已经给我们提供好协变能力了,所以我们在用的时候并不需要像Java一样来自己设置通配符,直接用就行。

 但是这里有一个奇怪的点大家可能会问,你刚刚不是还说通过out关键字限制之后,泛型只能出现在返回值的out位置吗,为什么contains()方法里(也就是in位置)还是出现了泛型呢?

 这么写本身确实是不合法的,因为在in位置上出现了泛型E就意味着可能会出现类型转换的安全隐患,但是contains()方法的目标非常明确,只是为了判断当前集合中是否包含参数中传入的这个元素,并不会涉及到修改当前集合的内容,所以操作是安全的。为此官方给我们开了个绿色通道,我们可以通过@UnsafeVariance注解来允许泛型出现在in位置上,但使用的前提是你自己要清楚自己在干什么。

 协变差不多就是这样了,接下来看看逆变。

逆变

 其实我们听名字,就有点能听出来意思了,刚刚协变是定义一个泛型类MyClass<T>,类A和类B,其中A是B的子类型,而同时MyClass<A>又是MyClass<B>的子类型。那么逆变就是反过来,A是B的子类型,而MyClass<B>却是MyClass<A>的子类型。开始有点晕了吧?放心,我也晕,不晕才怪呢...

 我们先来定义一个接口,用来执行一些转换的操作:

interface Transformer<T> {
    fun transform(t: T): String
}

很简单,声明一个接收泛型T类型作为参数并返回String的方法。接下来我们来实现它:

fun main(args: Array<String>) {
    val transformer = object : Transformer<People> {
        override fun transform(t: People): String {
            return "${t.name} ${t.age}"
        }
    }
    handleTransformer(transformer)
}

fun handleTransformer(transformer: Transformer<Student>) {
    val student = Student("tom", 18)
    val result = transformer.transform(student)
}

 这里我们第一步实现了一个泛型为People的匿名类,通过transform()方法把People对象转化为String的返回结果。第二步再新建了一个handleTransformer()方法,它接收一个泛型为Student的Transformer对象,我们用它来将Student对象转换为字符串。这两个步骤各自都没问题,但是当我们把泛型为People的匿名类实例传到handleTransformer()当中的时候,就会报错,而原因还是类型问题。

 这段代码从安全的角度来分析是没有任何问题的,因为Student是People的子类,使用Transformer<People>的匿名类来实现(将实例传到handleTransformer()方法里来)将Student转换成一个字符也是绝对安全的,并不会存在类型转换的安全隐患。

 这个时候,逆变就排上用场了,它就是专门来处理这种情况的。我们修改一下Transformer接口的定义:

interface Transformer<in T> {
    fun transform(t: T): String
}

 很简单,我们在泛型T的声明前面加上in关键字来修饰就行了。这样也就意味着泛型T就只能出现在in位置上,而不能出现在out位置上了,同时表明Transformer类在泛型T上是逆变的。

 这时候你会惊奇的发现,编译确实通过了,因为此时Transformer<People>已经成为了Transformer<Student>的子类型了。这里你也可以直观的看出来,为什么它叫“逆”变了。那按照这样的话,声明完逆变之后,泛型T是不能出现在out位置的,我们来试试:

interface Transformer<in T> {
    fun transform(t: T): String
    fun reverse(name: String, age: Int): @UnsafeVariance T
}

 在接口中新增一个reverse方法,用它来创建泛型T对象。这个时候编译器就会报错:Type parameter T is declared as 'in' but occurs in 'out' position in type T,就是说泛型T声明为“in”(逆变),却出现在了“out”(协变)位置上,所以我们先加上@UnsafeVariance注解。

 接着一样来实现它,并传到handleTransformer()里:

val transformer = object : Transformer<People> {
        
        override fun transform(t: People): String {
            return "${t.name} ${t.age}"
        }

        override fun reverse(name: String, age: Int): People {
            return Teacher(name, age)
        }
}
handleTransformer(transformer)

 我们在reverse()方法中直接构建一个Teacher对象出来,并返回。然后修改一下handleTransformer()方法:

fun handleTransformer(transformer: Transformer<Student>) {
//    val student = Student("tom", 18)
//    val result = transformer.transform(student)

    //for test
    val student = transformer.reverse("tom", 18)
}

 跑一下,这时候我们会“如愿以偿”的发现报错了(狗头):

Exception in thread "main" java.lang.ClassCastException: class com.example.jetpack.Teacher cannot be cast to class com.example.jetpack.Student
at com.example.jetpack.CovariantTestKt.handleTransformer(CovariantTest.kt:38)
at com.example.jetpack.CovariantTestKt.main(CovariantTest.kt:29)

 也就说,我们在handleTransformer()方法内是期望reverse()出一个Student对象出来,而实际我们得到的却是Teacher对象,那当然就会造成类型转换异常了,因为Teacher并不是Student的子类。

 这也就证明了我们一开始的假设,Kotlin在提供型变(协变、逆变和不变)的时候,就已经把各种潜在的类型转换安全隐患都考虑到了,我们只要严格遵循语法规则,就不会出现这种类型转换的异常。虽然@UnsafeVariance注解可以打破这一语法规则,但同时也会带来对应的风险,所以我们在使用时要时刻清楚自己是在干什么。

 同样,Java通过下界通配符<? super T>来提供类似Kotlin逆变的能力。我们先来看一个有趣的情况:

List<? extends People> peoples = new ArrayList<>();
peoples.add(new Student("tom", 18));

 我们通过上界通配符限定了List的泛型,然后我们往List中加入一个新new的Student对象(Student继承自People),按最直接的思路来说,<? extends People>不就是表明我们要加到List的对象只要继承People就行嘛。但实际你会发现,编译器报错了:

Required type:  capture of ? extends People
Provided: Student

 当然了,给大家挖了个坑,这坑很容易让我们有思维定势,上述的代码来说,我们只要定义List持有的泛型是People类型(父类),也就是List(People),那new一个Student对象丢进去就是完全没问题的。再回到现在的问题,其实刚刚我们也提到了,我们通过上界通配符<? extends People>实现的是让List<Student>成为List<? extends People>的子类型,是整个List类层次上的限定。而如果直接往List添加Student对象的话,编译器只能知道类型是People的子类,并不能确定具体类型是什么,因此也无法验证类型的安全性。

 那如果我们就是想用这种方式处理怎么办?下界通配符就登场了:

List<? super People> list = new ArrayList<>();
list.add(new Student("tom", 18));

 很神奇,编译通过了。其实这里我们通过<? super People>限定了泛型为People及其父类,所以list可以接受所有People的子类添加到其中。当然还有和Kotlin类似情形的用法:

public static void main(String[] args) {
        People people = new People("Jack", 38);
        SimpleData<People> simpleData2 = new SimpleData<>(people);
        handleData2(simpleData2);
 }

public static void handleData2(SimpleData<? super Student> data) {
       //do something
}

 你可以尝试一下,这个时候传一个SimpleData<Student>实例进来,是编译不了的。好了,扯得有点远了...

 最后我们再来看下逆变在Kotlin内置API中的应用,比较典型的例子就是Comparable:

public interface Comparable<in T> {
    public operator fun compareTo(other: T): Int
}

 想象一下,我们使用Comparable<People>来实现两个People年纪的比较,那么理所当然,用这个逻辑去比较两个Student的年纪肯定也是没问题的(因为People是Student父类),所以让Comparable<People>成为Comparable<Student>的子类就合情合理了。

总结

 总结一下,其实不管是协变还是逆变,都是在类声明的时候限定泛型的,也就说out限定泛型的时候,是协变,泛型是只能出现在out位置上,(而不是out关键字要去修饰返回值的位置,这点要理清楚,因为我刚开始接触的时候就会以为out关键字要修饰返回值);同理当in限制泛型的时候,是逆变,泛型只能出现在in位置上。

 这里有个生产者和消费者的概念记法,生产者只生产数据来输出(将数据发送出去,output),而消费者只消费(接收传进来的数据,input)。

produce = output = out = 协变
consume = input = in = 逆变

 当然了,out到协变以及in到逆变还需要你自己去联系起来。
 有点汗流浃背了...但还是那句话,技术学习的道路没有捷径,重在积累。

参考:《第一行代码》第三版 郭霖

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,492评论 6 513
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,048评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,927评论 0 358
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,293评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,309评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,024评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,638评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,546评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,073评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,188评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,321评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,998评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,678评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,186评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,303评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,663评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,330评论 2 358

推荐阅读更多精彩内容