Kotlin 泛型
类型擦除
kotlin伪泛型
与Java一样,kotlin的泛型也是编译时期的泛型,在运行时期的时候泛型会被擦除掉,这种泛型可以理解为伪泛型,与之相对,真泛型即在运行期还存在泛型类型。可以通过以下两个例子来验证kotlin类型擦除:
1 | // demo 1 |
上面demo 1代码中定义了两个泛型变量,分别是
ArrayList<String>
和ArrayList<Int>
,并通过获取javaClass对比,结果相等,说明在运行时泛型被擦除了,只剩下了原始类型
1 | // demo 2 |
demo 2中定义了一个泛型类为
MutableList<Int>
的变量c
,变量c
的泛型为<Int>
,但是通过反射能够添加<String>
类型的值,说明运行过程中泛型被擦除,没有了泛型约束
通过代码反编译也很容易观察到类型擦除:
1 | // Kotlin 代码 |
从反编译的代码中可以观察到泛型<T>
被擦除掉了,在实际运行过程中的类型是Object
reified inline
上述的代码可以证实Java(kotlin)确实存在类型擦除,那么为什么会存在类型擦除呢,这是因为在Java1.5版本之前没有泛型,泛型是在Java1.5版本引入的,因而为了兼容1.5版本及1.5之前的版本,Java选择伪泛型,否则Java需要就需要修改整个底层
为了版本兼容选择类型擦除无可厚非,但是类型擦除同时也带来了一些限制:
1 | fun <T> testGeneric(t: T){ |
这段代码无法编译通过,泛型T
只是一个泛型参数,无法获取Class
信息,同样这也是伪泛型的体现;因为无法通过泛型参数获取Class
,因此有时候需要另外传递一个Class
参数,比如Gson框架中的fromJson方法:
1 | public <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException { |
传递额外参数可以满足需要类对象的场景,kotlin还有另外更彻底的实现方式,通过关键字reified
和inline
的结合使用,把函数定义成内联函数,同时可以将函数中的泛型参数当成真实参数使用:
1 | // reified inline 使用 |
Kotlin型变
Java协变和逆变
假设有两个类Father
和Son
,Son
是Father
的子类,但是包含这两个泛型的泛型类之间没有任何关系,例如:List<Father>
和List<Son>
之间没有任何关系,这也意味着以下的代码是走不通的:
1 | class Father{} |
这段代码提示类型不匹配的,Family<Son>
类型对象并不能作为Family<Father>
类型参数传递。即使Son
和Father
之间存在继承关系,但是Family<Son>
和Family<Father>
之间并没有半毛钱关系;可是在实际开发中有时候会有这样的需求,希望带泛型类型的参数也能够实现多态传递,这个时候就需要借助通配符?
,使用? extends
或? super
。? extends
是上界通配符,能够使Java泛型具有协变性,? super
是下界通配符,能够使Java泛型就有逆变性。
在继承关系树中,子类继承自父类,可以认为父类在上,子类在下。
extends
限制了泛型类型的父类型,所以叫上界。与上界通配符对应,这里 super 限制了通配符 ? 的子类型,所以称之为下界。
1 | class Father{} |
通过使用
? extends
,允许传入参数可以是泛型类型参数Father
的子类型的任意类型,这种用法就是协变;与之相反,使用? super
,就是允许传入参数可以是泛型类型参数Son的父类型的任意类型,这个用法就是逆变。
与Java相似,Java通过? extends
和? super
来实现Java泛型的协变和逆变,Kotlin则通过泛型关键字out
和in
关键字来定义协变和逆变;或者可以说out
等同于? extends
,in
等同于? super
协变
out
:泛型关键字,实现kotlin泛型协变,out关键字的使用可以是在泛型类定义中,也可以是在带泛型类型参数的函数中
out
关键字的使用
1 | open class Father{} |
对于协变来说,假设类型A和类型B存在继承关系,且类型A是类型B的子类,那么通过协变
Family<A>
是Family<B>
的子类型,这个是协变的一个特点
另外一个角度理解协变
通过Java的型变分析,泛型型变的出现是为了使得泛型使用更加的灵活,kotlin也是一样;但是同时也需要对型变做一些限制,对于协变来说,规定类中的参数被声明为协变,那么就限制这个参数在该类中的使用:out关键字声明的泛型参数只能出现在out位置,out位置如下图:
“out”
位置是声明函数返回值类型的位置,其主要是函数运行结束时产生指定的泛型对象,其扮演的是生产者的角色,所以用“out”
声明很形象。那么,声明为协变的泛型为什么一定要限制在out
位置呢?这是出于泛型安全考虑:假设有垃圾桶,垃圾桶分别用来装可回收垃圾、有害垃圾
1 | open class Waste |
RecyclableWaste
类是Waste
类的子类,由于泛型类Ashbin<out T>
用关键字out
声明泛型参数为协变,泛型类Ashbin<out T>
子类型化得以保留;
1 | /** |
假设泛型类Ashbin<out T>
中的put(t : T)
函数成立,那么根据以上代码,就会出现这样一种情况:main()
函数中声明了一个用来装可回收垃圾的垃圾桶,并将其作为参数传递给wast(ashbin: Ashbin<Waste>)
函数,但是却在该函数中投放如有害垃圾;显而易见,在可回收垃圾桶中投放有害垃圾是一种错误的做法,不符合垃圾分类的原则。由此可见,在声明为协变的泛型类中,泛型出现在了函数参数的位置上会导致泛型安全问题,这是限制协变声明的泛型类的泛型参数只能出现在"out"
位置原因,这一限制在Java泛型中同样也适用:
1 | static class Waster{} |
协变总结
对泛型类泛型参数协变,即用out
关键字声明,那么:
- 子类型化会被保留(Family
是Family 的子类型) - 泛型类型(T)必须只能用在
"out"
位置上
逆变
逆变与协变不同,甚至可以说是相反,主要体现在子类型化关系上。假设有子类Dog
和父类Animal
,通过逆变,则Zoom<Animal>
是Zoom<Dog>
的子类型;可以看出不同类型的关系和声明逆变后泛型类之间的关系是截然相反的,这也是逆变的特点。
逆变是通过关键字in
来声明的:
1 | open class Animal |
和协变一样,对于逆变的使用同样也需要限制:对声明为逆变的泛型类,其泛型类型参数在泛型类内部只能被消费,而不能作为生产者,即泛型参数只能出现在in
位置而不能出现在out
位置,如下图:
同样,可以使用垃圾和垃圾桶的关系来解释下为什么逆变的泛型参数只能被放置在"in"
位置:
1 | open class Waste |
对于逆变,泛型参数作为消费使用的时候,传入的参数一定是该泛型T
的子类,能够保证泛型使用的安全;上述代码中定义了一个函数putWaste
,需要传递的参数是RefuseBin<RecyclableWaste>
,在函数中声明一个可回收垃圾,然后放入垃圾桶中,从逻辑上看,可回收垃圾桶放入可回收垃圾,没毛病;由于泛型参数被声明逆变,RefuseBin<Waste>
是 RefuseBin<RecyclableWaste>
的子类型,那么在main
函数中传递给 putWaste
的参数是RefuseBin<Waste>
是合理的,RefuseBin<Waste>
是一个可以装任意垃圾的垃圾桶,垃圾桶装可回收垃圾,也没有毛病,666。因此泛型参数用在in
位置是合法的。
1 | open class Waste |
假设在逆变声明的RefuseBin<in T>
中的pour函数是合法的,定义两个函数:pourRecyclableWaste
函数中传递的参数是RefuseBin<RecyclableWaste>
,在pourRecyclableWaste
函数中返回从可回收垃圾桶中获取的可回收垃圾,但是在main
函数中传递给pourRecyclableWaste
的是可以装任意垃圾的垃圾桶(RefuseBin<Waste>
),这样就会导致在pourRecyclableWaste
获取到的就有可能不是可回收垃圾,因为普通垃圾桶可能倒出有害垃圾或者其他垃圾,这就出现了泛型类安全问题。所以逆变泛型类中泛型参数不能作为生产者角色出现(即不能出现在out
位置)。
逆变总结
对泛型类泛型参数逆变,即用in
关键字声明,那么:
- 子类型化关系会被逆转(Family
是Family 的子类型) - 泛型类型(T)必须只能用在
"out"
位置上
不变和点变形
不变,顾名思义就是既不协变也不逆变,就是普通的泛型类,因此泛型参数即可以放在in
位置也可以用到out
位置,比如kotlin自带的MutableList<E>
1 | public interface MutableList<E> : List<E>, MutableCollection<E> { |
那么,问题来了,泛型不变是能够使泛型参数同时出现在in
位置也可以出现在out
位置,但是却失去了协变和逆变的特性,代码的复用性就很差,这不是一朝回到解放前么;能不能同时保留协变和逆变呢?是可以的,通过点变形来实现,如下代码:
1 | open class Waste |
如上所示,我们可以把泛型类声明为不型变的,但是在使用它的时候,加上out
或者in
,让它在使用的时候产生型变,通过这种方式,代码更加灵活,满足了既需要泛型参数作为函数参数类型又需要泛型参数作为函数返回值类型的类。但是同时也存在限制:如上代码,在pourWasteWithOut
函数中不能够调用TrashCan<T>
类的put
函数;在putWasteWithIn
函数中不能够调用TrashCan<T>
类中的pour
函数;具体原因如同上面的协变逆变分析。
星型投影 * 和 泛型边界
星型投影*
星型投影,其实就是:假如你对于你现在泛型要传入的泛型参数不确定或者是无所谓的时候,为了确保泛型类能够正确的编译运行,可以借助星型投影*
,而且该泛型类型的每个具体实例化将是该投影的子类型,kotlin
中星型投影*
的使用类似于java中?
的使用:
1 | public static void main(String[] args){ |
对应kotlin的代码:
1 | // kotlin星型投影 |
Kotlin 可以根据 * 所指代的泛型参数进行相应的映射,下面是官方的说法:
- 对于
Foo <out T : TUpper>
,其中T
是一个具有上界TUpper
的协变类型参数,Foo <*>
等价于Foo <out TUpper>
。 这意味着当T
未知时,你可以安全地从Foo <*>
读取TUpper
的值。 - 对于
Foo <in T>
,其中T
是一个逆变类型参数,Foo <*>
等价于Foo <in Nothing>
。 这意味着当T
未知时,没有什么可以以安全的方式写入Foo <*>
。 - 对于
Foo <T : TUpper>
,其中T
是一个具有上界TUpper
的不型变类型参数,Foo<*>
对于读取值时等价于Foo<out TUpper>
而对于写值时等价于Foo<in Nothing>
。
如果泛型类型具有多个类型参数,则每个类型参数都可以单独投影。
例如,如果类型被声明为 interface Function <in T, out U>
,我们可以想象以下星投影:
Function<*, String>
表示Function<in Nothing, String>
;Function<Int, *>
表示Function<Int, out Any?>
;Function<*, *>
表示Function<in Nothing, out Any?>
。
泛型边界
在 Java 中,我们可以用 extends 关键字为泛型参数指定上限,如下:
1 | // java |
而对应的kotlin写法类似:
1 | interface Bound{} |
如果同一类型参数需要多个上界,在Java中的写法是这样的:
1 | interface Bound{} |
在kotlin中同一类型参数需要多个上界与Java的写法有很大区别,kotlin的写法中多了关键字where
,如下:
1 | interface Bound{} |
总结
kotlin泛型中比较难理解的是协变和逆变,感觉学习起来会比较的抽象,但是协变和逆变在Java中就有所体现,只不过是在kotlin中的使用方式与Java不同,kotlin中使用变得更加的简便;同样对于泛型边界来说,虽然多了一个关键字where
,但是内容确实与Java完全一致,没有新增任何东西;总的来说,kotlin泛型的内容是与Java泛型一致的,只是在写法上有区分而已,要学习kotlin的泛型,可以先将Java泛型完全掌握,只要掌握了Java泛型,kotlin泛型学习起来就十分的简单,无非就是换个写法而已。