目录

Kotlin系列(十八):Kotlin中的范型

一、前言:

  • C++有模版类,Java有范型,细想其底层,终归是通过类型占位替换来实现。

  • 而Kotlin对于范型的改动略大,与Java的写法不同,其引入out与int来处理范型的场景。

  • 本次我们将通过Kotlin官网来一探Kotlin中的范型的实现以及其相对与Java原范型的优越指出。


二、泛型

  • 与 Java 类似,Kotlin 中的类也可以有类型参数:

      class Box<T>(t: T) {
      	var value = t
      }
    
  • 一般来说,要创建这样类的实例,我们需要提供类型参数:

      val box: Box<Int> = Box<Int>(1)
    
  • 但是如果类型参数可以推断出来,例如从构造函数的参数或者从其他途径,允许省略类型参数:

      val box = Box(1) 
    
  • 上面的由于传入的1为int,所以编译器知道我们说的是 Box


三、生产者与消费者

在 Java 泛型里,有通配符这种东西,我们要用 ? extends T 指定类型参数的上限,用 ? super T 指定类型参数的下限。Kotlin 抛弃了这个系统,

  • 在 Java 泛型里,有通配符这种东西,用 ? extends T 指定类型参数的上限,用 ? super T 指定类型参数的下限。

  • Kotlin 抛弃了这个系统,引用了生产者和消费者的概念。

  • Ok,我们通过一个例子来理解以下:

      public interface Collection<E> extends Iterable<E> {
      	boolean add(E e);
      	boolean addAll(Collection<? extends E> c);
      }
    
  • 这是 Collection 接口的add() 和 addAll() 方法,传入它们的类型参数一个是 E ,一个是 ? extends E,为什么呢?这两个方法之间不就是批量操作的区别吗?为什么一个只接受 E 类型的参数,另一个却接受 ? extend E 的类型?

  • 这就要引入一个概念,型变,那么什么是型变呢?我们看下面


型变

首先我们思考,Java为何需要复杂的通配符?

  • 为什么 Java 需要那些神秘的通配符。在 Effective Java 解释了该问题——第28条:利用有限制通配符来提升 API 的灵活性

Java中的列表和集合,为何列表优于集合?

  • Java中的列表是形变的,也就是说String[] 是Object[]的子类;

  • 但是Java 中的泛型是不型变的,这意味着 List<String>不是 List<Object> 的子类型。

  • 为什么这样?

  • 如果 List 是不可型变的,它就没比 Java 的数组好到哪去,因为如下代码会通过编译然后导致运行时异常:

      // Java代码
      List<String> strs = new ArrayList<String>();
      // !!!即将来临的问题的原因就在这里。Java 禁止这样!
      List<Object> objs = strs;
      // 这里我们把一个整数放入一个字符串列表
      objs.add(1); 
      // !!! ClassCastException:无法将整数转换为字符串
      String s = strs.get(0); 
    
  • 因此,Java 禁止这样的事情以保证运行时的安全。

  • 由于禁止了这一行为,带来了一些影响。

  • 例如,考虑 Collection 接口中的 addAll() 方法。该方法的签名应该是什么?直觉上,我们会这样:

      interface Collection<E> …… {
      	void addAll(Collection<E> items);
      }
    
  • 但随后,我们将无法做到以下简单的事情(为了安全):

      void copyAll(Collection<Object> to, Collection<String> from) {
      // !!!对于这种简单声明的 addAll 将不能编译:
      // 因为Collection<String> 不是 Collection<Object> 的子类型
      	to.addAll(from); 
    
      }
    
  • 这就是为什么 addAll() 的实际签名是以下这样:

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

Java中的通配符? extends E

  • Ok,我们Java中的**通配符类型出来了。

  • ? extends E 表示此方法接受 E 或者 E 的子类的类型对象的集合。注意,其是一个集合。

  • 简单的说就是接收的类型必须是E或者其子类的集合。理解这点很重要

  • 例如:对于一个 Collection 来说,因为 String 是 Object 的子类型,一个 String 对象就是 Object 类型,所以可以直接把它添加入 Collection 里,所以add() 方法的类型参数因为可以设为 E;而想把 Collection 添加入 Collection 时,因为 Java 泛型不型变的原因,就会出现编译错误,必须用 ? extends E 将 Collection 囊括到 Collection 里。

小结:

-也就是说,使用 ? extends E 定义的集合,我们可以安全地从其中(该集合中的元素是 E 的子类的实例)读取 E,但不能写入,因为Java的范型是不型变的如List

  • 简而言之,带 extends 限定(上界)的通配符类型使得类型是协变的(covariant)

