Kotlin系列(十八):Kotlin中的范型
- 更多分享:www.catbro.cn
一、前言:
-
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
是一个协变的类型参数。 -
你可以认为
Source
是T
的生产者,而不是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! }
-
我们相信 in 和 out 两词是自解释的(因为它们已经在 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,此外还有 *** 等价于 ?**。
-
本篇理解起来可能有点绕,写错的地方和有不懂的都可留言讨论,谢谢