Kotlin 泛型中的 in 和 out

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Kotlin 泛型中的 in 和 out相关的知识,希望对你有一定的参考价值。

参考技术A

Out (协变)

如果你的类是将泛型作为内部方法的返回,那么可以用 out:

可以称其为 production class/interface,因为其主要是产生(produce)指定泛型对象。因此,可以这样来记: produce = output = out

In(逆变)

如果你的类是将泛型对象作为函数的参数,那么可以用 in:

可以称其为 consumer class/interface,因为其主要是消费指定泛型对象。因此,可以这样来记: consume = input = in。

Invariant(不变)

如果既将泛型作为函数参数,又将泛型作为函数的输出,那就既不用 in 或 out。

假设我们有一个汉堡(burger)对象,它是一种快餐,当然更是一种食物。

1. 汉堡提供者

根据上面定义的类和接口来设计提供 food, fastfood burger 的类:

现在,我们可以这样赋值:

很显然,汉堡商店属于是快餐商店,当然也属于食品商店。

而如果像下面这样反过来使用子类 - Burger 泛型,就会出现错误,因为快餐(fastfood)和食品(food)商店不仅仅提供汉堡(burger)。

2. 汉堡消费者

再让我们根据上面的类和接口来定义汉堡消费者类:

现在,我们能够将 Everybody, ModernPeople 和 American 都指定给汉堡消费者(Consumer<Burger>):

很显然这里美国的汉堡的消费者既是现代人,更是人类。

同样,如果这里反过来使用父类 - Food 泛型,就会报错:

根据以上的内容,我们还可以这样来理解什么时候用 in 和 out:

对比Java学Kotlin泛型

泛型

先来个总结:
【相同点】

  • Java 用 ? extends? super 来实现协变和逆变,对应到 Kotlin 是 outin,但是 out 是严格只读的,而 ? extends 并非如此;
  • Java 和 Kotlin 在子类重写父类方法时对入参和形参的对待是一样的,即入参不变,出参协变;

【不同点】

  • Java 数组是协变的,而 Kotlin 数组是不变的;
  • 当泛型类型 T 需要满足多个条件时,Java 用 & 符号,而 Kotlin 使用 where 关键字;
  • Java 的 ? extends 是非只读的,而 Kotlin 的 out 是只读的;
  • Java 通配符是 ?,Kotlin 是 *

Java 泛型

顾名思义,泛是指广泛、通用、扩展;型是指类型,指编程语言的类型系统。
泛型是编程语言在某一场景(类、函数入参出参等)中支持多种类型的能力。
泛型可以使我们的代码更具灵活性,而且能保证类型安全,还能避免类型转换。
在 Java 中,我们有几种常见使用泛型的场景:类声明、方法声明、集合类、赋值语句等。我们一一介绍。

// 类声明,类 Source 中包含一个任意类型(用T表示)成员变量
class Source<T extends CharSequence> 
    T t;


// 方法声明,入参和出参都是任意类型 T,且入参和出参类型一致
public <R> R method(R r) 
    // 省略代码
    return r;


// 集合类
List<String> listStr = new ArrayList<String>(2);
List<Integer> listInt = new ArrayList<Int>(2);

// 赋值语句
List<? extends Object> listObj = ...;
List<? super String> listStr = ...;
List<?> list = ...;

Kotlin 泛型

在类声明、方法声明、集合类这几种场景下,我们对 Java 的经验几乎可以原封不动的移植到 Kotlin 上,对应上面的 Java 代码我们可以写出等价的 Kotlin 代码:

// 类声明,类 Source 中包含一个任意类型(用T表示)成员变量
class Source<T : CharSequence> 
    var t: T? = null


// 方法声明,入参和出参都是任意类型 T,且入参和出参类型一致
fun <R> method(r: R): R 
    // 省略代码
    return r

    
// 集合类
var listStr: List<String> = mutableListOf()
var listInt: List<Int> = mutableListOf()

好了,如果你在 Java 中从未用过赋值语句场景中的 ? extends? super? 通配符,那你可以不必再往下阅读了,因为你在 Kotlin 中大概率也不会用到相关功能。

我们下面讲述 Java 中 ? extends? super? 在 Kotlin 中的等价形式。

inout 关键字

