Java和Kotlin泛型笔记
在日常编程中, 我们经常会用到泛型, 用的时候感觉并不复杂, 然而最近在做Kotlin开发时, 被其中的逆变和协变搞得头大, 才发现自己对泛型的了解并不深, 因此系统地整理相关的知识, 希望能帮到遇到同样问题的你.
#0x00: 什么是泛型
在Java中
Java 泛型(generics)是 JDK 5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。
在这个定义中, 我们需要注意:
0x01. 泛型是JDK5(2004年)才引入的
这意味着, Java中的泛型需要考虑向前兼容的问题, 因此在Java中允许忽略类型参数.
// 忽略类型参数仍可以通过编译, 运行时也不会报错
List list = new List();
list.add("string1")
在Kotlin中, 泛型的定义和作用和Java是一致的. 不过Kotlin没有历史包袱, 所以在Kotlin中, 使用泛型时, 类型参数是必须的. 不过这里说的"必须", 是指编译器必须知道类型参数, 而Kotlin可以进行类型推导, 所以在类型参数已经确定的场景下, 你仍可以省略类型参数.
val list: List<String> = ArrayList<String>() // 完整的写法
val list: List<String> = ArrayList() // 已知类型是String, 所以ArrayList可以忽略类型参数
val list = ArrayList<String>() // 类型未知, 不能忽略类型参数
val list = ArrayList() // !!不能通过编译
0x02. 泛型提供了类型安全监测机制
这说明
泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。
换句话说, 泛型是用来给编译器提供额外的类型信息, 以此来确保你在写代码的时候不要犯傻的.
0x03. 它作用在编译期
这意味着, 泛型并不能在运行时(Runtime)提供额外的类型信息. 也就是我们经常可以看见的"类型擦除", 简单地说就是在运行时, JVM并不能看到泛型提供的类型信息. 例如List<String>
或者List<Integer>
在编译后都会变成List
, String
和Integer
都会变成Object
了, 所以说类型被擦除了.
类型擦除的一个副作用是我们不能检查泛型的类型, 不能T instanceOf String
或者T is String
#0x10: 为什么需要泛型
先想想, 如果没有泛型, 我们实现针对String
和Integer
的Collection
功能要怎么办? 一般有两种方案.
- 提供一个处理
Object
的ObjectCollection
. 使用这种方法, 因为类型是Object
, 所以你不能确定容器中的实际类型是String
还是Integer
, 使用过程你需要反复进行类型转换, 这很容易因为使用的错误的类型导致崩溃. - 分别提供
StringCollection
和IntegerCollection
. 这样我们可以确定使用的类型, 避免方法1的问题, 不过我们可以猜到, 这两个类之间肯定有很多重复的方法, 太不简洁了, 而且当需要的类型变多时, 类的数量也会变多, 这会让我们很难受.
那么, 如果可以在不能确定类型和完全确定类型之间折中处理, 让我们既可以在写代码的时候避免因为不知道类型而犯错, 又可以模糊类型信息, 达到复用代码的目的, 这样就完美了. 这也就是泛型要解决的问题.
折中处理类型信息
#0x20: 泛型的实现
其实在上面定义部分, 已经说明了泛型实现上述目标的方法.
- 提供额外的类型信息给编译器, 在写代码的过程中, 编译器能对泛型进行类型限制和类型转换, 防止我们犯错和省去类型转换. 相当于在编译期, 泛型处于类型确定的形态.
-
编译后则会进行类型擦除, 相当于在运行期, 泛型退化到类型不确定的形态, 此时可以处理任意类型, 以此来复用代码.
泛型的变化
#0x30 泛型的问题
目前看起来很完美, 不过事情往往没有那么简单.
引入泛型后, 我们得到了一些好处, 不过也引入了新的问题需要处理.
0x31. 类型忽略问题
泛型给我们提供的额外的类型信息在大部分时候都是好事, 不过在使用泛型时, 在有些场景中, 我们可能并不关心所使用的泛型的具体类型参数, 甚至可能不知道类型信息.
例如我们现在要实现针对Collection
的Comparator
接口, 假设我们只想比较容器的大小, 此时, Collection
中的类型是什么, 我们是不关心的.
当然我们可以直接把Object
当作类型参数, 不过在语义上会产生混淆, 你是希望这是一个Collection<Object>
还是你不知道它的类型参数?
为了解决这个问题, Java提供了类型通配符(?
), 以上例子可以这样写
public class CollectionComparator implements Comparator<Collection<?>> {
@Override
public int compare(Collection<?> c1, Collection<?> c2) {
return c1.size() - c2.size();
}
}
而Kotlin提供了星投影(*
)解决这个问题, 相同的功能用Kotlin可以这样写
class CollectionComparator : Comparator<Collection<*>> {
override fun compare(c1: Collection<*>, c2: Collection<*>): Int {
return c1.size - c2.size
}
}
不过, 如果忽略了类型参数, 那么编译器就不知道具体的类型了, 所以你就不能对泛型中的类型参数进行写操作了, 例如
// java
List<?> list = new ArrayList<String>();
list.add("string1"); // 类型不匹配, 编译错误
你仍可以读取泛型中的类型参数, 但是类型信息会丢失, Java中得到的就是Object
类型, Kotlin中为Any?
, 例如
// java
List<?> list = new ArrayList<String>()
Object e1 = list.get(); // 返回Object类型
0x32. 类型限制问题
我们在声明泛型的时候, 是不知道具体的类型的, 这导致我们在泛型内部没有办法使用类型的方法. 为了可以在内部把参数类型当作特定类型来使用, 我们需要告诉编译器, 这个参数类型的一些限制条件.
在Java中, 通过引入限定通配符来限制参数类型. 具体有上界限定通配符? extends E
和下界限定通配符? super E
来实现.
对于上界限定通配符, 可以实现调用该上界类型的方法. 例如以下例子
public class CharSequenceComparator<E extends CharSequence> implements Comparator<E> {
@Override
public int compare(E o1, E o2) {
// 编译器会把E类型看作CharSequence, 所以可以调用CharSequence的方法
return o1.length() - o2.length();
}
}
而下界限定通配符, 则有些特别. 在子类型关系问题中我们再分析下它的作用.
我个人的理解: 下界限定通配符是单纯为了解决子类型关系问题的, 而上界限定通配符则兼有类型限制和子类型关系这两个目的, 这显得Java的泛型有些混乱. 通配符类型也是Java类型系统中最棘手的部分之一.
相比之下, Kotlin则只有一种类型限制方法, 为E : String
, 对应Java上界限定通配符, 作用也一样. 不再赘述.
0x33. 子类型关系问题
到目前为止, 所说到的点都很好理解.
而泛型中最难梳理清楚的, 我个人觉得是由泛型引入的子类型关系问题. 也就是说List<Object>
和List<CharSequence>
和ArrayList<String>
之间是什么关系?
1. 子类型和子类
深入这个问题前, 我们先看看, 子类型和子类的区别.
A是B的子类型, 表明A实例可以赋值给B类型的引用.
// A是B的子类型
A a = new A();
B b = a;
A是B的子类, 表明A类继承了B类.
class A extends B
在通常情况下, 它们表达了同样的意思, 不过当引入了泛型后, 情况就有所不同了.
List<Object>
和List<String>
, 他们的原始类型都是List
类, 而String
是Object
的子类(同时也是子类型), 而且根据直觉, 可以放进List<String>
的实例, 也可以放进List<Object>
中, 然而, 实际情况是, List<String>
不是List<Object>
的子类型, 所以以下代码不能编译.
List<String> stringList = new ArrayList<>();
List<Object> objectList = stringList;// !!类型错误, 不能编译
这是因为在Java中, 泛型是不型变的.
2. 不型变, 协变和逆变
要梳理子类型关系, 我们还要先理解不型变, 协变和逆变这3个概念.
从上面可以知道
虽然String
是Object
的子类型, 但是List<String>
不是List<Object>
的子类型, 那么可以称之为不型变.
如果通过某个方法, 让List<String>
是List<Object>
的子类型, 那么称之为协变. 协变保留了子类型化关系.
如果通过某个方法, 让List<Object>
是List<String>
的子类型, 那么称之为逆变. 逆变反转了子类型化关系.
可能你开始有点头痛, 不过这只是一些概念, 我们需要记住的是, 这些概念的目的只有一个
保证编译期的类型安全
这也是泛型的主要目的之一. 后面我们再具体说下如何保证类型安全.
关于型变, 在Java中
实现协变的方式为限定上界通配符, 即List<? extends String>
是List<Object>
的子类型.
实现逆变的方式为限定下界通配符, 即List<Object>
是List<? super String>
的子类型.
结合上面提及的类型限制问题, 可以看到extends
的作用并不单一, 其实本质上的作用是一样的(确保类型安全的一种手段), 但是并不那么显而易见, 这导致理解Java泛型的时候略显复杂.
因此在Kotlin, 引入了类型投影的概念, 使用in
和out
修饰符
实现协变使用out
关键字, 即List<String>
是List<out Any>
的子类型.
实现逆变使用in
关键字, 即List<Any>
是List<in String>
的子类型.
3. 消费者和生产者, 以及类型安全
对于协变和逆变, 大神有如下的解释
Joshua Bloch 称那些你只能从中读取的对象为生产者,并称那些你只能写入的对象为消费者。
在Java中, 有以下助记符
PECS 代表生产者-Extends、消费者-Super(Producer-Extends, Consumer-Super)。
而在Kotlin, 则有
消费者 in, 生产者 out!
实际我是越看越懵, 所以我自己会从类型安全的角度去看协变和逆变.
对于泛型的类型参数, 只有两种用法;
第一类, 赋值给类型参数, 例如
// 方法入参, 调用方法时会赋值给element变量
public void add(E element) {
// 忽略...
}
第二类, 获取类型参数对应的实例, 例如
// 方法返回值
public E get(int index) {
// 忽略...
}
逆变(super
/in
)可以确保赋值给类型参数时类型安全
先看如下Java例子
List<CharSequence> charSequenceListList = new ArrayList<>();
List<? super String> stringList = charSequenceListList;
stringList.add("string1"); // 赋值给类型参数, 类型安全, 可以编译通过
String c = stringList.get(0); // 获取类型参数对应的实例, 不能编译通过
Object obj = stringList.get(0); // Object是所有类的超类, 可以编译通过
首先, 根据逆变, List<CharSequence>
是List<? super String>
的子类型, 对于List<? super String>
, 表示元素是String
的超类, 所以我们可以安全地把string1
字符串赋值给元素. 但是我们调用List<? super String>#get
时, 并不能确定返回的元素的类型, 因为它可能是String
的任意一个超类, 把它当做String
或者CharSequence
都是类型不安全的, 所以只能认为它是一个Object
.
对于Kotlin, 实际上本质是一样的, 只是in
修饰符更加直接, List<in String>
表示可以传String
, 所以add
方法可以传字符串, 但就不能取String
了, 因此get
方法只能取得一个Any?
.
协变(extends
/out
)可以确保取出类型参数对应的实例时类型安全
协变和逆变行为是相反的, 所以也很容易类推.
List<String> stringList = new ArrayList<>();
List<? extends CharSequence> charSequenceListList = stringList;
charSequenceListList.add(""); // 类型不安全, 编译错误
CharSequence c = charSequenceListList.get(0); // 类型安全, 正常编译
对于List<? extends CharSequence>
, 表示元素是CharSequence
的子类型, 该对象实际是一个ArrayList<String>
, 由此可见, 我们可以安全地把取出的元素(String
)赋值给一个CharSequence
. 但是我们调用List<? extends String>#add
时, 并不能确定List
保存的实际类型, 因为可能是CharSequence
的任意一个子类型, 所以把任何实例放进List
中都是类型不安全的, 因此会编译错误.
对于Kotlin, 使用out
修饰符实现协变, List<out CharSequence>
表示输出值是CharSequence
, 所以get
方法会返回CharSequence
, 但就不能使用add
进行赋值了.
以上两段是理解反省中, 子类型问题的关键, 而且有点绕, 需要反复琢磨.
#0x40 Kotlin中的泛型
Kotlin作为后来者, 针对Java做得不太好的地方做了写优化, 有些前面已经提到, 这里再次总结下.
1. 专门的上界限制语法
class CharSequenceList<T : CharSequence> : ArrayList<T>()
2. 专门的型变修饰符in
, out
val inList: MutableList<in String> = ArrayList<Any>()// 逆变
val outList: MutableList<out Any> = ArrayList<String>() // 协变
根据官网的说法, 这两个关键字是参考C#
我们相信 in 和 out 两词是自解释的(因为它们已经在 C# 中成功使用很长时间了)
3. 专门忽略参数类型的星投影*
val list: List<*> = ArrayList<String>()
4. 声明处型变
引用Kotlin官网的例子
// Java
interface Source<T> {
T nextT();
}
// Java
void demo(Source<String> strs) {
Source<Object> objects = strs; // !!!在 Java 中不允许
}
对于Source
类, 它不存在输入T
参数的方法, 所以它天然是输出安全(协变)的, 也就是说把Source<String>
当作Source<Object>
的子类型是安全的, 但是Java编译器并不知道这点, 并且正常地禁止这样的直接赋值, 而需要明确使用Source<? extends Object>
来表明协变, 在这种情况下, 这个extends
的声明是多余的, 单纯是为了满足编译器.
为了优化这种情况, Kotlin引入了声明处型变来向编译器解释这种情况, 在声明类时即表明类型参数T
仅会被返回.
interface Source<out T> {
fun nextT(): T
}
fun demo(strs: Source<String>) {
val objects: Source<Any> = strs // 这个没问题,因为 T 是一个 out-参数
}
4. 使用点型变
使用点型变是相对声明点型变而言, 实际上Java中所有型变都属于使用点型变, 因此和Java中对应的场景也就都属于使用点型变.
但是需要注意的是
在Java声明类的时候也可以使用
super
和extends
关键字, 如
public class CharSequenceComparator<E extends CharSequence> implements Comparator<E>
但此时应该把它们看作是限制泛型的类型, 而不是为了型变
这里的特殊说明, 也证明了Java在泛型的设计上确实有问题.
因为普通的泛型类都是不变型的, 包括Java中定义的所有泛型类, Kotlin中定义时没有使用in
和out
的类, 但是在实际使用时, 在某一个方法内, 它们可能只会发挥一种作用(消费者/生产者), 所以此时, 使用点变型能够放宽可接受的类型的范围(协变和逆变确定的子类型关系), 让代码更加通用.
5. 类型投影
fun copy(from: Array<out Any>, to: Array<Any>) { …… }
在上述的方法中, from
参数的类型是Array<out Any>
, 这个声明阻止了copy
方法写数据进from
数组中, 这称作类型投影, from
参数是一个投影的数组, 实际上就是一个受到限制的数组, 它不能写入数据.
6. inline
函数中泛型的类型参数具体化(reified
)
前面已经提到过, 类型参数是可擦除的, 所以我们不能调用T is String
这样的判断, 但是在inline
函数中, 情况有点不同, 因为inline
函数本质上是复制的代码段, 也就是说, 它实际上是可以知道上下文中的对象的类型的, 因此Kotlin中使用reified
关键字来处理这种情况.
如下
inline fun <reified T> Any.asType(): T? = if (this is T)
this
else
null
// 使用
val obj: Any = "abc"
val s = obj.asType<String>() // s的类型为String?, 实际值为abc
END
如有错漏, 欢迎指出讨论. 希望对大家有帮助.