在更深入的了解之前,让我们先从一些例子看起:
让我们先写一个简单的泛型类:
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
呢?是不是也能通过呢?见下图:
编译不通过,报错
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
这里又报错了,具体看下面截图:
怎么一会儿感觉符合逻辑,一会儿编译报错!
在具体解释一会儿编译过一会儿编译不过之前,先来简单介绍下不变、协变和逆变的概念。
- 不变(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>会有哪些影响呢?
- 编译不通过,报错提示本该是in类型出现的地方,却被声明成了out。结合上面的out解释,可以理解。
2.之前Box<Dog>
赋值给Box<Animal>
编译报错没有了。这就是说out产生了协变效果,Box<Dog>
成为了Box<Animal>
子类,让本来不变 的类型产生了协变(符合逻辑了)。 - 如果这时删掉了value的private修饰,也会报错,报错的原因同1中相同,也就是value会被外部修改。
- 编译不通过,报错提示本该是in类型出现的地方,却被声明成了out。结合上面的out解释,可以理解。
-
in --- 表示声明的类中只能有接受该类型的方法,不能有返回该类型的方法。
同样以上面的Box为例,如果改成 Box<in T>会有哪些影响呢?-
编译不通过,提示本该是out类型的地方,却被声明成了in。结合上面in的解释也可以理解。
- 之前
Box<Dog>
赋值给Box<Animal>
编译报错又出现了。之前out的协变 没有了。 - 如果这时删掉value的private修饰,也会报错,报错提示value是in类型,但是却出现在了不变 的位置。(这里可以理解成value会被外部访问到,换言之,只要被in、out任一修饰,该类型变量都不希望被外部直接访问到)。
- 如果把boxDog = boxAnimal会怎么样?注意!这里是把我们印象中的父类赋值给了子类!逻辑上类比dog = animal,但是编译却能通过。这就有点和印象不符了,不是说只有子类能赋值给父类,哪有父类能赋值给子类的。所以这里就需要思考一下,到底谁是谁的父类。其实这里确实是子类赋值给父类,也就是说boxDog是boxAnimal的父类。这就是前面讲的 逆变 。
-
有点懵,理一下思路,如果泛型什么都不加就是不变,如果加了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;
编译不通过,来阻止后续的类型转换异常。假如要自己实现 Collections
的 addAll
方法,直觉上会写成这样:
// 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数组当然是可以的)。
但是看一下下面这个情况:
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<*>,上面的代码可以通过编译:
并且可以调用相应的方法,但是返回不再是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,见下图:
上面讲完了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)
上面的代码在编译结果是什么样的?见下图:
不管什么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提示:
里面有个提示 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。因为只有内联到对应的代码中,才能知道泛型代表的实际类型,从而将泛型替换成真正的类型,才能做类型检查和转换。