Kotlin 泛型之 in,out,where

在更深入的了解之前,让我们先从一些例子看起:
让我们先写一个简单的泛型类:

class Box<T>(t: T) {
   private var value = t
}

open class Animal

//继承Animal的Dog
open class Dog : Animal()

fun test(){
    var animal = Animal()
    var boxAnimal = Box<Animal>(animal)
    var dog = Dog()
    var boxDog = Box<Dog>(dog)
    
}

代码很简单,代码逻辑没有问题,编译也能通过。但是如果把boxDog赋值给 boxAnimal 呢?是不是也能通过呢?见下图:

image.png

编译不通过,报错Type mismatch Required: Box<Animal> Found: Box<Dog>,也就是说Box<Dog>并不是Box<Animal>子类。逻辑上感觉有点不通。再来看一个例子:

    var listOfAnimal = listOf<Animal>()
    var listOfDog = listOf<Dog>()
    listOfAnimal = listOfDog

这里是可以通过编译的,没有报错,这似乎是符合逻辑的。那么再来一个例子:

    var mutableListOfAnimal = mutableListOf<Animal>() //注意这里是mutableListOf区别于上面的listOf
    var mutableListOfDog = mutableListOf<Dog>()
    mutableListOfAnimal = mutableListOfDog

这里又报错了,具体看下面截图:


image.png

怎么一会儿感觉符合逻辑,一会儿编译报错!


image.png

在具体解释一会儿编译过一会儿编译不过之前,先来简单介绍下不变、协变和逆变的概念。

  • 不变(invariant) --- 例如上面的mutableListOf<Animal>()对象不可以被mutableListOf<Dog>对象赋值,亦即mutableListOf<Dog>不是mutableListOf<Animal>的子类
  • 协变(covariant) --- 比如上面的List<Animal>对象可以被List<Dog>对象赋值,即List<Dog>是List<Animal>的子类
  • 逆变(contravariance) --- Contravariance describes a relationship between two sets of types where they subtype in opposite directions. 大意是和通常理解的类从属关系是相反的,这个不太好理解,先简单记一下和协变相反即可。

终于要说到in、out了,在即将介绍之前,先把前面的Box<T>类做一点小小的修改:

class Box<T>(t: T) {
    private var value = t

    fun getItem(): T = value

    fun setItem(t: T) {
        value = t
    }
}
  • out --- 表示声明的类中只能有返回该类型的方法,不能有接受该类型的方法。以上面的Box为例,如果改成 Box<out T>会有哪些影响呢?

    1. 编译不通过,报错提示本该是in类型出现的地方,却被声明成了out。结合上面的out解释,可以理解。
      image.png

      2.之前Box<Dog>赋值给Box<Animal>编译报错没有了。这就是说out产生了协变效果,Box<Dog>成为了Box<Animal>子类,让本来不变 的类型产生了协变(符合逻辑了)。
    2. 如果这时删掉了value的private修饰,也会报错,报错的原因同1中相同,也就是value会被外部修改。
  • in --- 表示声明的类中只能有接受该类型的方法,不能有返回该类型的方法。
    同样以上面的Box为例,如果改成 Box<in T>会有哪些影响呢?

    1. 编译不通过,提示本该是out类型的地方,却被声明成了in。结合上面in的解释也可以理解。


      image.png
    2. 之前Box<Dog>赋值给Box<Animal>编译报错又出现了。之前out的协变 没有了。
    3. 如果这时删掉value的private修饰,也会报错,报错提示value是in类型,但是却出现在了不变 的位置。(这里可以理解成value会被外部访问到,换言之,只要被in、out任一修饰,该类型变量都不希望被外部直接访问到)。
    4. 如果把boxDog = boxAnimal会怎么样?注意!这里是把我们印象中的父类赋值给了子类!逻辑上类比dog = animal,但是编译却能通过。这就有点和印象不符了,不是说只有子类能赋值给父类,哪有父类能赋值给子类的。所以这里就需要思考一下,到底谁是谁的父类。其实这里确实是子类赋值给父类,也就是说boxDog是boxAnimal的父类。这就是前面讲的 逆变
image.png

有点懵,理一下思路,如果泛型什么都不加就是不变,如果加了out就是协变,如果加了in就是逆变。如果说加了out产生 协变 更符合逻辑直觉,那么加in产生 逆变 是为了什么?不是为了把人搞懵逼吧?