Java 的 ? extends? super 分别相当于 Kotlin 中的 outin
我们举例说明其用法。
在 Java 里面,? extends T 表示 T 或 T 的类型。相应的,List<? extends T> list 表示集合 list 里面的元素类型是T或T的子类型。
而且我们只知道里面的元素类型是T或T的子类型,所以我们无法往里面写入新的元素,因为这样可能会导致类型不符而运行出错。
比如我们有Java 类 DogCat 二者都是 Animal 的子类,List<? extends Animal> list 里面装的都是 Dog,但是我们并不知道这一点,因为我们只知道里面是 Animal,如果我们往里面写入 Cat,那么在代码运行是可能会出现类型转成错误。所以,? extends 限定了类型的上界(upper bound),是只读(按父类型T来读取)的,是生产者(Producer)。
同理,? super T 表示 T 或 T 的父类型。比如 List<? super T> list 表示集合 list 里面的元素类型是T或T的类型。其限定了类型的下界(lower bound)。以 List<? super String> list为例,我们只知道 list 里面装的是 String 或者其父类 Charsequence,可以往里面写入 String类型的元素,但是无法按照某种类型来读取里面的元素(除非是 Object 类型,但是这样几乎没有意义)。所以,? super只写的,是消费者(Consumer)。

上述规则可以用 PECS 记忆,Producer Extends,Consumer Supers。
或者简单的记为 out 是出,即出参,in 是入,即入参。

我们知道 Java 的 ? extends? super 是在赋值语句的时候使用的,Kotlin 将其对应的 outin 关键字扩大化了,不仅可以用于赋值语句场景(user-site scenario),还可以用于类声明等场景(declare-site)。
我们看如下 Kotlin 代码:

// 示例一:out,只读场景
abstract class Source<out T> 
    abstract fun create(): T


class Derived: Source<String>() 
    override fun create(): String 


fun main () 
    val list : List<Source<Any>>  = mutableListOf<Derived>()
    val s : Source<Any> = Derived()


// 示例二:in,Kotlin 标准库里面的 Comparable 接口,只写场景
public interface Comparable<in T> 
    public operator fun compareTo(other: T): Int

如果你只想知道 Kotlin 的 outin 关键字是啥,本篇文章读到这里就可以了。
如果你还想更近一步,想知道 outin 关键字背后的设计思想,欢迎继续往下阅读。
我们先讲里氏替换原则,然后了解下编程语言中的型变、不变、协变、逆变等概念,最后再回到 Java 和 Kotlin 上来,从造物主的视角来看待 ? extends? superoutin 背后的设计哲学。

里氏替换原则

设计模式里面有几大原则,取首字母可以组成 SOLID:

  • Single Responsibility Principle,单一职责原则,一个方法或一个类应该尽量保持自己的职责单一;
  • Open Close Principle,开闭原则,面向扩展开放,面向修改关闭;
  • Liskov Substitution Principle,里氏替换原则,子类应该比父类更少受限,子类应该能出现在父类出现的任何地方,并且替换之后不用做任何其他修改;
  • Dependency Inversion Principle,方法应该依赖抽象而不依赖具体;

这里说的“替换”,常见的场景有:

  • 赋值表达式,即父类指向子类对象;
  • 方法入参,形参是父类,实参类型是子类;
  • 方法的返回值类型;
  • 方法本身

以 Java 中的数字类 Number 和 Integer 为例。Integer 是 Number 的子类,我们有:

Number n = new Integer(1); // 形式一:赋值表达式,编译ok

func(new Integer(1)); // 形式二:方法入参,编译ok

void func(Number aNumber) ... // 方法声明

那么问题来了,如果我们将 Number 和 Integer 这些简单的类型“包装”成复杂类型,那么他们的复杂类型之间是否仍然具有父子关系呢?

