Java中的泛型#
目录#
1. 背景#
这学期上了《分布式系统》课程,内容主要是基于Java实现分布式计算,所以老师前几节课主要在给我们讲用Java做分布式可能会用到的一些技术。为了方便学习和记录,我将老师讲的内容结合书籍做了一些整理,这一篇主要讨论泛型。
2. 泛型#
此部分内容主要参考自《Java编程的逻辑》第8章 1。
2.1. 基本概念和原理#
在实际编程过程中,很多时候我们关心的不是类型,而是能力,针对接口和能力编程,不仅可以复用代码,还可以降低耦合,提高灵活性。
泛型将接口的概念进一步延伸,“泛型”的字面意思就是广泛的类型。类、接口和方法代码可以应用于非常广泛的类型,代码与它们能够操作的数据类型不再绑定在一起,同一套代码可以用于多种数据类型,这样,不仅可以复用代码,降低耦合,而且可以提高代码的可读性和安全性。
这么说可能比较抽象,接下来,我们通过一些例子逐步进行说明。在Java
中,类、接口、方法都可以是泛型的,我们先来看泛型类。
2.1.1. 一个简单泛型类#
我们通过一个简单的例子来说明泛型类的基本概念、基本原理和泛型的好处。
- 基本概念
我们直接来看代码:
public class Pair<T> {
T first;
T second;
public Pair(T first, T second){
this.first = first;
this.second = second;
}
public T getFirst() {
return first;
}
public T getSecond() {
return second;
}
}
Pair
就是一个泛型类,与普通类的区别体现在:
- 类名后面多了一个
<T>
; first
和second
的类型都是T
。
T
是什么呢?T
表示类型参数,泛型就是类型参数化,处理的数据类型不是固定的,而是可以作为参数传入。怎么用这个泛型类,并传递类型参数呢?看代码:
Pair<Integer> minmax = new Pair<Integer>(1,100);
Integer min = minmax.getFirst();
Integer max = minmax.getSecond();
Pair<Integer>
中的Integer
就是传递的实际类型参数。Pair
类的代码和它处理的数据类型不是绑定的,具体类型可以变化。上面是Integer
,也可以是String
,比如:
Pair<String> kv = new Pair<String>("name","老马");
类型参数可以有多个,Pair
类中的first
和second
可以是不同的类型,多个类型之间以逗号分隔,来看改进后的Pair
类定义:
public class Pair<U, V> {
U first;
V second;
public Pair(U first, V second){
this.first = first;
this.second = second;
}
public U getFirst() {
return first;
}
public V getSecond() {
return second;
}
}
可以这样使用:
Pair<String,Integer> pair = new Pair<String,Integer>("老马",100);
<String,Integer>
既出现在了声明变量时,也出现在了new
后面,比较烦琐,从Java 7
开始,支持省略后面的类型参数,可以如下使用:
Pair<String,Integer> pair = new Pair<>("老马",100);
- 基本原理
泛型类型参数到底是什么呢?为什么一定要定义类型参数呢?定义普通类,直接使用Object
不就行了吗?比如,Pair
类可以写为:
public class Pair {
Object first;
Object second;
public Pair(Object first, Object second){
this.first = first;
this.second = second;
}
public Object getFirst() {
return first;
}
public Object getSecond() {
return second;
}
}
使用Pair
的代码可以为:
Pair minmax = new Pair(1,100);
Integer min = (Integer)minmax.getFirst();
Integer max = (Integer)minmax.getSecond();
Pair kv = new Pair("name","老马");
String key = (String)kv.getFirst();
String value = (String)kv.getSecond();
这样是可以的。实际上,Java
泛型的内部原理就是这样的。
我们知道,Java
有Java
编译器和Java
虚拟机,编译器将Java
源代码转换为.class
文件,虚拟机加载并运行.class
文件。对于泛型类,Java
编译器会将泛型代码转换为普通的非泛型代码,就像上面的普通Pair
类代码及其使用代码一样,将类型参数T擦除,替换为Object
,插入必要的强制类型转换。Java
虚拟机实际执行的时候,它是不知道泛型这回事的,只知道普通的类及代码。
再强调一下,Java
泛型是通过擦除实现的,类定义中的类型参数如T会被替换为Object
,在程序运行过程中,不知道泛型的实际类型参数,比如Pair<Integer>
,运行中只知道Pair
,而不知道Integer
。认识到这一点是非常重要的,它有助于我们理解Java
泛型的很多限制。
Java为什么要这么设计呢?泛型是Java 5以后才支持的,这么设计是为了兼容性而不得已的一个选择。
- 泛型的好处
既然只使用普通类和Object
就可以,而且泛型最后也转换为了普通类,那为什么还要用泛型呢?或者说,泛型到底有什么好处呢?泛型主要有两个好处:
- 更好的安全性。
- 更好的可读性。
语言和程序设计的一个重要目标是将bug尽量消灭在摇篮里,能消灭在写代码的时候,就不要等到代码写完程序运行的时候。只使用Object
,代码写错的时候,开发环境和编译器不能帮我们发现问题,看代码:
Pair pair = new Pair("老马",1);
Integer id = (Integer)pair.getFirst();
String name = (String)pair.getSecond();
看出问题了吗?写代码时不小心把类型弄错了,不过,代码编译时是没有任何问题的,但运行时程序抛出了类型转换异常ClassCastException
。如果使用泛型,则不可能犯这个错误,比如下面的代码:
Pair<String,Integer> pair = new Pair<>("老马",1);
Integer id = pair.getFirst(); //有编译错误
String name = pair.getSecond(); //有编译错误
开发环境(如Eclipse
)会提示类型错误
,即使没有好的开发环境,编译时Java
编译器也会提示。这称之为类型安全,也就是说,通过使用泛型,开发环境和编译器能确保不会用错类型,为程序多设置一道安全防护网。使用泛型,还可以省去烦琐的强制类型转换,再加上明确的类型信息,代码可读性也会更好。
2.1.2. 小结#
泛型是计算机程序中一种重要的思维方式,它将数据结构和算法与数据类型相分离
,使得同一套数据结构和算法能够应用于各种数据类型,而且可以保证类型安全
,提高可读性
。
2.2. 通配符#
本节主要讨论泛型中的通配符概念。通配符有着令人费解和混淆的语法,但通配符大量应用于Java
容器类中,它到底是什么?下面我们逐步来解析。
2.2.1. 更简洁的参数类型限定#
我们看下面这个例子,为了将Integer
对象添加到Number
容器中,我们的类型参数使用了其他类型参数作为上界,这种写法有点烦琐,它可以替换为更为简洁的通配符形式:
// 不使用通配符的例子
public <T extends E> void addAll(DynamicArray<T> c) {
for(int i=0; i<c.size; i++){
add(c.get(i));
}
}
// 通配符形式
public void addAll(DynamicArray<? extends E> c) {
for(int i=0; i<c.size; i++){
add(c.get(i));
}
}
这个方法没有定义类型参数,c
的类型是DynamicArray<?extends E>
,?
表示通配符,<?extends E>
表示有限定通配符,匹配E
或E
的某个子类型,具体什么子类型是未知的。使用这个方法的代码不需要做任何改动,还可以是:
DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);
这里,E
是Number
类型,DynamicArray<?extends E>
可以匹配DynamicArray<Integer>
。
那么问题来了,同样是extends
关键字,同样应用于泛型,<T extends E>
和<?extends E>
到底有什么关系?它们用的地方不一样,我们解释一下:
1)<T extends E>
用于定义
类型参数,它声明了一个类型参数T
,可放在泛型类定义中类名后面、泛型方法返回值前面。
2)<?extends E>
用于实例化
类型参数,它用于实例化泛型变量中的类型参数,只是这个具体类型是未知的,只知道它是E
或E
的某个子类型。
虽然它们不一样,但两种写法经常可以达成相同目标,比如,前面例子中,下面两种写法都可以:
public void addAll(DynamicArray<? extends E> c)
public <T extends E> void addAll(DynamicArray<T> c)
那么,到底应该用哪种形式呢?我们先进一步理解通配符,然后再解释。
2.2.2. 理解通配符#
除了有限定通配符,还有一种通配符,形如DynamicArray<?>
,称为无限定通配符。我们来看个例子,在DynamicArray
中查找指定元素,代码如下:
public static int indexOf(DynamicArray<?> arr, Object elm){
for(int i=0; i<arr.size(); i++){
if(arr.get(i).equals(elm)){
return i;
}
}
return -1;
}
其实,这种无限定通配符形式也可以改为使用类型参数。也就是说,下面的写法:
public static int indexOf(DynamicArray<?> arr, Object elm)
可以改为:
public static <T> int indexOf(DynamicArray<T> arr, Object elm)
不过,通配符形式更为简洁。虽然通配符形式更为简洁,但上面两种通配符都有一个重要的限制:只能读,不能写。怎么理解呢?看下面的例子:
DynamicArray<Integer> ints = new DynamicArray<>();
DynamicArray<? extends Number> numbers = ints;
Integer a = 200;
numbers.add(a); //错误!
numbers.add((Number)a); //错误!
numbers.add((Object)a); //错误!
三种add方法都是非法的,无论是Integer
,还是Number
或Object
,编译器都会报错。为什么呢?问号就是表示类型安全无知,?extends Number
表示是Number
的某个子类型,但不知道具体子类型,如果允许写入,Java
就无法确保类型安全性,所以干脆禁止。我们来看个例子,看看如果允许写入会发生什么:
DynamicArray<Integer> ints = new DynamicArray<>();
DynamicArray<? extends Number> numbers = ints;
Number n = new Double(23.0);
Object o = new String("hello world");
numbers.add(n);
numbers.add(o);
如果允许写入Object
或Number
类型,则最后两行编译就是正确的,也就是说,Java
将允许把Double
或String
对象放入Integer
容器,这显然违背了Java
关于类型安全
的承诺。
大部分情况下,这种限制是好的,但这使得一些理应正确的基本操作无法完成,比如交换两个元素的位置,看如下代码:
public static void swap(DynamicArray<?> arr, int i, int j){
Object tmp = arr.get(i);
arr.set(i, arr.get(j));
arr.set(j, tmp);
}
这个代码看上去应该是正确的,但Java
会提示编译错误,两行set
语句都是非法的。不过,借助带类型参数的泛型方法,这个问题可以如下解决:
private static <T> void swapInternal(DynamicArray<T> arr, int i, int j){
T tmp = arr.get(i);
arr.set(i, arr.get(j));
arr.set(j, tmp);
}
public static void swap(DynamicArray<?> arr, int i, int j){
swapInternal(arr, i, j);
}
swap
可以调用swapInternal
,而带类型参数的swapInternal
可以写入。Java
容器类中就有类似这样的用法,公共的API是通配符形式,形式更简单,但内部调用带类型参数的方法。
除了这种需要写的场合,如果参数类型之间有依赖关系,也只能用类型参数,比如,将src
容器中的内容复制到dest
中:
public static <D,S extends D> void copy(DynamicArray<D> dest,
DynamicArray<S> src){
for(int i=0; i<src.size(); i++){
dest.add(src.get(i));
}
}
S
和D
有依赖关系,要么相同,要么S
是D
的子类,否则类型不兼容,有编译错误。不过,上面的声明可以使用通配符简化,两个参数可以简化为一个,如下所示:
public static <D> void copy(DynamicArray<D> dest,
DynamicArray<? extends D> src){
for(int i=0; i<src.size(); i++){
dest.add(src.get(i));
}
}
如果返回值依赖于类型参数,也不能用通配符,比如,计算动态数组中的最大值,如下所示:
public static <T extends Comparable<T>> T max(DynamicArray<T> arr){
T max = arr.get(0);
for(int i=1; i<arr.size(); i++){
if(arr.get(i).compareTo(max)>0){
max = arr.get(i);
}
}
return max;
}
上面的代码就难以用通配符代替。
现在我们再来看泛型方法到底应该用通配符的形式还是加类型参数。两者到底有什么关系?我们总结如下。
- 通配符形式都可以用
类型参数的形式
来替代,通配符能做的,用类型参数都能做。 - 通配符形式
可以减少类型参数
,形式上往往更为简单
,可读性也更好,所以,能用通配符的就用通配符
。 - 如果类型参数之间
有依赖关系
,或者返回值依赖类型参数
,或者需要写操作
,则只能用类型参数
。 - 通配符形式和类型参数往往
配合使用
,比如,上面的copy方法,定义必要的类型参数,使用通配符表达依赖,并接受更广泛的数据类型。
2.2.3. 超类型通配符#
还有一种通配符,与形式<?extends E>
正好相反,它的形式为<?super E>
,称为超类型通配符
,表示E
的某个父类型
。它有什么用呢?有了它,我们就可以更灵活地写入了。
如果没有这种语法,写入会有一些限制。来看个例子,我们给DynamicArray
添加一个方法:
public void copyTo(DynamicArray<E> dest){
for(int i=0; i<size; i++){
dest.add(get(i));
}
}
这个方法也很简单,将当前容器中的元素添加到传入的目标容器中。我们可能希望这么使用:
DynamicArray<Integer> ints = new DynamicArray<Integer>();
ints.add(100);
ints.add(34);
DynamicArray<Number> numbers = new DynamicArray<Number>();
ints.copyTo(numbers);
Integer
是Number
的子类,将Integer
对象拷贝入Number
容器,这种用法应该是合情合理的,但Java
会提示编译错误,理由我们之前也说过了,期望的参数类型是Dynamic-Array<Integer>
,DynamicArray<Number>
并不适用。
如之前所说,一般而言,不能将DynamicArray<Integer>
看作DynamicArray<Number>
,但我们这里的用法是没有问题的,Java
解决这个问题的方法就是超类型通配符,可以将copyTo代码改为:
public void copyTo(DynamicArray<? super E> dest){
for(int i=0; i<size; i++){
dest.add(get(i));
}
}
这样,就没有问题了。
超类型通配符另一个常用的场合是Comparable/Comparator
接口。同样,我们先来看下如果不使用会有什么限制。以前面计算最大值的方法为例,它的方法声明是:
public static <T extends Comparable<T>> T max(DynamicArray<T> arr)
这个声明有什么限制呢?举个简单的例子,有两个类Base
和Child
,Base
的代码是:
class Base implements Comparable<Base>{
private int sortOrder;
public Base(int sortOrder) {
this.sortOrder = sortOrder;
}
@Override
public int compareTo(Base o) {
if(sortOrder < o.sortOrder){
return -1;
}else if(sortOrder > o.sortOrder){
return 1;
}else{
return 0;
}
}
}
Base
代码很简单,实现了Comparable
接口,根据实例变量sortOrder
进行比较。Child
代码是:
class Child extends Base {
public Child(int sortOrder) {
super(sortOrder);
}
}
这里,Child
非常简单,只是继承了Base
。注意:Child
没有重新实现Comparable
接口,因为Child
的比较规则和Base
是一样的。我们可能希望使用前面的max方法操作Child
容器,如下所示:
DynamicArray<Child> childs = new DynamicArray<Child>();
childs.add(new Child(20));
childs.add(new Child(80));
Child maxChild = max(childs);
遗憾的是,Java
会提示编译错误,类型不匹配。为什么不匹配呢?我们可能会认为,Java
会将max
方法的类型参数T
推断为Child
类型,但类型T的要求是extends Comparable<T>
,而Child
并没有实现Comparable<Child>
,它实现的是Comparable<Base>
。
但我们的需求是合理的,Base类的代码已经有了关于比较所需要的全部数据,它应该可以用于比较Child对象。解决这个问题的方法,就是修改max的方法声明,使用超类型通配符,如下所示:
public static <T extends Comparable<? super T>> T max(DynamicArray<T> arr)
这么修改一下就可以了,这种写法比较抽象,将T
替换为Child
,就是:
Child extends Comparable<? super Child>
<?super Child>
可以匹配Base
,所以整体就是匹配的。
我们比较一下类型参数限定与超类型通配符,类型参数限定只有extends
形式,没有super
形式,比如,前面的copyTo方法
的通配符形式的声明为:
public void copyTo(DynamicArray<? super E> dest)
如果类型参数限定支持super
形式,则应该是:
public <T super E> void copyTo(DynamicArray<T> dest)
事实是,Java
并不支持这种语法。
前面我们说过,对于有限定的通配符形式<?extends E>
,可以用类型参数限定替代,但是对于类似上面的超类型通配符,则无法用类型参数替代。
2.2.4. 通配符比较#
本节介绍了泛型中的三种通配符形式<?>
、<?super E>
和<?extends E>
,并分析了与类型参数形式的区别和联系,它们比较容易混淆,我们总结比较如下:
- 它们的目的都是为了使方法接口更为灵活,可以接受更为广泛的类型。
<?super E>
用于灵活写入或比较,使得对象可以写入父类型的容器,使得父类型的比较方法可以应用于子类对象,它不能被类型参数形式替代。<?>
和<?extends E>
用于灵活读取,使得方法可以读取E或E的任意子类型的容器对象,它们可以用类型参数的形式替代,但通配符形式更为简洁。Java
容器类的实现中,有很多使用通配符的例子,比如,类Collections
中就有如下方法:
public static <T extends Comparable<? super T>> void sort(List<T> list)
public static <T> void sort(List<T> list, Comparator<? super T> c)
public static <T> void copy(List<? super T> dest, List<? extends T> src)
public static <T> T max(Collection<? extends T> coll, Comparator<? super T> comp)
2.3. 细节和局限性#
泛型也存在一些局限性,这些局限性主要是由于Java
泛型的实现机制引起的,这些局限性包括:
- 不能使用基本类型
- 没有运行时类型信息
- 类型擦除会引发一些冲突
- 不能通过类型参数创建对象
- 不能用于静态变量
- 不能创建泛型数组,需要通过泛型容器来实现数组功能
我们需要理解这些局限性,幸运的是,一般并不需要特别去记忆,因为用错的时候,Java
开发环境和编译器会进行提示,当被提示时能够理解并从容应对即可。
3. 参考文献#
[1] 马俊昌. Java 编程的逻辑 [M]. 北京 : 机械工业出版社, 2018.
联系邮箱:curren_wong@163.com
Github:https://github.com/CurrenWong
欢迎转载/Star/Fork,有问题欢迎通过邮箱交流。