可以这样理解,如果boxAnimal = boxDog成立,即out的情况,那么只能输出不能出入泛型对象。也就是说变成boxAnimal后只能输出Animal,因为Dog本身就是Animal的子类,所以这样没有问题。如果boxDog = boxAnimal成立(其实这里boxDog = boxAny也成立,感兴趣的可以试一下),即in的情况,那么boxDog能接受Animal或者Any类。但是因为不会输出,所以不会有类型转换异常!但是为什么boxDog能接受Animal或者Any呢?因为泛型擦除机制,其实所有的泛型都会被擦除成Any,那么无论放什么进去,只要不取出来就不会有类型转换异常。个人认为理解这里的关键就是把boxAnimal = boxDog和boxDog = boxAnimal赋值后给boxAnimal 和 boxDog分别设置item或者取出item,如果设置或者取出item不会发生逻辑异常,就算是理解了in、out设计的用意了。

但是到这里对上面讲的List、mutableList能赋值和不能赋值也有了初步的理解了。说白了,就是List的代码泛型加了out,mutableList没有加。但是目前为止关于in、out的逻辑还是很不清晰。

那么Java是怎么处理泛型问题的?(协变和通配符)
首先Java中的泛型也是 不变 的,这意味着List<String>也不是List<Object>的子类。看一下下面的代码:

// Java
List<String> strs = new ArrayList<String>();

// Java 报错 type mismatch.
List<Object> objs = strs;

// 假如上面的代码不报错会怎样?
// 我们就能在一个List<String>中放一个Integer.
objs.add(1);

// 下面的代码就会在运行时抛出一个类型转化异常: Integer cannot be cast to String
String s = strs.get(0);

Java会在List<Object> objs = strs; 编译不通过,来阻止后续的类型转换异常。假如要自己实现 CollectionsaddAll 方法,直觉上会写成这样:

// Java
interface Collection<E> ... {
    void addAll(Collection<E> items);
}

但是,这样写就会导致下面的完全安全的代码无法编译通过:

// Java

// addAll方法会报错:
// Collection<String> is not a subtype of Collection<Object>
// 但是这段代码是完全安全的,即把Collection<String>赋值给Collection<Object> 
void copyAll(Collection<Object> to, Collection<String> from) {
    to.addAll(from);
}

真实的addAll 方法是怎么实现的呢?

// Java
interface Collection<E> ... {
    void addAll(Collection<? extends E> items);
}

这里的增加了通配符 ? extends E 来显示,该方法能够接受E和E的子类对象的集合,而不仅仅是E。那么从这个集合中可以被安全的读取的类型就是E,但是不能向该集合写入,因为对于未知类型的E,一个对象是无法确认是否是E的子类。作为这个限制的回报,就可以产生预期的行为: Collection<String> 是 Collection<? extends Object> 的子类。换言之,通配符通过产生一个扩展边界(上界)让类型产生了协变。

理解这为什么能work的关键是:如果你只能从一个集合中取出对象,那么从一个String的集合读取Object是安全的。相对应的,如果你只能将对象放入一个集合中,把String放入Object的集合也是ok的。Java中List<? super String>接受String或者他的超类。

后者List<? super String>就是 逆变 , 外部只能调用String作为入参的方法,(例如:可以调用addAll(String) 或者 set(int, String))。如果想要从List<T> 中调用return T的方法,那么不会得到String,只能得到Object(下界)。

通过使用边界通配符来增加API的扩展性。通常使用生产者来表示只能读取,使用消费者表示只能写入。为了最大程度的提高扩展性,使用通配符来表示生产者(Producer --- ? extends Object)和消费者(Consumer --- ? super String)。缩写:PECS(Producer-Extends,Consumer-Super)。
如果使用一个生产者模型List<? extends Foo>,那么不允许调用add()或者set()方法,但是这并不意味着这个集合中的内容是永远不变的,例如:可以调用clear() 来移除所有的内容,因为这个方法没有任何传参。通配符或者其他类型的协变的唯一关注点是类型安全,而不是内容是否是可变的。

根据上面讲的Java泛型的原则,写一个例子:

// Java
interface Source<T> {
    T nextT();
}

// Java
void demo(Source<String> strs) {
    Source<Object> objects = strs; // !!! Java里面是不行的!!!!!
    // ...
}

上面的代码逻辑上是没有问题的,但是Java是无法编译通过的。解决的方案就是:Source<? extends Object>。但是这么做看起来就没啥意义,因为你只能调用 T nextT(); 方法,根本不会添加其他类型。但是Java编译器不管这些,就是编译不通过。

但是在Kotlin里面,就可以通过一种方式告诉编译器我们的使用方式。就是是被称为声明时协变declaration-site variance):可以通过在泛型上增加注解的方式(上面这个例子中就是指out)来确保只返回T类型(即是生产者),不接受T类型(即不是消费者)。具体见下面代码:

interface Source<out T> {
    fun nextT(): T
}

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // OK, 因为T被标注了out
    // ...
}

