8.2 解析通配符
8.2 解析通配符
本节主要讨论泛型中的通配符概念。通配符有着令人费解和混淆的语法,但通配符大量应用于Java容器类中,它到底是什么?下面我们逐步来解析。
8.2.1 更简洁的参数类型限定
在8.1节最后,我们提到一个例子,为了将Integer对象添加到Number容器中,我们的类型参数使用了其他类型参数作为上界,我们提到,这种写法有点烦琐,它可以替换为更为简洁的通配符形式:
1 | public void addAll(DynamicArray<? extends E> c) { |
这个方法没有定义类型参数,c的类型是DynamicArray<? extends E>
, ?表示通配符,<? extends E>
表示有限定通配符,匹配E或E的某个子类型,具体什么子类型是未知的。使用这个方法的代码不需要做任何改动,还可以是:
1 | DynamicArray<Number> numbers = new DynamicArray<>(); |
这里,E是Number类型,DynamicArray<? extends E>
可以匹配DynamicArray<Integer>
。
那么问题来了,同样是extends关键字,同样应用于泛型,<T extends E>
和<?extends E>
到底有什么关系?它们用的地方不一样,我们解释一下:
1)<T extends E>
用于定义类型参数,它声明了一个类型参数T,可放在泛型类定义中类名后面、泛型方法返回值前面。
2)<? extends E>
用于实例化类型参数,它用于实例化泛型变量中的类型参数,只是这个具体类型是未知的,只知道它是E或E的某个子类型。
虽然它们不一样,但两种写法经常可以达成相同目标,比如,前面例子中,下面两种写法都可以:
1 | public void addAll(DynamicArray<? extends E> c) |
那么,到底应该用哪种形式呢?我们先进一步理解通配符,然后再解释。
8.2.2 理解通配符
除了有限定通配符,还有一种通配符,形如DynamicArray<? >,称为无限定通配符。我们来看个例子,在DynamicArray中查找指定元素,代码如下:
1 | public static int indexOf(DynamicArray<? > arr, Object elm){ |
其实,这种无限定通配符形式也可以改为使用类型参数。也就是说,下面的写法:
1 | public static int indexOf(DynamicArray<? > arr, Object elm) |
可以改为:
1 | public static <T> int indexOf(DynamicArray<T> arr, Object elm) |
不过,通配符形式更为简洁。虽然通配符形式更为简洁,但上面两种通配符都有一个重要的限制:只能读,不能写。怎么理解呢?看下面的例子:
1 | DynamicArray<Integer> ints = new DynamicArray<>(); |
三种add方法都是非法的,无论是Integer,还是Number或Object,编译器都会报错。为什么呢?问号就是表示类型安全无知,? extends Number表示是Number的某个子类型,但不知道具体子类型,如果允许写入,Java就无法确保类型安全性,所以干脆禁止。我们来看个例子,看看如果允许写入会发生什么:
1 | DynamicArray<Integer> ints = new DynamicArray<>(); |
如果允许写入Object或Number类型,则最后两行编译就是正确的,也就是说,Java将允许把Double或String对象放入Integer容器,这显然违背了Java关于类型安全的承诺。
大部分情况下,这种限制是好的,但这使得一些理应正确的基本操作无法完成,比如交换两个元素的位置,看如下代码:
1 | public static void swap(DynamicArray<? > arr, int i, int j){ |
这个代码看上去应该是正确的,但Java会提示编译错误,两行set语句都是非法的。不过,借助带类型参数的泛型方法,这个问题可以如下解决:
1 | private static <T> void swapInternal(DynamicArray<T> arr, int i, int j){ |
swap可以调用swapInternal,而带类型参数的swapInternal可以写入。Java容器类中就有类似这样的用法,公共的API是通配符形式,形式更简单,但内部调用带类型参数的方法。
除了这种需要写的场合,如果参数类型之间有依赖关系,也只能用类型参数,比如,将src容器中的内容复制到dest中:
1 | public static <D, S extends D> void copy(DynamicArray<D> dest, |
S和D有依赖关系,要么相同,要么S是D的子类,否则类型不兼容,有编译错误。不过,上面的声明可以使用通配符简化,两个参数可以简化为一个,如下所示:
1 | public static <D> void copy(DynamicArray<D> dest, |
如果返回值依赖于类型参数,也不能用通配符,比如,计算动态数组中的最大值,如下所示:
1 | public static <T extends Comparable<T>> T max(DynamicArray<T> arr){ |
上面的代码就难以用通配符代替。
现在我们再来看泛型方法到底应该用通配符的形式还是加类型参数。两者到底有什么关系?我们总结如下。
1)通配符形式都可以用类型参数的形式来替代,通配符能做的,用类型参数都能做。
2)通配符形式可以减少类型参数,形式上往往更为简单,可读性也更好,所以,能用通配符的就用通配符。
3)如果类型参数之间有依赖关系,或者返回值依赖类型参数,或者需要写操作,则只能用类型参数。
4)通配符形式和类型参数往往配合使用,比如,上面的copy方法,定义必要的类型参数,使用通配符表达依赖,并接受更广泛的数据类型。
8.2.3 超类型通配符
还有一种通配符,与形式<? extends E>正好相反,它的形式为<? super E>,称为超类型通配符,表示E的某个父类型。它有什么用呢?有了它,我们就可以更灵活地写入了。
如果没有这种语法,写入会有一些限制。来看个例子,我们给DynamicArray添加一个方法:
1 | public void copyTo(DynamicArray<E> dest){ |
这个方法也很简单,将当前容器中的元素添加到传入的目标容器中。我们可能希望这么使用:
1 | DynamicArray<Integer> ints = new DynamicArray<Integer>(); |
Integer是Number的子类,将Integer对象拷贝入Number容器,这种用法应该是合情合理的,但Java会提示编译错误,理由我们之前也说过了,期望的参数类型是Dynamic-Array<Integer>
, DynamicArray<Number>
并不适用。
如之前所说,一般而言,不能将DynamicArray<Integer>
看作DynamicArray<Number>
,但我们这里的用法是没有问题的,Java解决这个问题的方法就是超类型通配符,可以将copyTo代码改为:
1 | public void copyTo(DynamicArray<? super E> dest){ |
这样,就没有问题了。
超类型通配符另一个常用的场合是Comparable/Comparator接口。同样,我们先来看下如果不使用会有什么限制。以前面计算最大值的方法为例,它的方法声明是:
1 | public static <T extends Comparable<T>> T max(DynamicArray<T> arr) |
这个声明有什么限制呢?举个简单的例子,有两个类Base和Child, Base的代码是:
1 | class Base implements Comparable<Base>{ |
Base代码很简单,实现了Comparable接口,根据实例变量sortOrder进行比较。Child代码是:
1 | class Child extends Base { |
这里,Child非常简单,只是继承了Base。注意:Child没有重新实现Comparable接口,因为Child的比较规则和Base是一样的。我们可能希望使用前面的max方法操作Child容器,如下所示:
1 | DynamicArray<Child> childs = new DynamicArray<Child>(); |
遗憾的是,Java会提示编译错误,类型不匹配。为什么不匹配呢?我们可能会认为,Java会将max方法的类型参数T推断为Child类型,但类型T的要求是extendsComparable<T>
,而Child并没有实现Comparable<Child>
,它实现的是Comparable<Base>
。
但我们的需求是合理的,Base类的代码已经有了关于比较所需要的全部数据,它应该可以用于比较Child对象。解决这个问题的方法,就是修改max的方法声明,使用超类型通配符,如下所示:
1 | public static <T extends Comparable<? super T>> T max(DynamicArray<T> arr) |
这么修改一下就可以了,这种写法比较抽象,将T替换为Child,就是:
1 | Child extends Comparable<? super Child> |
<? super Child>
可以匹配Base,所以整体就是匹配的。
我们比较一下类型参数限定与超类型通配符,类型参数限定只有extends形式,没有super形式,比如,前面的copyTo方法的通配符形式的声明为:
1 | public void copyTo(DynamicArray<? super E> dest) |
如果类型参数限定支持super形式,则应该是:
1 | public <T super E> void copyTo(DynamicArray<T> dest) |
事实是,Java并不支持这种语法。
前面我们说过,对于有限定的通配符形式<? extends E>
,可以用类型参数限定替代,但是对于类似上面的超类型通配符,则无法用类型参数替代。
8.2.4 通配符比较
本节介绍了泛型中的三种通配符形式<? >
、<? super E>
和<? extends E>
,并分析了与类型参数形式的区别和联系,它们比较容易混淆,我们总结比较如下:
1)它们的目的都是为了使方法接口更为灵活,可以接受更为广泛的类型。
2)**<? super E>
用于灵活写入或比较,使得对象可以写入父类型的容器,使得父类型的比较方法可以应用于子类对象,它不能被类型参数形式替代。
3)<? >
和<? extends E>
用于灵活读取**,使得方法可以读取E或E的任意子类型的容器对象,它们可以用类型参数的形式替代,但通配符形式更为简洁。
Java容器类的实现中,有很多使用通配符的例子,比如,类Collections中就有如下方法:
1 | public static <T extends Comparable<? super T>> void sort(List<T> list) |
通过前面两节,我们应该可以理解这些方法声明的含义了。关于泛型,还有一些细节以及限制,让我们下一节继续探讨。