深入kotlin - 范型

Posted 颐和园

tags:

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

范型

定义

class A<T>(t:T)
	var a: T
	init 
		this.a = t
	

......
var aa = A("kotlin")
println(aa.a) // 打印 kotlin

其中 A<String>("kotlin")可以根据类型推断简写写成 A("kotlin")

协变、逆变

所谓协变、逆变是指一个对象被意外地降级为子类/升级为父类。特别是集合操作中,很容易出现此类问题。

在 Java 中,List<String> 并不是 List<Object> 的子类,因此不能把 List<String> 赋值给 List<Object>

但是对于数组来说则不同,因为数组天然支持协变。看如下代码:

Object[] arr = new String[]"aaa","bbb";
arr[0] = new Object();

第一句由于数组天然支持协变,所以 String 数组属于 Object 数组子类,因此把一个 String 数组赋给 Object 数组是可行的。

但对于第二句,arr[0] 实际上指向的是一个 String,将一个 Object 赋值给他,实际上是将父类赋给了子类——在面向对象中这是绝对不行的。这实际上会导致一个运行时异常。但 Java 在编译时却不能检查出来,在编译器看来,Object 数组中的元素自然也是 Object,将一个 Object 对象赋值给他自然是行得通的。但是在实际使用这个数组的过程中,由于数组这种允许协变的特性,有可能会导致它的元素被意外降级为子类(比如将一个 String 数组赋值给它,那么它所有的元素实际上都被降级为 String ),因此协变的出现对 Java 来说是一个威胁。

为了解决此问题,Java 提供了 ?通配符:

List<? extends Object> list // 表示元素类型包括 Oject 及其子类。

因此List<String>List<? Extends Object> 的子类,而非List<Object>的子类。List<? Extends Object>这样的范型限制了类型的上界。当我们向 List 中放入一个 String 元素后再读去这个元素,这个元素缺只能被当成 Object 看待,这种情况称之为协变。

与此相反,是逆变的概念:

List<? super String> // 其元素类型只能是 String 及其父类

当你向这个 List 中写入对象时,不仅仅可以放入 String 对象,还可以放入 String 的父类对象,比如 Object。

Java 通过在读取时使用<? extends E> ,在写入时使用<? super E>来实现协变和逆变。

总结一下,所谓协变逆变,都是范型约束的一种泛化,协变是向下(到子类)的泛化,逆变是向上(到父类)的泛化。

如果一个范型被指定为允许协变,表示这个范型可以和子类兼容,反之,逆变则允许和父类兼容。

在Kotlin 中,用 out 关键字来解决协变的问题:

class A<out T>(t: T)  
	private var t: T
	init
		this.t = t
	
	fun get(): T = this.t

这里, 表示该范型仅仅用于读取(get 方法),因此它将允许向下协变,即可以涵盖 T 及其子类。比如:

var aa:A<Any> = A<String>("abc") // 虽然传入的是 String,但是可以当成 Any,当然读出来的时候已经变成 Any 而不是 String

虽然 aa 被定义为 A 类型,但是我们把一个 A 赋值给它也是没问题的,因为 String 是 Any 的子类。

类似地,in 关键字用于解决逆变问题:

class B<in T>(t: T) 
	private var t: T
	init
		this.t = t
	
	fun set(t: T)
		this.t = t
	

表示该范型仅仅用于写入(set 方法),因此它允许向上逆变,即可以涵盖 T 及其父类:

var aa:A<Int> = A<Number>(3) // 虽然传入的是 Number, 但是可以当成 Int

虽然 aa 被定义为 A 类型,但是我们把一个 A 赋值给它,因为 Number 是 Int 的父类。

协变只能用于读,逆变只能用于写

请看如下 java 代码:

     	  List<Cat> cats = new ArrayList<>();
        cats.add(new Cat());
        List<? extends Animal> animals = cats;
        Animal animal = animals.get(0); // 允许读取到 Animal
        System.out.println(animal); // 打印出来的实际是 com.Cat@66d3c617,但你仍然无法赋值给 Cat 对象
        animals.add(new Animal());

这时编译器在最后一句报错: capture<? extends com.Animal> cannot be applied to com.Cat

这说明,协变只允许读取,不允许写入(比如 add)。

