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