layout | title | original-doc |
---|---|---|
doc |
泛型 | Generics |
和Java中一样,Kotlin中的类也可以有类型参数: {% highlight java %} class Box(t : T) { var value = t } {% endhighlight %}
通常来说,若要新建这些类型的实力,需要提供具体的类型参数: {% highlight java %} val box : Box = Box(1) {% endhighlight %}
但是如果类型参数可以推导出来,即从构造函数的参数类型,或通过其他的方式推导,则可以省略掉类型参数:
{% highlight java %} val box = Box(1) // 1是Int类型,所以编译器可以推导出来我们在使用Box {% endhighlight %}
和Java不一样的地方在于,Kotlin中类的类型信息在运行时是保留的。 这样我们就可以在运行时完整地检查对象的类型: {% highlight java %} if (b is Box) { // 'is' 替换了 Java 的 'isinstanceof' 符号 // b 保证是 Box, 而不仅仅是Box } {% endhighlight %}
我们甚至可以在is检查中使用类型参数: {% highlight java %} if (foo is T) { // 这是个有用的检查 // ... } {% endhighlight %}
同样,我们还可以新建泛型数组: {% highlight java %} class ArrayDemo { val array = Array() // Array就是一个普通类 } {% endhighlight %}
并且,不,Kotlin并没有 生肉类型(raw types),并且类型擦除也不像在Java中那样扮演重要角色。 比如,上述的Box<T>类,因为是泛型类,所以并不能使用不带类型参数的Box类。 这和Effective Java中的 第23条:不要在新代码中使用生肉类型 和第24条:消除unchecked警告相符合。
Java的类型系统中最复杂的部分就是通配符类型(参看Java泛型FAQ)。 而Kotlin并没有这个特性,而是引入了另外两种特性:声明处型变(Declaration-site Variance)和类型映射(Type Projection)。
首先,我们思考一下为什么Java需要那些神秘的通配符。
这个问题在Effective Java,
第28条:使用限制的通配符增强API灵活性有所解释。
首先,Java中的泛型是不可型变(invariant)的,这意味着List<String>
不是List<Object>
的子型。
为何如此?如果List是型变的,那它就和Java的数组没什么区别了,因为下面的代码会编译通过,而在运行时抛出异常:
{% highlight java %} // Java List strs = new ArrayList(); List objs = strs; // !!! The cause of the upcoming problem sits here. Java prohibits this! objs.add(1); // Here we put an Integer into a list of Strings String s = strs.get(0); // !!! ClassCastException: Cannot cast Integer to String {% endhighlight %}
所以,Java禁止这个特性,以保证运行时的类型安全。但这样也带来一些问题。
比如,考虑Collection
接口的addAll()方法。这个方法的签名应该是什么样的?
直观地我们,我们会这么写:
{% highlight java %}
// Java
interface Collection ... {
void addAll(Collection item);
}
{% endhighlight %}
但是这样的话,我们就不能做下面的简单的操作了(这个操作是完全安全的): {% highlight java %} // Java void copyAll(Collection to, Collection from ) { to.addAll(from); // !!! 不能编译通过!因为 Collection 不是 Collection 的子型 } {% endhighlight %}
(在Java中,我们历经教训才学会了这一课,参考Effective Java 的第25条:优先选择List,而不是Array)
这就是为什么addAll()实际上的签名是: {% highlight java %} // Java interface Collection ... { void addAll(Collection<? extends E> items); } {% endhighlight %}
它和里,通配符参数? extens T
表示这个方法接受一个集合,其元素的类型是T的某个子型,而不是T本身。
这意味着我们可以安全的从集合中读出T类型的元素(因为元素的类型是T的子型),
但是不能往其中写入T类型的参数,因为我们不知道具体是哪一个符合T子型的类型。
虽然有这个限制,但我们得到了期望的特性:Collection<String>
是Collection<? extends Object>
的子型。
用更“技术”的话说,带extend边界(上边界)的通配符使类型变成协变类型(covariant types)。
理解这个技巧的原理,关键点其实很简单:如果你只能从集合中拿出东西,那么从String的集合中拿出Object是可以的。
相反地,如果你只能往一个集合里放东西,那么往一个Object集合中放置一个String:在Java中,我们有
List<? super String>
,它是List<String>
的父型。
这被称为逆变类型,即你调用这种集合的方法时,只能使用String类型参数。
(比如List<? super String>
集合,可以调用add(String)或者set(int, String)),
但是如果调用某个返回T的方法,只能得到Object,而不是String。
Joshua Bloch称那些你只能从中读取的对象为生产者(producers),而那些只能往其中写数据的对象为消费者(consumers)。
他建议:"为了获得最大的灵活性,在代表生产者和消费者的输入参数最好使用通配符类型",
并且他提出了下面的助记词:
PECS 表示producer-extends, consumer-super.
注意:如果你使用一个生产者对象,如List<? extends Foo>
,不允许调用add()或者set()方法,但这并不代表这个集合是不可变(immutable)的。
比如,没谁阻止你调用clear()来删除列表中的全部元素,因为这个方法并不需要类型参数。
通配符(或其它的型变机制)唯一保证的事情是类型安全。不可变性是另一个故事。
假设我们有一个泛型接口Source<T>
,但它没有带泛型参数T的方法,而只有一个方法返回T:
{% highlight java %}
// Java
interface Source {
T nextT();
}
{% endhighlight %}
那么,在类型为Source<Object>
的变量中存入一个Source<String>
引用,应该是完全安全的--
并没有可以调用的消费者方法。但是Java并不知道这一点,所以仍然禁止:
{% highlight java %}
// Java
void demo(Source strs) {
Source objects = strs; // !!! 在Java中不允许
// ...
}
{% endhighlight %}
为了解决这个问题,我们必须将objects声明为Source<? extends Object>
类型。
但这样做没有太多意义,因为声明变复杂后,在这个变量上可以调用所有的方法,和之前是一样的。
即我们增加了类型的复杂度,却没有带来附加价值,仅仅是因为编译器并不知道才增加的。
在Kotlin中,有一种办法可以告诉编译器这个事情。这个机制被称为声明处型变:
我们可以给类Source的类型参数T加上标注,以保证它只从Source<T>
类的成员中返回(生产),而从来不消费。
这里我们使用out修饰符。
{% highlight java %} abstract class Source { fun nextT() : T }
fun demo(strs : Source) { val objects : Source = strs // 这样是可以的,因为T是一个out参数 // ... } {% endhighlight %}
基本的规则是:如果类C的类型参数T被声明为out,那么T就只能出现在成员的out位置(out-position),
而加上这个限制后,这样得到的好处是,C<Base>
是C<Derived>
的父型了。
用更“技术”的话来说,类C在T类型参数上是协变的,或者说T是一个协变类型参数。 这时候你可以把类型C看作T的生产者,而不是T的消费者。
out修饰符被称为协变标注,而且因为它是在类型声明的地方标记的,因此称为声明处协变。 这和Java的使用处协变不同,Java实在使用类型时确定一个类的型变性的。
out之外,Kotlin还提供了互补的型变标注:in。它会使一个类型参数变成逆变类型参数:这个类型只能消费,不能生产。
逆变型参的一个很好的示例是Comparable
:
{% highlight java %} abstract class Comparable { fun compareTo(other : T) : Int }
fun demo(x : Comparable) { x.compareTo(1.0) // 1.0 的类型是Double, 是Number的子型 // 所以我们可以把x赋值到类型为Comparable的变量y上 val y : Comparable = x // OK! } {% endhighlight %}
我们认为in和out这两个词都自明其义(并且他们在C#中已经成功很久了), 所以上面提到的助记词并不是很有必要,但可以为了更高的目的改述它: **存在主义的转述:Consumer in, Producer out! **:-)
使用out标记类型参数T非常方便,并且不用再烦恼使用时类型转换。 是的,这样很方便,但只是当这个类确实只返回T(生产者)的时候。 但是如果不是呢?Array是个很好的例子: {% highlight java %} class Array(val length : Int) { fun get(index : Int) : T { /* ... / } fun set(index : Int, value : T) { / ... */ } } {% endhighlight %}
这个类在T参数上既不是协变也不是逆变。而什么都不指定,也会带来一些不灵活。 考虑下面的函数: {% highlight java %} fun copy(from : Array, to : Array) { assert {from.length == to.length} for (i in from.indices) to[i] = from[i] } {% endhighlight %}
这个函数的目的是从一个数组拷贝数据到另一个数组。我们试试实际使用它: {% highlight java %} val ints : Array = array(1, 2, 3) val any = Array(3) copy(ints, any) // Error: 期望 (Array, Array) {% endhighlight %}
这里我们遇到了似曾相识的问题:Array<T>
对T是不可型变的。所以Array<Int>
和Array<Any>
两个类型完全没有父子关系。
为什么? 因为如果不这样的话,copy就可以做一些坏事了。
比如它可以试着往from数组中写入一个String, 而如果我们传入的参数实际上是Array<Int>
,
那么运行下去的某个时刻,总会抛出ClassCastException...
所以我们唯一想确保的事情就是copy()不做坏事。我们希望禁止它往from参数中写入数据,我们可以这么做: {% highlight java %} fun copy(from : Array, to : Array) { // ... } {% endhighlight %}
这里发生的事情称作类型映射: 我们说from不仅仅是个简单的数组,而且是个受限制的(映射了的)数组:
我们只能调用它的返回T的方法,在这个例子中即我们只能调用get()。
这就是我们的使用处型变的方式。它相当于Java的Array<? extends Object>
,但是稍微简单一些。
你也可以用in来映射类型参数: {% highlight java %} fun fill(dest : Array, value : String) { // ... } {% endhighlight %}
Array<in String>
相当与Java的Array<? super String>
,即你可以传入一个CharSequence数组,或者Object数组到fill()函数中。
有时候你会说你不知道类型参数的信息,但仍然想安全地使用它。我们说的安全是指我们想使用out映射(对象不接受消费任何未知类型的值)。
而且这个映射是限制在对应的参数的类型上限里的(即 out Any?,在大多数情况下)。
这种功能,我们有一个简单的写法,称为星号映射: Foo<*>
代表 Foo<out Bar>
,其中Bar是Foo的类型参数的型上限。
注意:星号映射和Java的生肉类型很像,但不同的是,它是类型安全的。
不止类可以使用类型参数,函数也可以。通常,在函数名之后用尖括号放置函数的类型参数: {% highlight java %} fun singletonList(item : T) : List { // ... } {% endhighlight %}
但是对于扩展函数来说,在函数名只前声明类型参数是有必要的, 因此Kotlin也支持下面的替代语法:
{% highlight java %} fun T.basicToString() : String { return typeinfo.typeinfo(this) + "@" + System.identityHashCode(this) } {% endhighlight %}
如果在使用的时候指定类型参数,则只能跟在函数名称之后: {% highlight java %} val l = singletonList(1) {% endhighlight %}
一个类型参数能够选择使用的类型的集合,是可以通过泛型限制来进行限制的。
最常用的限制是上界,和Java的extends关键字类似: {% highlight java %} fun sort<T : Comparable>(list : List) { // ... } {% endhighlight %}
类型参数后面的冒号之后就是该参数的上界:即只有Comparable<T>
的子型才能置换T。
比如:
{% highlight java %} sort(list(1, 2, 3)) // OK. Int是Comparable的子型 sort(list(HashMap<Int, String>())) // Error: HashMap<Int, String> 不是 Comparable<HashMap<Int, String>>的子型 {% endhighlight %}
如果不提供上届的话,默认上届是Any?
。在尖括号里只能指定一个上界,如果需要更多的上界的话,需要一个单独的where语句:
{% highlight java %}
fun cloneWhenGreater<T : Comparable>(list : List, threshold : T) : List
where T : Cloneable {
return list when {it > threshold} map {it.clone()}
}
{% endhighlight %}
还有一种泛型限制:类对象的泛型限制。 它们用来限制类对象的属性,以使之在替换T参数时,保证有某个属性。
考虑下面的例子。假设我们有一个类Default,它有一个属性default,用来存放该类的默认值
{% highlight java %} abstract class Default { val default : T } {% endhighlight %}
比如,Int类可以这样扩展继承Default: {% highlight java %} class Int { class object : Default { override val default = 0 } // ... } {% endhighlight %}
现在我们考虑 一个函数,接受一系列可空的T参数,即T?,并且将所有的null替换成默认值。 {% highlight java %} fun replaceNullsWithDefaults(list : List<T?>) : List { return list map { if (it == null) T.default // 现在我们还不知道T的类对象有没有这个属性 else it } } {% endhighlight %}
为了让这个函数通过编译,我们需要指定一个类型限制:T的类对象应该是Default<T>
的子型。
{% highlight java %}
fun replaceNullsWithDefaults(list : List<T?>) : List
where class object T : Default {
// ...
{% endhighlight %}
现在编译器就知道T(当作类对象来引用时)一定有default这个属性,所以我们就可以访问它了。 这个功能能够实现,是因为在Kotlin中,泛型类型是保留到运行时的
参看相关的issue