通常的规则是:当泛型T在Class Box中被声明成了out,那么T就只能出现在方法返回类型里,不能出现在方法的入参里。并且 Box<Animal> 是 Box<Dog>的父类。(注意,这里渐渐开始和开头的相关概念产生了关联!

out修饰符被称为 协变 注解,并且因为出现在类型声明时,被称为声明时协变。与之相对应的是Java的使用时协变,在使用时通过通配符的方式产生协变。

除了out外,Kotlin还提供了与之相对应的in。它会让泛型产生逆变,这表明这个类只消费这个泛型,不产生泛型。一个很好的例子就是Comparable中的逆变:

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!
}

in、out从名字就能很好的理解他们的用途(C#已经使用很久了),类似上面的PECS,这里有个POCI(Producer-Out,Consumer-In)。

到了这里对in、out就有了大概的理解了。in、out对比java就是把使用时的通配符协变替换成了声明时的协变,方便使用。

类型投影(Type Projections)

使用时协变:类型投影

将泛型T声明成out可以很方便的解决使用时泛型子类的问题,但是就限制了Box类中只能返回T。下面来举一个Array的例子:

class Array<T>(val size: Int) {
    operator fun get(index: Int): T { ... }
    operator fun set(index: Int, value: T) { ... }
}

这个类现在是不变 的。那么根据前文内容,就会带来一些扩展性的问题,例如Array<Dog>不再是Array<Animal>子类。那么看一下接下来的方法:

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

这个方法将from的内容copy到to中,接下来调用这个方法:

val ints: Array<Int> = arrayOf(1, 2, 3)
val any = Array<Any>(3) { "" }
copy(ints, any)
//   ^ type is Array<Int> but Array<Any> was expected

这里又会遇到上文中提过的报错:type is Array<Int> but Array<Any> was expected。因为当前的泛型是 不变 的,所以 Array<Int> 和 Array<Any> 没有任何子类从属关系。为什么这样做不行? 因为可能对from做一些预期之外的行为,比如向 from中写入String。注意,因为copy方法的入参是from: Array<Any>,如果不采取任何编译限制,就可以向from: Array<Any>中写入String。后续的如果从ints中读取数据,可能会发生ClassCastException。
如果要禁止向from中写入数据,可以用下面的代码:

fun copy(from: Array<out Any>, to: Array<Any>) { ... }

这就是类型投影。通过添加out告诉编译器这不是一个随意的array,是一个有限制的array(有投射类型)。只能从这个from中读取T。这是Kotlin的使用时协变,对比起Java中的Array<? extends Object> 使用起来要更加简明。

也可以使用in来做类型投影:

fun fill(dest: Array<in String>, value: String) { ... }

Array<in String> 和Java中的Array<? super String>对应。这个方法只能传入字符数组或者Object(毕竟,将String放入一个Object数组当然是可以的)。


image.png

但是看一下下面这个情况:

class Box<out T>(private var item: T) {
    fun get(): T = item
    fun has(other: T) = item == other
}

因为这里被标记了out,所以 has(other: T) 方法是无法通过编译的。没有修改T的对象item,但是方法又必须传入other的T类型对象。这时可以改成 has(other: @UnsafeVariance T),告诉编译器这里明确是要传入T类型的对象,不要发出编译错误。事实上这也是Kotlin库中indexOf的实现方式。

*星投影(Star-projections)

如果不知道具体的类型,比如通过方法传递过来一个包含未知泛型的参数,但是仍想在使用过程中保证安全。这时候*星投影就可以保证实例化的对象就是传入泛型的投影。
这么讲概念非常不好理解,可以看下下面的例子:

class Box<out T>(t: T) {//注意这里的out
    private var value = t

    fun getItem(): T = value
}

    var animal: Animal = Animal()
    var boxAnimal = Box<Animal>(animal)
    var dog: Dog = Dog()
    var boxDog = Box<Dog>(dog)
    var starBox:Box<*> = boxDog
    val item = starBox.getItem()

上面的代码中,不用关心Box里面的泛型到底是什么,直接传递给Box<*>,上面的代码可以通过编译:

image.png

并且可以调用相应的方法,但是返回不再是boxDog中的Dog了,而是Any?。因为这里抹除了类型信息,Box<out T>这里T的 上界Upper Bound 是Any?,所以取出来就是Any。但是可能会有疑惑,Dog的 上界 不是Animal吗?从逻辑继承的角度看确实是这样,但是单从泛型里无法看出,如果想要取出来的类型是Animal就需要在Box的泛型上指出上界,可以看下下面的代码:

class Box<out T:Animal>(t: T) {//注意,这里从out T变成了out T:Animal
    private var value = t

    fun getItem(): T = value
}

那么相应的,取出来的就是Animal,见下图:


image.png

上面讲完了out,如果是in,星投影是什么效果呢?先看代码:

class Box<in T>(t: T) { //注意,这里改成了in
    private var value = t

    fun setItem(t: T) {
        value = t
    }
}

    var starBox:Box<*> = boxDog
    val item = starBox.setItem(dog)

上面的代码在编译结果是什么样的?见下图:


image.png

不管什么Dog还是Animal,这里直接提示Required:Nothing。Nothing是什么意思?

package kotlin

/**
 * Nothing has no instances. You can use Nothing to represent "a value that never exists": for example,
 * if a function has the return type of Nothing, it means that it never returns (always throws an exception).
 */
public class Nothing private constructor()

Nothing没有实例,用于表示一个不存在的值。这就是说上面的setItem 方法不能传入任何值。
看完了两个例子,可以总结下星投影:
在不知道T的具体类型的情况下:

  • 对于Box<out T:TUpper> ,T 是带有上界TUpper 的协变,Box<*>就相当于Box<out TUpper> 。也就是说只能从Box<*>中读取TUpper。
  • 对于Box<in T>,T 是逆变 的,Box<*>等同于Box<in Nothing> ,那么原本只能接受写入的Box变成了不能接受写入。
  • 丢与Box<T:TUpper> ,T是 不变 的,T的上界是TUpper,Box<*>在读取的时候等同于Box<out TUpper>,在写入的时候等同于Box<in Nothing>。只能读取上界,不能写入。

如果有多个泛型,那么每个泛型会独立遵从对应的规则。例如,有一个方法Function<in T, out U>,那么可以有三种组合,第一个T用星号代替,第二个U用星号代替,第三个两个都用星号代替,举例:

  • Function<*, String> 等同于 Function<in Nothing, String>.
  • Function<Int, *> 等同于 Function<Int, out Any?>.
  • Function<*, *> 等同于 Function<in Nothing, out Any?>.

通过这样的限制可以更加安全的使用泛型。

上面提到了泛型的上界Upper Bound。class Box<out T:Animal>表示T的上界是Animal,如果我想要多个上界呢?也就是进一步约束泛型。这里就引出了最后一个修饰符 where。

fun <T> copyWhenGreater(list: List<T>, threshold: T): List<String>
    where T : CharSequence,
          T : Comparable<T> {
    return list.filter { it > threshold }.map { it.toString() }
}

上面T的上界必须同时满足CharSequence和Comparable。String就是一个符合条件的类型。
到这里基本可以对in、out和where有了一个大概的了解。

但是,大家有没有想过,为什么Kotlin要设计这样一个机制?或者说Java为什么要设计协变和通配符机制?

核心的原因就在于泛型擦除。所有看到的通配符,in、out都存在于编译阶段。一旦进入到运行阶段,泛型实际上不会存储任何关于类型的信息,即类型被擦除了。例如Box<Dog> 和 Box<Animal?> 都会被擦除成Box<*>。 所以为了防止运行过程中的异常,就必须在编译阶段严格的检查类型。

再讲一个情况,假如要在运行时检查某个类对象是否是某个泛型的对象,按直觉怎么写?

 fun <A,  B> Pair<*, *>.asPairOf(): Pair<A, B>? {
    if (first !is A || second !is B) return null
    return first as A to second as B
}

报错无法对擦除类型进行检查和Uchecked cast lint提示:


image.png

image.png

里面有个提示 Make type parameter reified and function inline ,按提示修改代码:

inline fun <reified A,  reified B> Pair<*, *>.asPairOf(): Pair<A, B>? {//多了inline 和 reified
    if (first !is A || second !is B) return null
    return first as A to second as B
}

代码编译通过。首先得内联(可以理解为代码替换,即将内联函数的代码直接copy到调用的位置),然后要加reified。因为只有内联到对应的代码中,才能知道泛型代表的实际类型,从而将泛型替换成真正的类型,才能做类型检查和转换。

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

推荐阅读更多精彩内容

  • 建议先阅读我的上一篇文章 -- Java 泛型 和 Java 泛型一样,Kotlin 泛型也是 Kotlin 语言...
    JohnnyShieh阅读 6,492评论 1 26
  • 泛型 泛型(Generic Type)简介 通常情况的类和函数,我们只需要使用具体的类型即可:要么是基本类型,要么...
    Tenderness4阅读 1,415评论 4 2
  • 起源 泛型编程是一种编程风格,其中算法以尽可能抽象的方式编写,而不依赖于将在其上执行这些算法的数据形式。这个概念在...
    lambeta阅读 13,869评论 3 14
  • 众所周知,由于Java在发布之初是没有泛型的,即使在JDK 1.5加入泛型这个新特性后,Java的泛型充其量也只能...
    _Preacher_阅读 3,746评论 2 18
  • 说起 kotlin 的泛型,就离不开 java 的泛型,首先来看下 java 的泛型,当然比较熟悉 java 泛型...
    kotlon阅读 678评论 0 0