本文主要介绍 Kotlin
范型的相关用法。
泛型的本质是参数化类型,操作的数据类型被指定为一个参数。使用范型约束:
增加代码的复用性,有时我们使用一些公用的数据结构,方法,类等,只是操作的对象类型不一样,但是代码逻辑一样,此时可以使用范型复用代码,比如 List<T>
,就可以用来存储任何一种对象。
保证代码中类型转换的安全性,使用范型进行约束,能够保证在编译期对类型的匹配进行检测和转换,避免运行期出现类型转换异常,而且这些转换都是自动和隐式的。
简单实现
下面在 Java
和 Kotlin
中实现了最简单的范型的用法,用法相似,但是 Kotlin
就更加简单。当类型可以通过推断得到时,不必显式声明类型。
1 | // java |
1 | // kotlin |
Java 中的范型
范型类型是不可变的,因此 List<Child>
并不是 List<Parent>
的子类型
1 | // List<String> 不是 List<Object> 的子类型 |
为什么是不可变的呢?我们看一段伪代码,假如类型是可变的,那么 List<String>
就是 List<Object>
的子类。
1 | List<Object> objects = new ArrayList<>(); |
由于范型类型的不可变,尽管 String
是 CharSequence
的子类型,但是我们甚至不能简单的做到如下操作:
1 | class Box<T> { |
正因为如此,才有了 通配符类型参数 ,我们应该如下声明 resetBox()
的方法
1 | class Box<T> { |
通配符类型参数
我们参照 List
类,来看一个简单的例子
1 | List<? extends String> extendsTStringList = new ArrayList<>(); |
这里的 通配符类型参数 ? extends T
表示,集合元素的类型是 T
的某种子类型, 而不限于 T
本身,这就意味着,我们可以安全地从集合元素中 读取 T
(因为集合的元素是 T
的某个子类型的实例),但 不能写入 到集合中去,因为我们不知道什么样的对象实例才能与这个 T 的未知子类型匹配。指定了 extends
边界 (上边界)的通配符类型, 使得我们的类型成为一种 协变(covariant)
类型。
同理,我们可以指定类型的下边界:
1 | List<? super String> superTStringList = new ArrayList<>(); |
通配符类型参数 ? super T
表示,集合元素的类型是 T
的父类型,而不限于 T
本身,我们可以向集合中添加元素,因为集合中的元素一定是 T
的父类型,但我们不能从集合中读取元素,或者说只能读取为 object
类型的元素,因为我们不知道什么样的对象才能与 T
的未知父类型匹配。指定了 super
边界 (下边界)的通配符类型, 使得我们的类型成为一种 逆变(contravariance)
。
协变;子类取代父类的位置是被允许的,也就是需要父类型时可以使用子类型代替
逆变;父类取代子类的位置是被允许的,也就是需要子类型时可以使用父类型代替
不变;不允许改变类型。
对于只能读取的对象,称为 生产者,因为它可以产出对象,对于只能写入的对象称为 消费者,因为它可以消费对象,生产者对应 extends
,消费者对应 super
。
声明处类型变异
我们有如下 Box
类,它只有读取方法,也就是只能生产 T
类型对象,不存在消费者方法的调用,因此我们完全可以使用 Box<Parent>
存储 Box<Child>
的数据,这是安全的,但是在 java
中不能理解这一点。
1 | class Box<T> { |
为了解决上述问题,我们必须进行如下声明才能消除错误,但是使用了更加复杂的声明与上面所能调用的方法是一样的,但是编译器并不能理解这一点。
1 | public static void test(Box<String> stringBox) { |
在 Kotlin
中我们使用在范型声明处使用注解标注的方式将这种情况告诉编译器,由于这种注解出现在声明处,因此称为 声明处类型变异(declaration-site variance) ,这种方案与 Java
中的 使用处类型变异(use-site variance) 刚好相反, 在 Java
中, 是类型使用处的通配符产生了类型的协变。有两种注解修饰符:
out
被称为 协变注解(variance annotation),他表示此类型只能被生产,不能被消费,也就是只能出现在返回类型中,此时Box<Parent>
可以安全的用作Box<Child>
的父类型。
in
被称为 逆变注解(contravariant annotation),他表示此类型只能被消费,不能被生产,也就是只能出现在参数类型中,此时可以用Box<Child>
安全的接受Box<Parent>
1 | interface Box1<out T>{ |
那么此时我们就可以解决上面的复杂声明的问题
1 | fun test(box:Box1<String>){ |
同时看一下官网 Comparable
使用逆变注解的例子
1 | abstract class Comparable<in T> { |
类型投射
我们有时并不能保证范型类型只作为返回值出现,或只作为参数出现,我们通常会有更复杂的需求:
1 | class Box3<T> { |
如果我们写一个 copy
函数
1 | fun copyBox(to: Box3<Any>, from: Box3<Any>) { |
由于范型参数类型的不变性,就是我们最初在 Java
中遇到的问题,下面的调用将无法通过编译,因为 Box<String>
并不是 Box<Any>
的子类,这种操作是为了避免向 from
中进行写入操作,比如 Int
类型,导致 ClassCastException
。
1 | val from = Box3<String>() |
我们需要使用 out
注解,说明我们在 copy
函数中对 from
不会进行写入操作。
1 | fun copyBox(to: Box3<Any>, from: Box3<out Any>) { |
同理我们可以使用 in
注解来声明我们不会对对象进行读取操作,如下
1 | fun fill(dest: Box3<String>, value: String) { |
这种声明在 Kotlin
中称为 类型投射(type projection),他用来声明该类型不是一个简单的类型,而是一个被限制的类型,我们只能对该类型进行读取或写入操作,从而保证数据的安全性,这是 Kotlin
使用处类型变异 的实现方式,与 Java
中的通配符相似,但是更简单。
星号投射
星号投射(Star-projection)
有时可能想表示你并不知道类型参数的任何信息,但是仍然希望能够安全地使用它。这里所谓”安全地使用”是指,对泛型类型定义一个类型投射,要求这个泛型类型的所有的实体实例,都是这个投射的子类型。
星号投射与 Java
的原生类型(raw type
)非常类似,但可以安全使用。
下面是官方的说法:
1 | 对于 Foo<out T>,其中 T 是一个具有上界 TUpper 的协变类型参数,Foo<*> 等价于 Foo<out TUpper>。 这意味着当 T 未知时,你可以安全地从 Foo<*> 读取 TUpper 的值。 |
在什么时候可以使用 星号投射,什么时候不可以
1 | // error 不允许作为变量的的范型参数 |
Raw
类型就是对于定义时有泛型参数要求,但在使用时指定泛型参数的情况,这个只在 Java
中有,显然也是为了前向兼容。在 Java
中我们可以如下声明
1 | List list = new ArrayList(); |
这样的声明在 Kotlin
中是不允许的,但是可以使用
1 | val list = ArrayList<Any?>() |
在 Java
中可以有这样的写法,会引发异常
1 | List<Integer> integers = new ArrayList<>(); |
在 Kotlin
中这样的写法是错误的
1 | var list = ArrayList<Any?>() |
范型函数
函数中也可以使用范型参数类型
1 | fun <T> test(t:T):List<T>{ |
范型约束
约束范型的上界
如下范型参数 T
必须是 Box<T>
的子类
如果有多个上限,则需要使用 where
字句
如果上限的对象带有范型参数的声明处类型编译注解,则当前声明也需要同步
1 | class Box4<T : Box<T>> { |
在函数中约束范型上界
1 | fun <T : Box<T>>test1(t:T){ |