正如item28所指出得,参数化类型是不变的。还击话说,对于type1和type2这两种不同的类型,List<Type1>既不是List<Type2>的子类型也不是父类型。虽然List<String>不是List<Object>的子类型是反直觉的,但是它确实是有意义的。你可以将任何对象放入List<Object>,但是你只能将String类型放入List<String>,由于List<String>不能完成List<Object>的所有操作,所以它不是子类型(里氏代换原则,item10).
有时候,你需要比不变量类型所能提供的更多的灵活性。考虑Item29的Stack类。为了刷新你的内存,这是它的公有API:
假设我们要添加一个方法,该方法接受一系列元素并将他们全部推到Stack上。如下是第一次尝试:
image.png
这种方法编译清晰,但并不完全令人满意。如果迭代器src的元素类型与Stack的类型匹配,它能正常工作。但是假设你有Stack<Number>,并且你调用了push(intVal),intVal是Integer类型。这能正常工作因为Integer是Number的子类。所以逻辑上,它看起来这也应该正常工作:
image.png
如果你这么尝试了,你会得到如下错误信息,因为参数化类型是不可变的:
image.png
幸运的是,有一条出路。语言提供了一种特殊的参数化类型调用-有界通配符类型,以处理这种情况。输入参数的类型应该不是“Iterable of E”而是“Iterable of some subtype of E”.有一种通配符类型,它的确切意思是:Iterable<? extends E>.(extends关键字的使用是略微误导了:回想一下item29,即定义了子类型,以便每个类型都是自身的一个子类型,即使它不扩展自己),让我们一起修改pushAll来使用这个类型:
image.png
随着此更改,Stack不仅可以干净地编译,而且不会使用原来的pushAll声明编译的客户端代码也一样。因为Stack及其客户端编译干净,所以你知道所有的东西都是类型安全的。
现在假设你想要编写一个popAll方法与pushAll类似。popAll方法弹出Stack的每个元素,并将元素添加到给定的集合中。这是如何编写popAll方法的初次尝试的样子:
image.png
同样,编译干净切工作良好,如果目标集合的元素类型与Stack的元素类型完全匹配。但是同样,这并不完全令人满意。假设你有Stack<Number>和Object类型的变量。如果你从stack中pop一个元素并在变量中存储,它编译通过并运行没有错误。所以你不能这么做吗?
image.png
如果你尝试根据前面显示的popAll版本编译该客户端代码,你将得到一个非常类似我们使用第一个版本的pushAll:Collection<Object>不是Collection<Number>的子类。再一次,通配符类型提供了一条出路。popAll的输入参数应该不是”collection of E“,而是”collection of some supertype of E“.(其中,超类中有定义为本身即本身的超类 [JLS, 4.10])。同时,有一个通配符类型,它精确地标识: Collection<? super E>.让我们修改popAll方法来使用:
image.png
随着这个改变,Stack和客户端代码编译都很清晰。
这个教训很清晰。为了最大的灵活性,在输入参数上使用通配符类型代表了生产者或消费者。如果一个输入参数都是生产者和一个消费者,那么通配符类型对你没有好处:你需要一个精确的类型匹配,这就是没有任何通配符的情况。
下面是帮助你记住使用哪种通配符类型的助记符:
PECS stands for producer-extends, consumer-super
换句话说,如果一个参数化类型代表了T生产者,使用<? extends T>;如果代表了一个T 消费者,使用<? super T>.在我们的Stack 例子中,通过Stack的pushAll的src参数生产者E实例的使用,所以src的合适的类型是Iterable<? extends E>;popAll的dst参数从stack消费E实例,所以dst的合适的类型是Collection<? super E>.PESC助记符捕获了指导通配符类型使用的基本原则。Naftalin和Wadler称其为GET和PUT原则 [Naftalin07, 2.4]。
随着内心有着这个助记符,让我们看看在本章前几项中的一些方法和构造方法声明。在item28中的Chooser构造方法有如下声明:
这个构造方法只使用集合选项来生成T类型的值(并存储它们供以后使用),因此它的声明应该使用扩展T的通配符类型。下面是生成的构造方法声明:
image.png
这种变化在实际上有改变吗?是的,它有。假设你有一个List<Integer>,你希望将它传递给Chooser<Numer>的构造方法。这将不会使用原始声明进行编译,但一旦将有界通配符类型添加到声明中,则会进行编译。
现在,让我们一起从Item30中看union方法。这是声明:
image.png
s1和s2和生产者E,根据PECS助记告诉我们声明应该是这样的:
image.png
注意到返回类型仍然是Set<E>。不要将有界通配符作为返回类型。它将迫使用户在客户端代码中使用通配符类型,而不是为用户提供额外的灵活性。使用修改吼的声明,该代码将干净地编译:
如果使用得当,类的用户几乎看不到通配符类型。它们导致方法接受它们应该接受的参数,并拒绝它们应该拒绝的参数。如果类的用户不得不考虑通配符类型,可能是API出错了。
在Java8之前,类型推断规则并不足够智能,无法处理前面的代码片段,这要求编译器使用上限为指定的返回类型(或目标类型)来推断E的类型。前面显示的联合调用的目标类型是Set<Number>.如果你尝试在早期版本的Java中编译该片段(对工厂Set.of进行适当的替换),你将得到一条冗长,复杂的错误消息,如下所示:
幸运的是,有一种方法可以处理这类错误。如果编译器不能推断正确的类型,你可以总是告诉它在显示类型中使用哪种类型 [JLS, 15.12]。即使在Java8中引入目标类型之前,这也不是你必须经常做的事情,这很好,因为显式类型参数不是很漂亮。通过添加显式类型参数(如此处所示),代码片段在Java8之前的版本中可以清晰地编译:
image.png
接下来,让我们关注item30的max方法,这是最初的声明:
image.png
这是使用通配符类型修改的声明:
image.png
为了得到修改后的声明,我们使用了两次PECS的启发。简单的应用程序是参数列表。它生成T实例,所以我们将类型从List<T>更改为List<? extends T>.棘手的应用是类型参数T。这是我们第一次看到通配符应用于类型参数。起初,指定T来扩展Comapable<T>,但可比较的T消费T实例(并生成指示顺序关系的整数)。因此,参数化类型Comparable<T>被有界通配符替换为Comparable<? super T>.可比较性始终是消费者,所以你通常使用Comparable<? super T>来代替Comparable<T>.Comparators也是一样;因此,你应该通常使用Comparator<? super T>优先于Comparator<T>.
修订后的max声明可能是本书中最复杂的方法声明。增加的复杂性真的给你带来了什么吗?再说一次,的确如此。下面是一个清单的简单例子,它将被原始声明排除在外,但经修订的声明却允许这样做:
不能将原始方法声明应用于此列表的原因是ScheduledFuture并没有实现Comparable<ScheduledFuture>.相反,它是Delay的子接口,它extends了Comparable<Delayed>。换句话说,一个ScheduledFuture实例不仅仅与其他ScheduledFuture的实例比较;它还要与任何Delayed实例比较,这足以导致原始声明拒绝它。更普遍地说,通配符需要支持那些不直接实现可比较(或比较器)但扩展了可比较(或比较器)类型的类型。
还有一个与通配符相关的话题指的讨论。类型参数与通配符之间存在二元性,可以使用其中一种或另一种来声明许多方法。例如,下面是用于交换列表中两个索引项的静态方法的两个可能的声明。第一个参数使用无界类型参数( item30),第二个参数使用无界通配符。
这两种声明哪个更可取,为什么?在公有API,第二个更好,因为它更简单。你传递了一个列表-任何列表-方法叫喊了元素的索引。没有类型参数来担心。作为一个规则,如果类型参数在方法声明中只出现一次,则用通配符替换它。如果它是一个无界类型参数,用一个无界通配符替换它;如果它是有界类型参数,则用有界通配符替换它。
swap的第二个声明有一个问题。简单的实现不会编译。
试图编译它会产生以下不太有用的错误消息:
image.png
似乎我们不能把一个元素放回我们刚刚从列表中删除的列表中,这似乎是不对的。问题在于,list的类型是List<?>,不能将除了null以外的任何值放入List<?>.幸运的是,有一种方法可以实现此方法,而不必求助于不安全的类型或原始类型。这个想法是编写一个私有辅助方法来捕获通配符类型。这个辅助方法必须是为了捕获类型的泛型方法。这是它的样子:
image.png swapHelper方法知道list的类型是List<E>.因此,它知道它从这个list中得到的任何值都是E类型的,将E类型的任何值放入list是安全的。这个稍微复杂的交换实现可以清晰地编译。它允许我们导出基于通配符的声明,同时利用内部更复杂的泛型方法。交换方法的客户端不必面对更复杂的swapHelper声明,但是它们确实从中受益。值得注意的是,helper方法具有我们认为对公共方法来说过于复杂的签名。
总之,在你的APIs中使用通配符类型,虽然有点棘手但是使得API更加灵活。如果编写了一个将被广泛使用的库,则应该认为正确使用通配符类型是强制性的。记住基本规则:producer-extends, consumer-super (PECS)。也记住所有可比较的和比较器都是消费者。
本文写于2019.6.27,历时1天
网友评论