具体的,假设我们用 f(x) 表示简单类型构成的复杂类型(数组、类等),用 f(Number) 和 f(Integer 分别表示简单类型 Number 和 Integer 对应的复杂类型。

Number 和 Integer 是父-子关系,那么 f(Number) 和 f(Integer) 仍然是父-子关系吗?还是反过来的子-父关系?还是压根就没有血缘关系?

这很重要,因为这个关系直接决定了我们能否对 f(Number) 和 f(Integer) 使用里氏替换原则以及该原则以何种形式存在。

型变、不变、协变、逆变

不失一般性的:

  • 如果类 X 和 Y 是父-子关系,f(X) 和 f(Y) 依然是满足 父-子关系,我们称 f 是协变的(Covariant);
  • 如果类 X 和 Y 是父-子 关系,而 f(X) 和 f(Y) 满足 子-父 关系,我们称 f 是逆变的(Contravariant);
  • 如果类 X 和 Y 是父-子 关系,f(X) 和 f(Y) 既满足 父-子 关系又满足 子-父 关系,我们称 f 是双向可变的(Bivariant);
  • 如果 f 是协变或逆变或双向可变的,我们称 f 是可变的(Variant);
  • 如果类 X 和 Y 是父-子 关系,而 f(X) 和 f(Y) 之间不存在父子关系,我们称 f 是不变的(Invariant);

这种「简单类型之间父子关系」和「对应复杂类型之间父子关」的关系,我们称之为型变或可变性(Variance)。

型变,是每一种编程语言都需要考虑的性质。编程语言的设计者会根据自身的需要决定自己类型系统中的各个复杂成员(数组、集合、函数等)满足哪种型变,甚至可以不遵守里氏替换原则。

啰嗦了这么一大堆高深的概念,有什么用呢?我们来看一些常见的型变应用场景。

常见的型变

类型一:数组

首先来看下 Java 的数组。

Java 是从 1.5 版本才开始支持泛型的,而对数组的支持则要比泛型早的多。让我们穿越回 Java 不支持泛型的年代,来看一个数组拷贝的场景:

void copy(Object[] from, Object[] to) 
    // 省略代码
    for (int i = 0; i < from.length; i++) 
        to[i] = from[i];
    

Object 是 Java 的祖先类,自然也是 Number 和 Integer 的父类。如果 Java 数组是不变(Invariant)的,即虽然 Object 和 Integer 是父-子关系,但是 Number[] 和 Integer[] 并非是 Object[] 的子类,那么就无法使用里氏替换原则,上面的 copy() 函数的入参就只能是 Object[] 类型,而不能是 Integer[] 或者 Integer[] 类型,否则下述代码将会编译报错:

Integer[] from = new Integer[];
Integer[] to = new Integer[];
copy(from, to);

在这种假设前提下,我们要为 Object 的每种子类型写一个 copy() 方法,这是非常不方便的。基于这种考虑,Java 数组被设计为协变(Covariant),即 Number[]、Integer[] 都是 Object[] 的子类。在这种设定下,上述代码都是可以正常运行的。

虽然如此,上述代码仍然不是类型安全的,比如下面 Java 代码这样并不会编译报错,但是会出现运行时抛出异常 ArrayStoreException:

String[] from = new String[];
Integer[] to = new Integer[];
copy(from, to);

当然,现在我们有泛型了,可以保证类型安全了:

<T> void copy(T[] from, T[] to) 
    // omitted code
    for (int i = 0; i < from.length; i++) 
        to[i] = from[i];
    

显然,出生在好时代的 Kotlin 数组是没有这种历史包袱的,Kotlin 的数组是不变的,比如如下代码是会编译报错:

// 函数调用
val from : Array<Int> = arrayOf(1, 2)
val to = Array<Any>(3) ""
copy(from, to) // 编译报错:Type mismatch,required:Array<Any>,found: Array<Int>

// 函数定义
fun copy(from: Array<Any>, to: Array<Any>) 
    // 省略代码
    for (i in to.indices) 
        to[i] = from[i]
    

怎么改动才能让上述代码通过编译呢?根据里氏替换原则,只要 Array 是 Array 的父类就行了,即让 Array 满足协变。

于是我们的 out 关键字就派上用场了,类似于 Java 的 ? extendsout 关键字能让 Kotlin 数组的型变由不变转成协变:

// 函数调用
val from : Array<Int> = arrayOf(1, 2)
val to = Array<Any>(3) ""
copy(from, to) // 编译ok

// 函数定义
fun copy(from: Array<out Any>, to: Array<Any>) 
    // 省略代码
    for (i in to.indices) 
        to[i] = from[i]
    

跟 Java 不同的是,为了保证类型安全,out 关键字严格践行了只读的原则,from[i] = ... 之类的赋值语句会编译报错。而 Java 中的 PECS 更多的是一个最佳实践,并未严格限制往 Java 数组或集合类中写入元素。

类似的,我们可以使用 in 关键字:

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

可能你会说:这个 in 关键字加的好勉强,没有 in 照样可以呀。
你说的都对。只是,带 in 的时候,fill() 方法具有更好的扩展性。比如带 in 的时候可以这么用:

val arrayCs = Array<CharSequence>(1) ""
fill(arrayCs, "") // 编译ok

如果没有 in,上面的代码可就要报错了。

另外,Objective-C 和 Swift 的数组也是不变的。总结一下常见前端编程语言的数组型变:

语言型变备注
Java协变
C#协变
Kotlin不变
OC不变
Swift不变
Dart/List
TypeScript协变

类型二:集合

Java 数组是协变的,那集合类呢?我们看下这段代码,注意注释里面的编译结果:

List<Object> list = new ArrayList<Integer>(); // 编译报错,类型不匹配
ArrayList<Object> list1 = new ArrayList<Integer>(); // 编译报错,类型不匹配

这是为什么呢?因为这种写法不是类型安全的,比如:

ArrayList<Object> list = new ArrayList<Integer>();
Object o = "string";
list.add(o);

所以,Java 集合是不变,但是通过其他方式实现了协变,具体来说就是 ? extends 实现了协变、通过 ? super 试下了逆变:

List<? extends Object> list = new ArrayList<Integer>(); // 编译OK

类似地,Java 用 ? super 来表示逆变关系,比如:

ArrayList<? super Integer> list = new ArrayList<Number>(); // 编译OK          

更进一步的,我们有下述关系图:

那 Kotlin 的集合类呢?跟 Java 是一样的,集合类本身是不变的,需要使用 outin 关键字来实现协变和逆变:

// 协变场景
val list: MutableList<Number> = mutableListOf<Int>() // 编译报错
val listOut: MutableList<out Number> = mutableListOf<Int>() // 编译ok
listOut.add(1) // 编译报错,因为 listOut 是只读的

// 逆变场景
val listIn: MutableList<in Int> = mutableListOf<Number>() // 编译ok
listIn.add(1) // 编译ok

类型三:继承

Java 子类在重写父类方法时,可以更改方法的入参和返回值类型吗?比如把下面代码中子类重写方法中的入参更改为其对应的子类型或父类型,可行吗?

// 父类
class Base 
    protected ReturnType method(ParamType param) 


// 子类
class Derived extends Base 
    @Override
    protected BaseOrDerivedReturnType method(BaseOrDerivedParamType param)  // 出参类型由 ReturnType 改为其父类或子类,入参类型由原来的 ParamType 改为其父类型或子类型,共 2x2=4 种组合,哪些组合会编译报错?

熟悉Java的同学可能会说,入参类型不能变(即不变,Invariant),返回类型可以更改为子类型(即协变)。

Kotlin 在这一点上是和Java一样的。

而对于其他语言,并不一定和 Java 同学,比如 Dart 就可以在对入参使用 covariant 关键字将其声明为协变类型:

class Animal 
  void chase(Animal x)  ... 


class Mouse extends Animal  ... 

class Cat extends Animal 
  
  void chase(covariant Mouse x)  ... 

类型四:函数类型

在有些编程语言中,比如 Kotlin,函数本身也是可以作为参数类型传递的。

在这种场景下,编程语言的设计者需要考虑函数之间的型变。

假设我们用 P -> R 表示一个函数,即入参为 P、返回类型为 R 的函数。那么 P1 -> R1 是 P -> R 的子类型,当且仅当 P1 是 P 的父类型而 R1 是 R 的子类型,即入参类型是逆变,而返回类型是协变。我们可以简单的理解为:输入更自由而返回更保守。

Java 8 开始支持函数作为参数传递。Kotlin 也支持,高阶函数就是。

TypeScript 比较特殊,其函数是双向可变的,也是协变的(需 strictFunctionType 注解)。

多类型

如果 Java 中泛型类型要满足多个上界限制,则要使用 & 符号,比如:

class D<T extends A&B&C> ... // 注意,这里的 extens 和 ? extends 区别开来

而 Kotlin 中,则使用 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() 

通配符

Java 中的通配符是 ?,而 Kotlin 的是 *

  • 对于 Foo<out T : TUpper>,T 是 协变类型参数,Foo<*> 等价于 Foo<out TUpper>,表示 T 类型位置,但是可以安全的以 TUpper 类型从 Foo<*> 中读取数据;
  • 对于 Foo<in T>,T 是逆变类型参数,Foo<*> 等价于 Foo<in Nothing>,表示当 T 类型未知时,向 Foo<*> 写入任何类型的数据都无法保证类型安全;
  • 对于 Foo<T : TUpper>,T 是不变类型参数,Foo<*> 在读取时等价于 Foo<out TUpper>,在写入时等价于 Foo<in Nothing>

类型擦除

Java 和 Kotlin 的泛型代码都只是在编译期间检测类型安全,而在运行时均不持有其原有的类型信息,即类型擦除。比如 Kotlin 代码 Foo<Bar>Foo<Bar?> 在运行时都会被擦除成 Foo<*>

参考文献

以上是关于Kotlin 泛型中的 in 和 out的主要内容,如果未能解决你的问题,请参考以下文章

泛型中的类型擦除

Java泛型中的“超级”和“扩展”有啥区别[重复]

Java泛型中的协变和逆变

java 泛型中的T和?

unity的C#学习——泛型的创建与继承泛型集合类泛型中的约束和反射

scala - 泛型中的任何与下划线