这其中的道理很容易理解,因为 animals 定义为List<? extends Animal> 它可以在运行时被赋值为 List、List…因为它们都是List<? extends Animal> 的子类。 如果像代码中一样,animals 被一个 List 赋值,那么它里面的元素就应该是 Cat 对象,如果 java 允许对元素进行赋值,那么很可能会错误地把一个 Animal 对象赋给一个 Cat 元素,这在 Java 中是绝对不允许的(在 java 中,将父类赋值给子类对象是不允许的,反之可以)。为了防止这类错误发生,编译器直接禁止了对协变的类型进行 set 操作。

逆变则相反:

List<Animal> animals = new ArrayList<>();
List<? super Cat> cats = animals;
cat.add(new Cat());
Cat cat = cats.get(0);

此时编译器报错: Required: com.Cat, Found: capture<? super com.Cat>,说明逆变不允许读取,但可以写入(比如 add)。

我们打印 get(0) 得到的对象类型,确实就是 Cat:

System.out.println(cats.get(0)); // 打印:com.study.Cat@66d3c617

但编译器不允许对 cats 进行读取——准确地说,不允许读取到一个 T 类型的变量中,如果读取到 Object 对象是没问题的:

Object cat = cats.get(0);

这其中的道理不难理解,因为 cats 的定义是List<? super Cat> ——集合中的元素可能是 Cat 及其父类类型,那么当你从中读取一个元素再赋给一个 Cat 对象时,读取的元素很可能是一个 Animal,在 java 中,将父类赋值给子类对象是不允许的(反之可以)。

out 允许范型降级

Kotlin 通过 out 关键字允许协变,或范型降级(即声明范型时是某个类型,但通过某些手段可以将它降级为声明时类型的子类):

class A<out T>(private val value: T)  // 1

... ...
val aa = A("out sample") // 2
val aaa: A<Any> =  aa // 3
  1. class A 有一个允许协变的范型 T(用 in 关键字),因此这个 T 允许降级,即可以将子类赋值给父类。
  2. aa 是一个 A 类型(通过类型推断)。
  3. aaa 是一个 A 类型,由于允许协变,虽然 T 为 Any,但我们可以将 Any 的子类 String 赋值给 T。这样,实际上 T 从 Any 被降级为 String 了。因为这中操作限制了写入操作,你实际上不可能将 value 读取到一个 String 变量中了(编译器报错)。

out 用作返回类型

如果一个范型被定义为 out ,那么这个范型可以作为方法返回类型,但不能作为方法的参数类型,这点和 out 的字面意义是一致的。反过来,如果你发现一个范型仅仅是充当方法的返回类型使用,那么我们可以将该范型定义为 out。

in 允许范型升级

与此相反,in 关键字允许逆变,即将范型升级为父类。

class B<in T>  // 1
	fun toString(value: T): String 
		return value.toString() // 2
	

... ...
val bb = B<Number>() // 3
val bbb: B<Int> = bb // 4
assertTrue(bbb is B<Int>) // 5
  1. class B 有一个允许逆变的范型 T(范型升级),使用 in 关键字。
  2. 逆变只能用于写,不能用于读,因此直接返回 value 是不行的(因为它是 T 类型),但返回 value 的 toString 方法是可以的(它不再是 T 类型,而是 String 类型)。
  3. bb 是一个 B 类型。
  4. 逆变允许升级,虽然 bbb 在声明时指定 T 为 Int,但是可以将 Int 的父类 Number 赋值给 T,实际上 T 是被升级了。
  5. 虽然 T 被升级,但它仍然还是定义时的 Int。因此断言通过。

in 用作参数类型

如果一个范型被定义为 in ,那么这个范型可以作为方法参数类型,但不能作为方法的返回类型,这点和 in 的字面意义是一致的。反过来,如果你发现一个范型仅仅是充当方法的参数类型使用,那么我们可以将该范型定义为 in。

范型不能同时修饰 out 和 in

同时,不能在同一个范型上既使用 out 又使用 in。如果一个范型既要作为方法的参数类型,又要作为方法的返回类型,那么这个范型不使用任何关键字修饰(这被称之为不变)。

out 和 in 本质上是多态

多态中规定,子类实例可以赋值给一个父类的引用。

这在于协变很好理解——因为它本就是范型降级,逆变则比较难于理解了,让我们举一个逆变的例子说明:

Interface Consumer<in T> 
	fun consumer(item: T)

Consumer 接口定义了一个范型 in T,表示这是一个逆变范型,即允许声明 T 时用子类声明,但使用时指定一个父类。同时,在方法中使用了范型 T。

