第八章 泛型程序设计
8.1 为什么要使用泛型程序设计
泛型程序设计(generic programming)意味着编写的代码可以对多种不同类型的对象重用。例如,你并不希望为收集String和File对象分别编写不同的类。实际上,也不需要这样做,因为一个ArrayList类就可以收集任何类的对象。这就是泛型程序设计的一个例子。
实际上 ,在Java有泛型类之前已经有一个ArrayList类。下面来研究泛型程序设计的机制是如何演变的,另外还会介绍这对用户和实现者来说意味着什么。
8.1.1 类型参数的好处
在Java中增加泛型类之前,泛型程序设计是用继承实现的。ArrayList类只维护一个Object引用的数组:
1 | public class ArrayList |
这两种方法存在两个问题。当获取一个值时必须进行强制类型转换。
1 | ArrayList files = new ArrayList(); |
此外,这里没有错误检查。可以向数组列表中添加任何类的值。
1 | files.add(new File("...")); |
对于这个调用,编译器和运行都不会错。不过在其他地方,如果将get的结果强制类型转换转换为String类型,就会产生一个错误。
泛型提供了一个更好的解决方案:参数类型(type parameter)。ArrayList类有一个类型参数用来指示元素的类型:
1 | ArrayList<String> files = new ArrayList<String>(); |
这使得代码具有更好的可读性。人们一看就知道这个数组列表中包含的是String对象。
编译器也可以充分利用这个类型信息。调用get的时候,不需要进行强制类型转换。编译器知道返回值类型为String,而不是Object:
1 | String filename = files.get(0); |
编译器还知道ArrayList\
1 | files.add(new(File("...")));//can only add String objects to an Arraylist<String> |
是无法通过编译得。不过,出现编译错误要比运行时出现类得强制类型转换异常好得多。
这正是类型参数得魅力所在:他们会让你的程序更易读,也更安全。
8.1.2 谁想成为泛型程序员
下面是让标准类库得设计者们饱受折磨得一个典型问题。ArrayList类有一个方法addAll,用来添加另一个集合得全部元素。一个程序员可能想要将一个ArrayList\
8.2 定义简单泛型类
泛型类(generic class)就是一个或多个类型变量的类。本章使用一个简单的Pair类作为例子。这个类是我们可以可以只关注泛型,而不用为数据存储的细节而分心。。下面是泛型Pair类的代码:
1 | public class Pair<T> |
Pair类引入了一个类型变量T,用尖括号<>括起来,放在类名后面。泛型类可以有多个类型变量。例如,可以定义Pair类,其中第一个字段和第二个字段使用不同的类型:
1 | public class Pair<T,U>{...} |
类型变量在整个类定义中用于指定方法的返回类型以及字段和局部变量的类型。例如:
1 | private T first; |
可以使用具体的类型替换类型变量来实例化(instantiate)泛型类型,例如:
1 | Pair<String> |
可以把结果想象成一个普通类,它有以下构造器:
1 | Pair<String>() |
以及以下方法:
1 | String getFirst() |
换句话说,泛型类相当于普通类的工厂。
1 | package pair1; |
8.3 泛型方法
上一节已经介绍如何定义一个泛型类。还可以定义一个带有类型参数的方法。
1 | class ArrayAlg |
这个方法是在普通类中定义的,而不是在泛型类中。不过,这是一个泛型方法,可以从尖括号和类型变量看出这一点。注意,类型变量放在修饰符(这里的修饰符就是public static)的后面,并在返回类型的前面。
泛型方法可以在普通类中定义,也可以在泛型类中定义。当调用一个泛型方法时,可以把具体类型的包括在尖括号中,放在方法名前面:
1 | String middle = ArrayAlg.<String>getMiddle("John","Q.","Public"); |
在这种情况下(实际也是大多数情况下),方法调用中可以省略\
1 | String middle = ArrayAlg.getMiddle("john","Q.","Public"); |
几乎所有的情况下,泛型方法的类型推导能正常工作。偶尔,编译器也会提示错误,此时你就需要解译错误报告。考虑下面这个实例:
1 | double middle = ArrayAlg.getMiddle(3.14,1729,0); |
错误消息以晦涩的方式指出:解释这个代码有两种方式,而且这两种方式都是合法的。简单地说,编译器将把参数自动装箱为1个Double和2个Integer对象,然后寻找这些类的共同超类型。事实上,它找到了2个超类型:Number和Comparable接口,Comparable接口本身也是一个泛型类型。在这种情况下,可以采取的补救措施是将所有的参数都写为double值。
8.4 类型变量的限定
有时,类或方法需要对类型变量加以约束。下面是是一个典型的例子。我们要计算数组中的最小元素:
1 | class ArrayAlg |
但是,这里有一个问题。请看min方法的代码。变量smallest的类型为T,这意味着它可以是任何一个类的对象。如何知道T所属的类有一个compareTo方法呢?
解决这个问题的办法是限制T只能是实现了Comparable接口的类。可以通过对类型变量T设置一个限定(bound)来实现这一点:
1 | public static<T extends Comparable> T min(T[] a)... |
实际上Comparable接口本身就是一个泛型类型。目前,我们忽略其复杂性以及编译器产生的警告。8.8节中会讨论如何在Comparable接口中适当地使用类型参数。
现在,泛型方法min只能在实现了Comparable接口类(如String、LocalDate等)的数组上调用。由于Rectangle类没有实现Comparable接口,所以在Rectangle数组上调用min将会产生一个编译错误。
你或许会感觉到奇怪——在这里我们为什么使用关键字extends而不是implements?毕竟Comparable是一个接口。下面的记法
1 | <T extends BoudingType> |
表示T应该是限定类型(bounding type)的子类型(subtype)。T和限定类型可以是类,也可以是接口。选择关键字extends的原因是它更接近子类型的概念,并且Java的设计者也不打算在语言中添加一个新的关键字(如sub)。
一个类型变量或通配符可以有多个限定,例如:
1 | T extends Comparable & Serializable |
限定类型用“&”分割,而逗号用来分隔类型变量。
在Java的继承中,可以根据需要拥有多个接口超类型,但最多有一个限定可以是类。如果有一个类作为限定,它必须是限定列表中的第一个限定。
1 | package pair2; |
8.5 泛型代码和虚拟机
虚拟机没有泛型类型对象——所有对象都属于普通类。在泛型实现的早期版本中,甚至能够将使用泛型的程序编译为1.0虚拟机上运行的类文件!在下面的小节中你会看到编译器如何“擦除”类型参数,以及这个过程对Java程序员有什么影响。
8.5.1 类型擦除
无论何时定义一个泛型类型,都会自动提供一个相应的原始类型(raw type)。这个原始类型的名字就是去掉类型参数后的泛型类型名。类型变量会擦除(erased),并替换为其限定类型(或者,对于无限的变量则替换为Object)
例如,Pair\
1 | public class Pair |
因为T是一个无限定的变量,所以直接用Object替换。
结果是一个普通的类,就好像Java语言中引入泛型之前实现的类一样
在程序中可以包含不同类型的Pair,例如,Pair\
原始类型用第一个限定来替换变量类型,或者,如果没有给限定,就替换为Object。例如,类Pair\
1 | public class Interval<T extends Comparable & Serializable>implements Serializable |
原始类型Interval如下所示:
1 | public class Interval implements Serializable |
8.5.2 转换泛型表达式
编写一个泛型方法调用时,如果擦除了返回类型,编译器会插入强制类型转换。例如,对于下面这个语句列,
1 | Pair<Employee>buddies = ...; |
getFirst擦除类型后的返回类型是Object。编译器自动插入转换到Employee的强制类型转换。也就是说,编译器把这个方法调用转换为两条虚拟机指令:
- 对原始方法Pair.getFirst的调用;
- 将返回的Object类型强制转换为Employee。
当访问一个泛型字段时也要插入强制类型转换。假设Pair类的first字段和second字段都是公共的。表达式
1 | Employee buddy = buddies.first; |
也会在结果字节码中插入强制类型转换。
8.5.3 转换泛型方法
类型擦除也会出现在泛型方法中。程序员通常认为类似下面的泛型方法
1 | public static <T extends Comparable> T min(T[] a) |
是整个一组方法,而擦除类型之后,只剩下一个方法:
1 | public static Comparable min(Comparable[] a) |
注意,类型参数T已经被擦除了,只留下了限定类型Comparable。
方法的擦除带来了两个复杂的问题。看一看下面这个示例:
1 | class DateInterval extends Pair<LoclaDtae> |
日期区间是一对LocalDate对象,而且我们需要覆盖这个方法来确保第二个值永远不小于第一个值。这个类擦除后变成
1 | class DateInterval extends Pair |
令人奇怪的是,还有一个从Pair继承的setSecond方法,即
1 | public void setSecond(Object second) |
这显然是一个不同的方法,因为它有一个不同的参数类型——Object,而不是LocalDate。不过,不应该不一样。考虑从下面的语句序列:
1 | DateInterval interval = new DateInterval(...); |
这里,我们希望setSecond调用具有多态性,会调用最合适的那个方法。由于pair引用一个DateInterval对象,所以应该调用DateInterval.setSecond。问题在于类类型擦除与多态发生了冲突。为了解决这个问题,编译器在DateInterval类中生成了一个桥方法(bridge method):
1 | public void setSecond(Object second) |
想要了解为什么这样可行,请仔细跟踪以下语句执行:
1 | pair.setSecond(aDate) |
变量pair已经声明为类型Pair\
桥方法可能会变得更奇怪。假设DateInterval类也覆盖了getSecond方法:
1 | class DateInterval extends Pair<LocalDate> |
在DateInterval类中,有两个getSecond方法:
1 | LocalDate getSecond(); |
不能这样编写Java代码(两个方法有相同的参数类型是不合法的,在这里,两个方法都没有参数)。但是,在虚拟机中,会由参数类型和返回值类型共同指定一个方法。因此,编译器可以为两个仅返回类型不同的方法生成字节码,虚拟机能够正确地处理这种情况。
总之,对于Java泛型的转换,需要记住以下几个事实:
- 虚拟机中没有泛型,只有普通的类和方法。
- 所有的类型参数都会替换为他们的限定类型。
- 会生成桥方法来保持多态。
- 为保持类型安全性,必要时会插入强制类型转换。
8.5.4 调用遗留代码
8.6 限制与局限性
在下面几节中,我们将讨论使用Java泛型时需要考虑的一些限制。大多数限制都是由类型擦除引起的。
8.6.1 不能用基本类型实例化类型参数
不能用基本类型代替类型参数。因此,没有Pair\
这的确令人烦恼。但是,这样做与Java语言中基本类型的独立状态相一致。这并不是一个致命的缺陷——只有8种基本类型,而且即使不能接受包装器类型(wrapper type),也可以使用单独的类和方法来处理。
8.6.2 运行时类型查询只适用于原始类型
虚拟机中的对象总有一个特定的非泛型类型。因此,所有的类型查询只产生原始类型。
例如,
1 | if(a instanceof Pair<String>)//ERROR |
实际上仅仅测试a是否是任意类型的一个Pair。下面的测试同样如此:
1 | if(a instanceof Pair<T>)//ERROR |
或强制类型转换:
1 | Pair<String> p = (Pair<String>) a;//warning--can only test that a is a Pair |
为提醒这一风险,如果试图查询一个对象是否属于某个泛型类型,你会得到一个编译器错误(使用instanceof时),或者得到一个警告(使用instanceof时),或者得到一个警告(使用强制类型转换时)。同样的道理,getClass方法总是返回原始类型。例如:
1 | Pair<String> stringPair = ...; |
其比较的结果是true,这是因为两次getClass调用都返回Pair.class。
8.6.3 不能创建参数化类型的数组
不能实例化参数化类型的数组,例如:
1 | Pair<String> table = new Pair<String>[10];//ERROR |
这有什么问题呢?擦除之后,table的类型是Pair[ ]。可以把它转换为Object[ ]:
1 | Object[] objarray = table; |
数组会记住它的元素类型,如果试图存储其他类型的元素,就会抛出一个ArrayStoreException异常:
1 | objarray[0] = "Hello";//ERROR--component type is Pair |
不过对于泛型类型,擦除会使这种机制无效。以下赋值
1 | objarray[0] = new Pair<Employee>(); |
尽管能够通过数组存储的检查,但仍会导致一个类型错误。出于这个原因,不允许创建参数化类型的数组。
需要说明的是,只是不允许创建这些数组,而声明类型为Pair\
8.6.4 Varargs警告
上一节了解到,Java不支持泛型类型的数组。这一节我们再来讨论一个相关的问题:向参数个数可变的方法传递一个泛型类型的实例。
考虑下面这个简单的方法,它的参数个数是可变的:
1 | public static <T> void addAll(Collection<T> coll, T... ts) |
回忆一下,实际上参数ts是一个数组,包含提供的所有实参。
现在考虑以下调用:
1 | Collection<Pair<String>> table = ...; |
为了调用这个方法,Java虚拟机必须建立一个Pair\
可以采用两种方式来抑制这个警告。一种方法是为包含addAll调用的方法增加注解@SuppressWarnings(“unchecked”)。或者在Java7中,还可以用@SafeVarargs直接注解addAll方法:
1 |
|
现在就可以提供泛型类型来调用这个方法了。对于任何只需要读取参数数组元素的方法,都可以使用这个注解。
@SafeVarargs 只能用于声明为static、final或private的构造器和方法。所有其他方法都可能被覆盖,使得这个注解没有什么意义。
8.6.5 不能实例化类型变量
不能在类似new T(…)的表达式中使用类型变量。例如,下面的pair\
1 | public Pair() |
类型擦除将T变成Object,而你肯定不希望调用 new Object()。
在java8之后,最好的解决办法是让调用者提供一个构造器表达式。例如:
1 | Pair<String> p = Pair.makePair(String::new); |
makePair方法接受一个Supplier\
1 | public static <T> Pair<T> makePair(Supplier<T> constr) |
比较传统的解决方法是通过反射调用Constructor.newInstance方法来构造泛型对象。遗憾的是,细节有点复杂。不能调用以下方法:
1 | first = T.class.getConstructor().newInstance();//ERROR |
表达式T.class是不合法的,因为他会擦为Object.class。必须适当地设计API以便得到一个Class对象,如下所示:
1 | public static <T> Pair<T> makePair(Class<T> cl) |
这个方法可以如下调用:
1 | Pair<String> p = Pair.makePair(String.class); |
注意,Class类本身是泛型的。例如String.class是一个Class\
8.6.6 不能构造泛型数组
就像不能实例化泛型实例一样,也不能实例化数组。不过原因有所不同,毕竟数组可以填充nul值,看上去好像可以安全地构造。不过,数组本身也带有类型,用来监控虚拟机中的数组存储。这个类型会被擦除。例如,考虑下面的例子:
1 | public static <T extends Comparable> T[] ninmax(T...a) |
类型擦除会让这个方法总是构造Comparable[2]数组。
如果数组仅仅作为一个类的私有实例字段,那么可以将这个数组的元素类型声明为擦除的类型并使用强制类型转换。例如,ArrayList类可以如下实现:
1 | public class ArrayList<E> |
但实际的实现没有这么清晰
1 | public class ArrayList<E> |
这里,强制类型转换E[ ]是一个假象,而类型擦除使其无法察觉。
这个技术并不适合我们的minmax方法,因为ninmax方法返回一个T[ ]数组,如果类型不对,就会得到运行时错误结果。假设实现以下代码:
1 | public static <T extends Comparable> T[] minmax(T... a) |
以下调用
1 | String[] names = ArrayAlg.ninmax("Tom","Dick","Harry"); |
编译时不会有任何警告。当方法返回Comparable[ ]引用强制转换为String[ ]时,将会出现ClassCastException异常。
在这种情况下,最好让用户提供一个数组构造器表达式:
1 | String[] names = ArrayAlg.minmax(String[]::new,"Tom","Dick","Harry"); |
构造器表达式String::new 指示一个函数,给定所需的长度,会构造一个指定长度的String数组。
ninmax方法使用这个参数生成了一个有正确类型的数组:
1 | public static <T extends Comparable> T[] minmax(IntFunction<T[]> constr,T... a) |
比较老式的方法是利用反射,并调用Array.newInstance:
1 | public static <T extends Comparable> T[] minmax(T... a) |
ArrayList类的toArray方法就没有这么幸运。他需要生成一个T[ ]数组,但没有元素类型。因此,有下面两种不同的形式:
1 | Object[] toArray() |
第二个方法接受一个数组参数。如果数组足够大,就使用这个数组。否则,用result的元素类型构造一个足够大的新数组。
8.6.7 泛型类的静态上下文中类型变量无效
8.6.8 不能抛出或捕获泛型类的实例
8.6.9 可以取消对检查型异常的检查
8.6.10 注意擦除后的冲突
8.7 泛型类型的继承规则
在使用泛型类时,需要了解有关继承和子类型的一些规则。下面先从许多程序员感觉不太直观的情况开始介绍。考虑一个类和一个子类,如Employee和Manager。Pair\
无论S与T有什么关系,通常,Pair\与Pair\
最后,泛型类可以扩展或实现其他的泛型类。就这一点而言,它们与普通的类没有什么区别。例如,ArrayList\
8.8 通配符类型
8.8.1 通配符概念
在通配符类型中,允许类型参数发生变化。例如,通配符类
1 | Pair<? extends Employee> |
表示任何泛型Pair类型,它的类型参数是Employee的子类,如Pair\
假设要编写一个打印员工对的方法,如下所示:
1 | public static void printBuddies(pair<Employee> p) |
正如前面讲到的,不能将Pair\
1 | public static void printBuddies(Pair<? extends Employee> p) |
类型Pair\
使用通配符会通过Pair<? extends Employee>的引用破坏Pair\
1 | Pair<Manager> managerBuddies = new Pair<Manager>(ceo,cfo); |
这不可能引起破坏。对setFirst的调用有一个类型错误。要了解其中的缘由,请仔细看一看类型Pair<? extends Employee>
1 | ? extends Employee getFirst() |
这样讲不可能调用setFirst方法。编译器只知道需要Employee的某个子类型,但不知道具体是什么类型它拒绝传递特定的类型。毕竟?不能匹配。
使用getFirst就不存在这个问题:将getFirst的返回值赋给一个Employee引用是完全合法的。
这就是引入有限定的通配符的关键之处。现在已经有办法区分安全的访问器方法和不安全的更改器方法了
8.8.2 通配符的超类型限定
通配符限定与类型变量限定十分类似,但是,还有一个附加的能力,即可以指定一个超类型限定(supertype bound),如下所示:
1 | ? super Manager |
这个通配符限制为Manager的所有超类。
为什么要这样做呢?带有超类型限定的通配符的行为与8.8节介绍的相反。可以为方法提供参数,但不能使用返回值。例如,Pair<? super Manager>有如下方法:
1 | void setFirst(? super Manager) |
这不是真正的Java语法,但是可以看出编译器知道什么。编译器无法知道setFirst方法的具体类型,因此不能接受参数类型为Employee或Object的方法调用。只能传递Manager类型的对象,或者某个子类型对象。另外,如果调用getFirst,不能保证返回对象的类型。只能把它赋给一个Object。
下面是一个典型示例。我们有一个经理数组,并且想把奖金最高和最低的经理放在一个Pair对象中,Pair的类型是什么?在这里,Pair\
1 | public static void minmaxBonus(Manager[] a, Pair<? super Manager> result) |
直观地将,带有超类型限定的通配符允许你写入一个泛型对象,而带有子类型限定的通配符允许你读取一个泛型对象。
下面是超类型限定的另一个应用。Comparable接口本身就是一个泛型类型。声明如下:
1 | public interface Comparable<T> |
在这里,类型变量指示了other参数的类型。例如String类实现了Comparable\
1 | public int compareTo(Stirng other) |
这很好,显式的参数有一个正确的类型。接口是泛型接口之前,other是一个Object,这个方法的实现中必须有一个强制类型转换。
由于Comparable是一个泛型类型,也许可以把ArrayAlg类的min方法做得更好一些?可以这样声明:
1 | public static <T extends Comparable<T>> T min(T[] a) |
看起来,这样写比只使用T extends Coparable更彻底,并且对于许多类来讲,这样工作得更好。例如,如果计算一个String数组得最小值,T就是类型String,而String是Comparable\
在这种情况下,可以利用超类型来解决:
1 | public static <T extends Comparable<? super T>> T min(T[] a)... |
现在compareTo方法写成
1 | int compareTo(? super T) |
它可以声明为使用类型T的对象,或者也可以是使用T的一个超类型的对象。无论如何,向compareTo方法传递一个T类型的对象是安全的。
对于初学者来说,类似
8.8.3 无限定通配符
还可以使用根本无限定的通配符,例如,Pair\<?>。初看起来,这好像与原始的Pair类型一样。实际上,这两种类型有很大的不同。类型Pair\<?>有以下方法:
1 | ? getFirst() |
getFirst()的返回值只能赋给一个Object。setFirst方法不能被调用,甚至不能用Object调用。Pair\<?>和Pair本质的不同在于:可以用任意Object对象调用原始Pair类的setFirst方法。
为什么要使用这样一个脆弱的类型?他对于很多简单操作非常有用。例如,下面这个方法可用来测试一个对组是否包含一个null引用,它不需要实际的类型。
1 | public static boolean hasNulls(Pair<?> p) |
通过将hasNulls转换成泛型方法,可以避免使用通配符类型:
1 | public static <T> boolean hasNulls(Pair<T> p) |
但是,带有通配符的版本可读性更好。
8.8.4 通配符捕获
1 | package pair3; |
1 | package pair3; |
1 | package pair3; |
1 | package pair3; |
8.9 反射和泛型
反射允许你在运行时分析任意对象。如果对象是泛型类的实例,关于泛型类型参数你将得不到太多信息,因为它们已经被擦除了。在下面的小节中,我们将学习利用反射可以获得泛型类的哪些信息。
8.9.1 泛型Class类
现在,Class类是泛型的。例如,String.class实际上是一个Class\
类型参数十分有用,这是因为它允许Class\
1 | T newInstance() |
8.9.2 使用Class\参数进行类型匹配
匹配泛型方法中Class\
1 | public static <T> Pair<T> makePair(Class<T> c) throws InstantiationException,IllegalAccessException |
如果调用
1 | makePair(Employee.class) |
Employee.class将是一个Class\
8.9.3 虚拟机中的泛型类型信息
Java泛型的突出特性之一是在虚拟机中擦除泛型类型。令人奇怪的是,擦除的类仍然保留原先泛型的微弱记忆。例如,原始Pair类知道它源于泛型类Pair\
类似地,考虑以下方法:
1 | public static Comparable min(Comparable[] a) |
这是擦除以下泛型方法得到的:
1 | public static <T extends Comparable<? super T>> T min(T[] a) |
可以使用反射API来确定:
- 这个泛型方法有一个名为T的类型参数。
- 这个类型参数有一个子类型限定,其自身又是一个泛型类型。
- 这个限定类型有一个通配符参数。
- 这个通配符参数有一个超类型限定。
- 这个泛型方法有一个泛型数组参数。
换句话说,你可以重新构造实现者声明的泛型类和方法的所有有关内容。但是,你不会知道对于特定的对象或方法调用会如何解析类型参数。
为了表述泛型类型声明,可以使用java.lang.reflect包中的接口Type。这个接口包含以下子类型:
- Class类,描述具体类型
- TypeVariable接口,描述类型变量(如 T extends Comparable<? super T>)
- wildcardType接口,描述通配符(如?super T)
- ParameterizedType接口,描述泛型类或接口类型(如Comparable<? super T>)
- GenericArrayType接口,描述泛型数组(如 T[ ])
1 | package genericReflection; |