之前章节中我们多次提到过泛型这个概念,本章我们就来详细讨论Java中泛型。虽然泛型的基本思维和概念是比较简单的,但它有一些非常令人费解的语法、细节,以及局限性。
后续章节我们会介绍各种容器类。容器类可以说是日常程序开发中天天用到的,没有容器类,难以想象能开发什么真正有用的程序。而容器类是基于泛型的,不理解泛型,就难以深刻理解容器类。那泛型到底是什么呢?本章我们分为三节逐步来讨论:8.1节主要介绍泛型的基本概念和原理;8.2节重点介绍令人费解的通配符;8.3节介绍一些细节和泛型的局限性。
8.1 基本概念和原理
之前我们一直强调数据类型的概念,Java有8种基本类型,可以定义类,类相当于自定义数据类型,类之间还可以有组合和继承。我们也介绍了接口,其中提到,很多时候我们关心的不是类型,而是能力,针对接口和能力编程,不仅可以复用代码,还可以降低耦合,提高灵活性。
泛型将接口的概念进一步延伸,“泛型”的字面意思就是广泛的类型。类、接口和方法代码可以应用于非常广泛的类型,代码与它们能够操作的数据类型不再绑定在一起,同一套代码可以用于多种数据类型,这样,不仅可以复用代码,降低耦合,而且可以提高代码的可读性和安全性。
这么说可能比较抽象,接下来,我们通过一些例子逐步进行说明。在Java中,类、接口、方法都可以是泛型的,我们先来看泛型类。
8.1.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 U getSecond(){
return second;
}
}
可以这样使用:
Pair<String,Integer> pair = new Pair<String, Integer>("老马", 100);
<String,Integer>既出现在了声明变量时,也出现在了new后面,比较繁琐,从Java7开始,支持省略后面的类型参数,可以如下使用:
Pair<String, Integer> pair = new Pair<>("老马", 100);
2. 基本原理
泛型类型参数到底是什么呢?为什么一定要定义类型参数呢?定义普通类,直接使用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 以后才支持的,这么设计是为了兼容性而不得已的一个选择。
3. 泛型的好处
既然只使用普通类和Object就可以,而且泛型最后也转换为了普通类,那为什么还要用泛型呢?或者说,泛型到底有什么好处呢?泛型主要有两个好处:
- 更好的安全性
- 更好的可读性
语言和程序设计的一个重要目标是将bug尽量消灭在摇篮里,能消灭在写代码的时候,就不要等到代码写完程序运行的时候。只使用Object, 代码写错的时候,开发环境和编译环境不能帮我们发现问题,看代码:
Pair pair = new Pair("老马", 1);
Integer id = (Integer)pair.getFirst();
String name = (String)pair.getSecond();
看出问题了吗?写代码时不小心把类型弄错了,不过,代码编译时是没有任何问题的,但运行时程序抛出了类型转换异常ClassCastExcption。如果使用泛型,则不可能犯这个错误,比如下面的代码:
Pair<String,Integer> pair = new Pair<>("老马", 1);
Integer id = (Integer)pair.getFirst(); //有编译错误
String name = (String)pair.getSecond(); //有编译错误
开发环境(如Eclipse)会提示类型错误,即使没有好的开发环境,编译时Java编译器也会提示。这称之为类型安全,也就是说,通过使用泛型,开发环境和编译环境能确保不会用错类型,为程序都设置一道安全防护网。使用泛型,还可以省去繁琐的强制类型转换,再加上明确的类型信息,代码可读性也会更好。
8.1.2 容器类
泛型类最常用的用途是作为容器类。所谓容器类,简单地说,就是容纳并管理多项数据的类。数组就是用来管理多项数据的,但数组有很多限制,比如,长度固定,插入、删除操作效率比较低。计算机技术有一门课程叫数据结构,专门讨论管理数据的各种方式。
这些数据结构在Java中的实现主要就是Java中的各种容器类,甚至Java泛型的引入也是为了更好地支持Java容器。后续章节我们会详细讨论主要的Java容器,本节先实现一个非常简单的Java容器,来解释泛型的一些概念。
我们实现一个简单的动态数组容器。所谓动态数组,就是长度可变的数组。底层数组的长度当然是不可变的,但我们提供一个类,对于这个类的使用者而言,好像就是一个长度可变的数组。Java容器中有一个对应的类ArrayList, 本节我们来实现一个简化版,如代码清单8-1所示。
代码清单8-1 动态数组DynamicArray
public class DynamicArray<E>{
private static final int DEFAULT_CAPACITY = 10;
private int size;
private Object[] elementData;
public DynamicArray(){
this.elementData = new Object[DEFAULT_CAPACITY];
}
private void ensureCapacity(int minCapacity){
int oldCapacity = elementData.length;
if(oldCapacity >= minCapactiy){
return;
}
int newCapacity = oldCapacity * 2;
if(newCapacity < minCapacity)
newCapacity = minCapacity;
elementData = Arrays.copyOf(elementData, newCapacity);
}
public void add(E e){
ensureCapacity(size + 1);
elementData[size++] = e;
}
public E get(int index){
return (E)elementData[index];
}
public int size(){
return size;
}
public E set(int index, E element){
E oldValue = get(index);
elementData[index] = element;
return oldValue;
}
}
DynamicArray 就是一个动态数组,内部代码与我们之前分析过的StringBuilder类似,通过ensureCapacity方法来根据需要扩展数组。作为一个容器类,它容纳的数据类型是作为参数传递过来的,比如,存放Double类型:
DynamicArray<Double> arr = new DynamicArray<Double>();
Random rnd = new Random();
int size = 1+rand.nextInt(100);
for(int i = 0; i < size; i++){
arr.add(Math.random());
}
Double d = arr.get(rnd.nextInt(size));
这就是一个简单的容器类,适用于各种数据类型,且类型安全。后文还会以Dynamci-Array为例进行扩展,以解释泛型概念。
具体的类型还可以是一个泛型类,比如,可以这样写:
DynamicArray<Pair<Integer, String>> arr = new DynamicArray<>();
arr表示一个动态数组,每个元素是Pair<Integer,String>类型。
8.1.3 泛型方法
除了泛型类,方法也可以是泛型的,而且,一个方法是不是泛型的,与它所在的类是不是泛型没有什么关系。我们看个例子:
public static <T> int indexOf(T[] arr, T elm){
for(int i = 0; i < arr.length; i++){
if(arr[i].equals(elm)){
return i;
}
}
return -1;
}
这个方法就是一个泛型方法,类型参数为T, 放在返回值前面,它可以如下调用:
indexOf(new Integer[]{1,3,5}, 10)
也可以如下调用:
indexOf(new String[]{"hello", "老马", "编程"}, "老马")
indexOf表示一个算法,在给定数组中寻找某个元素,这个算法的基本过程与具体数据类型没有什么关系,通过泛型,它可以方便地应用于各种数据类型,且由编译器保证类型安全。
与泛型类一样,类型参数可以有多个,以逗号分隔,比如:
public static <U,V> Pair<U,V> makePair(U first, V second){
Pair<U,V> pair = new Pair<>(first, second);
return pair;
}
与泛型类不同,调用方法时一般并不需要特意指定类型参数的实际类型,比如调用make-Pair:
makePair(1, "老马");
并不需要告诉编译器U的类型是Integer, V的类型是String, Java编译器可以自动推断出来。
8.1.4 泛型接口
接口也可以是泛型的,我们之前介绍过Comparable和Comparator接口都是泛型的,它们的代码如下:
public interface Comparable<T>{
public int compareTo(T o);
}
public interface Comparator<T>{
int compare(T o1, T o2);
boolean equals(Object obj);
}
与前面一样,T是类型参数。实现接口时,应该指定具体的类型,比如,对Integer类,实现代码是:
public final class Integer extends Number implements Comparable<Integer>{
public int compareTo(Integer anotherInteger){
return compare(this.value, anotherInteger.value);
}
//其他代码
}
通过implements Comparable<Integer>, Integer实现了Comparable接口,指定了实际类型参数为Integer,表示Integer 只能与Integer对象进行比较。
再看Comparator的一个例子,String类内部一个Comparator的接口实现为:
private static class CaseInsensitiveComparator implements Comparator<String>{
public int compare(String s1, String s2){
//省略主体代码
}
}
这里,指定了实际类型参数为String。
8.1.5 类型参数的限定
在之前的介绍中,无论是泛型类、泛型方法还是泛型接口,关于泛型参数,我们都知之甚少,只能把它当做Object, 但Java支持限定这个参数的一个上界,也就是说,参数必须为给定的上界类型或其子类型,这个限定是通过extends关键字来表示的。这个上界可以是某个具体的类或者某个具体的接口,也可以是其他类型参数,我们逐个介绍其应用。
1. 上界为某个具体类
比如,上面的Pair类,可以定义一个子类NumberPair,限定两个类型参数必须为Number,代码如下:
public class NumberPair<U extends Number, V extends Number> extends Pair<U, V>{
public NumberPair(U first, V second){
super(first, second);
}
}
限定类型后,就可以使用该类型的方法了。比如,对于NumberPair类,first和second变量就可以当做Number进行处理了。比如可以定义一个求和方法,如下所示:
public double sum(){
return getFirst().doubleValue() + getSecond().doubleValue();
}
可以这么用:
NumberPair<Integer, Double> pair = new NumberPair(10, 12.34);
double sum = pair.sum();
限定类型后,如果类型使用错误,编译器会提示。指定边界后,类型擦除时就不会转换为Object了,而是会转换为它的边界类型,这也是容易理解的。
2. 上界为某个接口
在泛型方法中,一种常见的场景是限定类型必须实现Comparable接口,我们来看代码:
public static <T extends Comparable> T max(T[] arr){
T max = arr[0];
for(int i=1; i<arr.length; i++){
if(arr[i].compareTo(max) > 0){
max = arr[i];
}
}
return max;
}
max方法计算一个泛型数组中的最大值。计算最大值需要进行元素之间的比较,要求元素实现Comparable接口,所以给类型参数设置了一个上边界Comparable, T必须实现Comparable接口。
不过,直接这么编写代码,Java中会给一个警告信息,因为Comparable是一个泛型接口,它也需要一个类型参数,所以完整的方法声明应该是:
public static <T extends Comparable<T>> T max(T[] arr){
//主体代码
}
<T extends Comparable<T>> 是一种令人费解的语法形式,这种形式称为递归类型限制,可以这么解读:T 表示一种数据类型,必须实现Comparable接口,且必须可以与相同类型的元素进行比较。
3. 上界为其他类型参数
上面的限定都是制定了一个明确的类或接口,Java支持一个类型参数以另一个类型参数作为上界。为什么需要这个呢?我们看个例子,给上面的DynamicArray类增加一个实例方法addAll,这个方法将参数容器中的所有元素都添加到当前容器里来,直接上,代码可以如下书写:
8.1.6 小结
泛型是计算机程序中一种重要的思维方式,它将数据结构和算法与数据类型相分离,使得同一套数据结构和算法能够应用于各种数据类型,而且可以保证类型安全,提高可读性。 在Java中,泛型广泛应用于各种容器类中,理解泛型是深刻理解容器的基础。
本节介绍了泛型的基本概念,包括泛型类、泛型方法和泛型接口,关于类型参数,我们介绍了多种上界限定,先定位某具体类、某具体接口或其他类型参数。泛型类最常见的用途是容器类,我们实现了一个简单的容器类DynamicArray, 以解释泛型概念。
在Java中,泛型是通过类型擦除来实现的,它是Java编译器的概念,Java虚拟机运行时对泛型基本一无所知,理解这一点是很重要的,它有助于我们理解Java泛型的很多局限性。
关于泛型,Java中有一个通配符的概念,用得很广泛,但语法非常令人费解,而且容易混淆,8.2节中,我们力图对它进行清晰的剖析。
8.2 解析通配符
本节主要讨论泛型中的通配符概念。通配符有着令人费解和混淆的语法,但通配符大量应用于Java容器类中,它到底是什么?下面我们逐步来解析。
8.3 细节和局限性
本节介绍泛型中的一些细节和局限性,这些局限性主要与Java的实现机制有关。Java中,泛型是通过类型擦除来实现,类型参数在编译时会被替换为Object, 运行时Java虚拟机不知道泛型这回事,这带来了很多局限性,其中有的部分是比较容易理解的,有的则是非常违反直觉的。
一项技术,往往只有理解了其局限性,才算是真正理解了它,才能更好的应用它。下面我们将从以下几个方面来介绍这些细节和局限性:
- 使用泛型类、方法和接口。
- 定义泛型类、方法和接口。
- 泛型与数组。
8.3.1 使用泛型类、方法和接口
在使用泛型类、方法和接口时,有一些值得注意的地方,比如:
- 基本类型不能用于实例化类型参数。
- 运行时类型信息不适用于泛型。
- 类型擦除可能会引发一些冲突。
我们逐个来看下。Java中,因为类型参数会被替换为Object, 所以Java泛型中不能使用基本数据类型,也就是说,类似下面的写法是不合法的:
Pair<int> minmax = new Pair<int>(1, 100);
解决方法是使用基本类型对应的包装类。
在介绍继承的实现原理时,我们提到在内存中每个类都有一份类型信息,而每个对象也都保存着其对应类型信息的引用。关于运行时信息,后续章节我们会进一步详细介绍,这里简要说明一下。在Java中,这个类型信息也是一个对象,它的类型为Class, Class本身也是一个泛型类,每个类的类型对象可以通过<类名>.class的方式引用,比如String.class、Integer.class。这个类型对象也可以通过对象的getClass()方法获得,比如:
Class<?> cls = "hello".getClass();
这个类型对象只有一份,与泛型无关,所以Java不支持类似如下写法:
Pair<Integer>.class
一个泛型对象的getClass方法的返回值与原始类型对象也是相同的,比如,下面代码的输出都是true:
Pair<Integer> p1 = new Pair<Integer>(1, 100);
Pair<String> p2 = new Pair<String>("hello", "world");
System.out.println(Pair.class == p1.getClass()); //true
System.out.println(Pair.class == p2.getClass()); //true
之前,我们介绍过instanceof关键字,instanceof后面是接口或类名,instanceof是运行时判断,也与泛型无关,所以,Java也不支持类似如下写法:
if(p1 instanceof Pair<Integer>)
不过,Java支持如下写法:
if(p1 instanceof Pair<?>)
由于类型擦除,可能会引发一些编译冲突,这些冲突初看上去并不容易理解,我们通过一些例子介绍。8.2.3节我们介绍过一个例子,有两个类Base和Child,Base的声明为:
8.3.4 小结
本节介绍了泛型的一些细节和局限性,这些局限性主要是由于Java泛型的实现机制引起的,这些局限性包括:不能使用基础类型,没有运行时类型信息,类型擦除会引发一些冲突,不能通过类型参数创建对象,不能用于静态变量等。我们还单独讨论了泛型与数组的关系。
我们需要理解这些局限性,幸运的是,一般并不需要特别去记忆,因为用错的时候,Java开发环境和编译器会进行提示,当被提示时能够理解并从容应对即可。
至此,关于泛型的介绍就结束了。泛型是Java容器类的基础,理解了泛型,接下来,就让我们开始探索Java中的容器类。