Java中的通配符 ? super E

  • 在 Java 中还有一个通配符 List<? super String> ,其意思是List存储的必须是String或者String的父类,super限定(下限)的通配符,带** super **的我们称之为逆变性(contravariance),并且对于 List <? super String> 你只能调用接受 String 作为参数的方法

  • (例如,你可以调用 add(String) 或者 set(int, String)),当然如果调用函数返回 List<T> 中的 T,你得到的并非一个 String 而是一个 Object


生产者、消费者概念的来源

  • Joshua Bloch 称那些你只能从中安全读取的对象为生产者,并称那些你只能安全写入的对象为消费者。安全这点的理解很重要。

  • 他建议:“为了灵活性最大化,在表示生产者或消费者的输入参数上使用通配符类型”,并提出了以下助记符:

PECS 代表生产者-Extens,消费者-Super(Producer-Extends, Consumer-Super)。

  • 注意:如果你使用一个生产者对象,如 List<? extends Foo>,在该对象上不允许调用 add()set()。但这并不意味着该对象是不可变的:例如,没有什么阻止你调用 clear()从列表中删除所有项目,因为 clear() 根本无需任何参数。通配符(或其他类型的型变)保证的唯一的事情是类型安全。不可变性完全是另一回事。

  • 而对于生产者、消费者,其对应的具体实现则为声明处型变和类型投影

  • 接下来我们将详细讲解这两个特性。