然后定义两个类:

class Human:Consumer<Fruit> 
	override fun consume(item: Fruit)
		println("Consume Fruit")
	

class Man: Consumer<Apple> 
	override fun consumer(item:Apple)
		println("Consume Apple")
	

Human 和 Man 之间没有继承关系。Human ''消费" 的是 Fruit,Man “消费” 的是 Apple。这里的消费即指方法所接收的参数。则我们可以这样做:

val consumer1: Consumer<Fruit> = Human()
val consumer2: Consumer<Apple> = Human()

consumer1 声明的时候 T 是 Fruit,使用的时候是也是 Fruit(查看 Human 的定义),声明和使用一致,即所谓的不变。这是没有任何问题的。

consumer2 声明的时候 T 是 Apple,但使用的时候却是 Fruit(Fruit 是 Apple 的父类),T 在使用时中被升级了,这就是所谓的逆变。多态中规定,子类实例可以赋值给父类引用。但这里明明是将一个父类赋值给了子类,怎么解释?

但是本质上这仍然是多态,consumer2 将 T 声明为 Apple,则你在调用 consume 方法时必然只能传递一个 Apple 参数给它(否则编译器不通过):

consumer2.consume(new Apple())

但是 consumer2 实际上是一个 Human,因此调用的是 Human 的 consume(item: Fruit) 方法。那么相当于是把一个 Apple 对象传给了 Fruit 参数,这不正是把子类实例(Apple)赋值给父类引用(Fruit)吗?符合多态的定义。

使用处协变(类型投影)

前面提到 kotlin 是声明处协变,Java 是使用处协变。但实际上 kotlin 也支持使用处协变(又称类型投影)。声明处协变的限制在于它实际上对范型进行了约束,对于声明为 out 的范型,你在使用时就只能作为方法返回值而不能作为参数,这一点是不现实的。

以 Array 为例,Array 声明时即没有使用 out 也没有使用 in 修饰,这是因为 T 既在返回类型时使用(get 方法),也在参数中使用(set方法)。

在这种情况下,Array 有可能产生使用处协变。

fun copy(from: Array<Any>, to: Array<Any>) 
	assertTrue(frome.size == to.size)
  for(i in from.indices) 
    to[i]=from[i]
  

fun main(args: Array<String>)
  val from:Array<Int> = arrayOf(1,3,5,6)
  val to:Array<Any> = Array<Any>(4, "hello"+it)
  for(item in to) 
    println(item)// "hello0","hello1","hello2","hello3"
  
  copy(from, to)

在 copy(from, to) 处出现警告, from 的类型不匹配:定义的是 Array 但是提供了一个 Array。虽然 Int 是 Any 的子类,但 Array 并不是 Array 的子类。

我们可以将 copy 方法中的 from 参数重定义为 Array ,这样编译就可以通过了。这就是使用时协变。它和声明时协变的区别在于,它不是在定义该范型的地方使用 out,而是在实例化的地方使用 out:

// 范型声明
Consumer<in T>
// 范型实例化
Array<out Any>

范型声明,是声明某个类型不确定,使用一个占位符来代替这个类型,你可以用任意名字,比如 T 或 S (只要符合明明规范)来暂代这个类型名,这个占位符,我们可以称之为“范型变量”。而范型的实例化,则是指将某个类型赋值范型变量,类似于 T = Any 这样。使用处协变,就是指在类型名前使用 out 关键字,声明处协变,就是指在范型变量名前使用 out 关键字。

因此 out 不仅仅可以修饰范型变量, 也可以修饰某个类型。

此外,使用处协变仍然受到和声明时协变一样的限制,即可以读不能写,比如:

for (i in from.indices) 
	to[i] = from[i]
	from[i] = i // 编译错误

编译器将在 from[i] 处报错,因为 from 中的元素是 类型,你不能向< out Any> 进行写入操作。但是对 to[i] 进行写入操作就没事:

to[i] = from[i]

因为 to 中的元素是 类型,没有 out 的限制。

本质上讲,使用处协变仍然是通过类型约束来保证范型的安全性。

使用处逆变

类型投影还包括另外一种情况,即使用处逆变。

fun setValue(to:Array<String>, index: Int, value:String)
	to[index] = value