四、声明处型变

  • 假设有一个泛型接口 Source<T>,该接口中不存在任何以 T 作为参数的方法,只是方法返回 T 类型值:

      interface Source<T> {
      	T nextT();
      }
    
  • 那么,在 Source <Object> 类型的变量中存储 Source <String> 实例的引用是极为安全的——没有消费者-方法可以调用。但是 Java 并不知道这一点,并且仍然禁止这样操作:

      void demo(Source<String> strs) {
      	Source<Object> objects = strs; // !!!在 Java 中不允许
      	// ……
      }
    
  • 为了修正这一点,我们必须声明对象的类型为 Source<? extends Object>,这是毫无意义的,因为我们可以像以前一样在该对象上调用所有相同的方法,所以更复杂的类型并没有带来价值。但编译器并不知道。

  • 在 Kotlin 中,有一种方法向编译器解释这种情况。这称为声明处型变:我们可以标注 Source类型参数 T 来确保它仅从 Source<T> 成员中返回(生产),并从不被消费。

  • 为此,我们提供 out 修饰符:

      abstract class Source<out T> {
      		abstract fun nextT(): T
      }
    
      fun demo(strs: Source<String>) {
      		val objects: Source<Any> = strs // 这个没问题,因为 T 是一个 out-参数
      		// ……
      }
    
  • 一般原则是:当一个类 C 的类型参数 T 被声明为 out 时,它就只能出现在 C 的成员的输出-位置,但回报是 C<Base> 可以安全地作为 C<Derived>的超类。

  • 对应我们的例子就是,Source类的类型参数T被声明为out时,T只能作为Source类中成员的返回类型,如函数。这样子做的好处是,我们可以直接T类型的子类的集合直接安全的设置给T类型的集合

  • 简而言之,他们说类 Source 是在参数 T 上是协变的,或者说 T 是一个协变的类型参数。

  • 你可以认为 SourceT生产者,而不是 T消费者

  • out修饰符称为型变注解,并且由于它在类型参数声明处提供,所以我们讲声明处型变

  • 这与 Java 的使用处型变相反,其类型用途通配符使得类型协变。使用处型变就是接收一个集合时强制转换。

  • 另外除了 out,Kotlin 又补充了一个型变注释:in。它使得一个类型参数逆变:只可以被消费而不可以被生产。逆变类的一个很好的例子是 Comparable

      abstract class Comparable<in T> {
      		abstract fun compareTo(other: T): Int
      }
    
      fun demo(x: Comparable<Number>) {
      		x.compareTo(1.0) // 1.0 拥有类型 Double,它是 Number 的子类型
      		// 因此,我们可以将 x 赋给类型为 Comparable <Double> 的变量
      		val y: Comparable<Double> = x // OK!
      }
    
  • 我们相信 inout 两词是自解释的(因为它们已经在 C# 中成功使用很长时间了),

  • 因此上面提到的助记符不是真正需要的,并且可以将其改写为更高的目标:

  • 其out和in也就是kotlin为我们划分了特定的场景,避免我们进行强制转换。

  • 例如第一个例子:

       //设置
      	val objects: Source<Any> = (Source<Any>)strs 
      //接收
      val result:Source<String> = (Source<String>)objects
      	// ……
    
  • 这就需要我们人为去控制风险了。


五、类型投影

  • 使用处型变:类型投影

  • 将类型参数 T 声明为 out 非常方便,并且能避免使用处子类型化的麻烦,但是有些类实际上不能限制为只返回 T

  • 一个很好的例子是 Array:

      class Array<T>(val size: Int) {
      		fun get(index: Int): T { ///* …… */ }
      		fun set(index: Int, value: T) { ///* …… */ }
      }
    
  • 该类在 T 上既不能是协变的也不能是逆变的。这造成了一些不灵活性。考虑下述函数:

      fun copy(from: Array<Any>, to: Array<Any>) {
      		assert(from.size == to.size)
      		for (i in from.indices)
      			to[i] = from[i]
      }
    
  • 这个函数应该将项目从一个数组复制到另一个数组。让我们尝试在实践中应用它:

      val ints: Array<Int> = arrayOf(1, 2, 3)
      val any = Array<Any>(3) { "" } 
      copy(ints, any) // 错误:期望 (Array<Any>, Array<Any>)
    
  • 这里我们遇到同样熟悉的问题:Array <T>T 上是不型变的,因此 Array <Int>Array <Any> 都不是另一个的子类型。为什么? 再次重复,因为 copy 可能做坏事,也就是说,例如它可能尝试一个 String 到 from

  • 并且如果我们实际上传递一个 Int 的数组,一段时间后将会抛出一个 ClassCastException 异常。

  • 那么,我们唯一要确保的是 copy() 不会做任何坏事。我们想阻止它from,我们可以:

      fun copy(from: Array<out Any>, to: Array<Any>) {
       // ……
      }
    
  • 这里发生的事情称为类型投影:我们说from不仅仅是一个数组,而是一个受限制的(投影的)数组:我们只可以调用返回类型为类型参数 T 的方法。

  • 如上,这意味着我们只能调用 get()。这就是我们的使用处型变的用法,并且是对应于 Java 的 Array<? extends Object>、但使用更简单些的方式。

  • 你也可以使用 in 投影一个类型:

      fun fill(dest: Array<in String>, value: String) {
      		// ……
      }
    
  • Array<in String> 对应于 Java 的 Array<? super String>,也就是说,你可以传递一个 CharSequence 数组或一个 Object 数组给 fill() 函数。


六、星投影(可以慢慢消化,需先消化上面的内容)

  • 有时你想说,你对类型参数一无所知,但仍然希望以安全的方式使用它。

  • 这里的安全方式是定义泛型类型的这种投影,该泛型类型的每个具体实例化将是该投影的子类型。

  • Kotlin 为此提供了所谓的星投影语法:

  • 对于 Foo <out T>,其中 T 是一个具有上界 TUpper 的协变类型参数,Foo <*> 等价于 Foo <out TUpper>。 这意味着当 T 未知时,你可以安全地从 Foo <*> 读取 TUpper 的值。

  • 对于 Foo <in T>,其中 T 是一个逆变类型参数,Foo <*> 等价于 Foo <in Nothing>。 这意味着当 T 未知时,你可以以安全的方式写入 Foo <*>

  • 对于 Foo <T>,其中 T 是一个具有上界 TUpper 的不型变类型参数,Foo<*> 对于读取值时等价于 Foo<out TUpper> 而对于写值时等价于 Foo<in Nothing>

  • 如果泛型类型具有多个类型参数,则每个类型参数都可以单独投影。

  • 例如,如果类型被声明为 interface Function <in T, out U>,我们可以想象以下星投影:

  • 1、Function<*, String> 表示 Function<in Nothing, String>
  • 2、Function<Int, *> 表示 Function<Int, out Any?>

  • 3、Function<*, *> 表示 Function<in Nothing, out Any?>

  • 注意:星投影非常像 Java 的原始类型,但是安全。

八、泛型函数

  • 不仅类可以有类型参数。函数也可以有。

  • 类型参数要放在函数名称之前:

      fun <T> singletonList(item: T): List<T> {
      		// ……
      }
    
      fun <T> T.basicToString() : String {  // 扩展函数
      		// ……
      }
    
  • 要调用泛型函数,在调用处函数名之后指定类型参数即可:

      val l = singletonList<Int>(1)
    

九、总结

  • 我们只要记住 out为生产者、其能够安全的返回,里面不能有插入。int 其能安全的写入,但不能有返回。

  • 如果你觉得太晦涩难懂,就这么记吧:out T 等价于 ? extends T,in T 等价于 ? super T,此外还有 *** 等价于 ?**。

  • 本篇理解起来可能有点绕,写错的地方和有不懂的都可留言讨论,谢谢