fun main(args:Array<String>)
  val array:Array<Any> = Array<Any>(4, it->"hello"+it)
  for(item in array)
    println(item) // "hello0","hello1","hello2","hello3"
  
  setValue(array, 1, "world") // 编译错误

此时 setValue(array, 1, “world”) 一句报错,依然是 array 类型不匹配,需要 Array,但提供了 Array。解决的办法是使用使用处逆变,在 setValue 方法第一个参数处使用 in:

fun setValue(to:Array<in String>, index: Int, value: String)

道理和使用处协变是一样的。限制仍然是允许对 to 进行写入操作,但不能读取。

星投影

星投影其实指的是<*>。在范型协变中,<*>可以用于表示,在范型逆变 中,<*> 可以用于表示 。Nothing 在 kotlin 中表示这个类型没有任何实例,因此不能向其中写入任何值。

class Star<out T> 

class Star2<in T> 
	fun setValue(t:T)
	

fun main(args: Array<String>) 
	var star: Star<Number> = Star<Int>()
	var star2: Star<*> = star			// 1
	
	var star3: Star2<Int> = Star2<Number>()
	var star4: Star2<*> = star3			// 2
	
	star4.setValue(3) 			// 3 编译错误

  1. star2 的类型为 Star<*> 表示范型 T 的类型不确定,但将 star 赋值给它后,T 可以依靠类型推断。因为 val star: Star,T 的上界(Tupper)就是 Number,因此将 star 赋值给 star2 之后,Star<*>就相当于 Star。
  2. star4 的类型为 Star2<*> 表示范型 T 的类型不确定,依靠 star3 的类型推断。因为 var star3: Star2 ,因此 Star2<*>就相当于 ,类型未知。
  3. 因为 不能写入任何类型,因此这句报错。但是将 star4 改成 star3 之后,就可以了。

如果对于不变的范型(没有 in 也没有 out 修饰),星投影<*>又会怎么样呢?看一个例子:

class Star3<T>(private var t:T) 
	fun setValue(t:T)
	fun getValue():T return this.t 

fun main(args:Array<String>)
  var star5: Star3<String> = Star3<String>("hell0")
  var star6: Star3<*> = star5
  star6.getValue()
  star6.setValue("whatever") // 编译错误

star6.setValue("whatever")一句编译器报错,这是因为:

如果在一个不变范型上使用星投影,那么它等于是+。

具体到 star6 上来说,相当于 和 ,自然是不能写到。

再来看一个例子:

val list:MutableList<*> = mutableListOf("1","2","3")
list[0] = "4" // 编译错误

道理是同样的。

@UnsafeVariance

正常情况下,以下代码中,setValue 方法是不能通过编译的:

class MyClass<out T>(private var t:T) 
	fun getValue(): T return this.t
	fun setValue(t: T)  this.t = T

因为 T 是 out, 不能用于方法参数,但通过 @UnsafeVariance 注解,可以突破编译器的这种限制:

fun setValue(t: T)  this.t = T

这是因为范型擦除的缘故。

范型擦除

何谓范型擦除,看以下代码:

var myClass1: MyClass<Int> = MyClass(5)
var myClass2: MyClass<Any> = myClass1
println(myClass2.getValue())
myClass2.setValue("hello")
println(myClass2.getVlaue) 

myClass1 的类型是MyClass,它只能存放 Int,但是当我们将它赋值给一个 MyClass 之后,就可以向其中放任何东西了。这是因为从字节码的层级,范型的真实类型其实被丢失了(范型擦除),不管什么类型对它来说统统都是 Object。

范型方法

不管类是不是范型的,它的函数都可以使用范型:

fun <T> getValue(item: T): T 
	return item

getValue 函数时一个顶层函数,调用时可以这样调用它:

val item = getValue<Int>(100) // 根据类型推断,可以简写成:getValue(100) 
println(item)

范型约束

class MyClass<T: List<T>>  // 范型约束:T 只能是 List 或 List 的子类 

如果不指定范型约束,那么就是 Any?。

多个约束通过 where 关键字定义(同 swift):

class MyClass<T> where T:Comparable<T>, T: Any 

以上是关于深入kotlin - 范型的主要内容,如果未能解决你的问题,请参考以下文章

深入kotlin - 范型

父类如何获取子类的范型

深入kotlin - KClass 特性深入研究

深入kotlin - KClass 特性深入研究

深入kotlin - KClass 特性深入研究

深入kotlin - 与Java互操作:kotlin调用java