在Java语言中,数组是协变的(因为Integer
也是Number
, Integer
的数组也是Number
的数组),但泛型不是( List<Integer>
不是 List<Number>
。)人们可以争论关于哪种选择是“正确的”和哪种选择是“错误的”(当然两者都有优缺点),但是毫无疑问,拥有两种相似的机制来构造具有微妙不同语义的派生类型是造成混乱和错误的重要原因。
界通配符(一些有趣的“ ? extends T
”通用类型说明符)是语言提供了处理缺乏协方差的工具之一-他们让类声明时方法的参数或者返回值是协变的(或者相反, 逆变 )。 虽然知道何时使用有界通配符是泛型的更复杂方面之一,但使用它们的负担主要落在库编写者而不是库用户身上。 有界通配符最常见的错误是根本忘记使用它们,从而限制了类的实用性,或者强迫用户跳过循环以重用现有的类。
需要有界通配符
让我们从一个简单的通用类开始,一个名为Box
的值容器,其中包含一个已知类型的值:
public interface Box<T> {
public T get();
public void put(T element);
}
因为泛型不是协变的,所以即使Integer
是Number
, Box<Integer>
也不是Box<Number>
。 但是对于像Box
这样的简单泛型类来说,这不是问题,实际上我们甚至可能不会注意到,因为Box<T>
的接口完全是根据类型T的变量指定的-而不是通过T生成的类型。直接在类型变量方面允许我们想要免费实现的多态性。 清单1显示了这种多态性的两个示例:将Box<Integer>
的内容作为Number
检索,并将Integer
放入Box<Number>
:
清单1.利用泛型类利用固有的多态性
Box<Integer> iBox = new BoxImpl<Integer>(3);
Number num = iBox.get();
Box<Number> nBox = new BoxImpl<Number>(3.2);
Integer i = 3;
nBox.put(i);
我们对这个简单Box
类的经验使我们确信不需要协方差,因为在我们期望多态的地方,数据已经处于一种形式,在这种形式下,编译器能够应用适当的子类型化规则。
然而,事情变得更加复杂,当我们希望API不仅处理类型T的变量,而且处理类型T上生成的类型时。假设我们想向Box
添加一个新方法,该方法允许我们从另一个Box
获取内容。并将其放在这一行中,如清单2所示:
清单2.扩展的Box接口,它看起来并不灵活
public interface Box<T> {
public T get();
public void put(T element);
public void put(Box<T> box);
}
这个扩展Box
的问题在于我们只能将内容放入类型参数与接收箱完全相同的Box
中。 因此,例如,清单3中的代码将无法编译:
清单3.泛型不是协变的
Box<Number> nBox = new BoxImpl<Number>();
Box<Integer> iBox = new BoxImpl<Integer>();
nBox.put(iBox); // ERROR
我们收到一条错误消息,告诉我们在Box<Number>
上找不到方法put(Box<Integer>)
。 当我们认为泛型不是协变时,此错误才有意义。 即使Integer
是Number
, Box<Integer>
也不是Box<Number>
,但这使Box
类感觉不到我们希望的“泛型”。 为了提高通用代码的实用性,我们可以指定上限(或下限),而不是指定泛型类型参数的确切类型。 为此,我们使用有界通配符,其形式为“ ? extends T
”或“ ? super T
”。 (有界通配符只能用作类型参数;它们本身不能作为类型出现-为此,必须有界的命名型变量。)在清单4中,我们将put()
的签名更改为使用上限通配符- Box<? extends T>
Box<? extends T>
这意味着该类型参数Box
可以是T
或任何亚类T
。
清单4.清单3中Box类的改进版本,它说明了协方差
public interface Box<T> {
public T get();
public void put(T element);
public void put(Box<? extends T> box);
}
现在, 清单3中的代码将编译并执行我们想要的操作,因为我们已经说过put()
的参数可以是Box
其类型参数是T或其任何子类型。 因为Integer
是Number
的子类型,所以编译器能够解析方法引用put(Box<Integer>)
因为Box<Integer>
匹配有界通配符Box<? extends Number>
Box<? extends Number>
。
Box
的早期版本中的“错误”很容易陷入。 即使是专家也犯了这个错误-您可以在平台类库中找到许多使用Collection<T>
代替Collection<? extends T>
Collection<? extends T>
。 例如,在java.util.concurrent包的AbstractExecutorService
中, invokeAll()
的参数最初是Collection<Callable<T>>
。 但是,这使使用invokeAll()
变得相当麻烦,因为这要求任务集必须由完全由Callable<T>
参数化的集合来保存,而不是由实现Callable<T>
某些类参数化的集合来保存。 在Java 6中,此签名已更改为Collection<? extends Callable<T>>
Collection<? extends Callable<T>>
-但是为了说明容易犯此错误,正确的解决方法是使invokeAll()
接受Collection<? extends Callable<? extends T>>
的参数Collection<? extends Callable<? extends T>>
Collection<? extends Callable<? extends T>>
Collection<? extends Callable<? extends T>>
。 后者绝对丑陋,但好处是不会在您的客户中装箱。
下界通配符
大多数有界通配符都位于上面; "? extends T
”符号在类型上设置了上限。 尽管不太常见,也可以在类型的下限上加上符号“ ? super T
”,表示“ T或T的任何超类”。 当您要指定回调对象(例如比较器)或要在其中放置值的数据结构时,将显示下界通配符。
假设我们要增强Box
的功能,使其能够将内容与另一个Box的内容进行比较。 我们可以使用containsSame()
方法和Comparator
回调对象的定义来扩展Box
,如清单5所示:
清单5.在Box中添加比较方法的尝试过于严格
public interface Box<T> {
public T get();
public void put(T element);
public void put(Box<? extends T> box);
boolean containsSame(Box<? extends T> other,
EqualityComparator<T> comparator);
public interface EqualityComparator<T> {
public boolean compare(T first, T second);
}
}
我们记得要使用通配符在containsSame()
中定义另一个框的类型,这样可以避免我们之前遇到的问题。 但是我们仍然有一个类似的问题。 比较器参数必须恰好是EqualityComparator<T>
。 这意味着我们无法编写清单6中的代码:
清单6.尝试使用清单5中的比较方法时失败
public static EqualityComparator<Object> sameObject
= new EqualityComparator<Object>() {
public boolean compare(Object o1, Object o2) {
return o1 == o2;
}
};
...
BoxImpl<Integer> iBox = ...;
BoxImpl<Number> nBox = ...;
boolean b = nBox.containsSame(iBox, sameObject);
在这里使用EqualityComparator<Object>
似乎是一件很合理的事情。 当客户可以通用指定Box
时,为什么必须为每种可能的Box
类型创建一个单独的比较器? 解决方案是使用下界通配符-由“ ? super T
”表示。 清单7显示了Box
类的正确版本,并用compareTo()
方法进行了扩展:
清单7.使用有界通配符的清单5中比较操作的更灵活版本
public interface Box<T> {
public T get();
public void put(T element);
public void put(Box<? extends T> box);
boolean containsSame(Box<? extends T> other,
EqualityComparator<? super T> comparator);
public interface EqualityComparator<T> {
public boolean compare(T first, T second);
}
}
通过使用下界通配符, containsSame()
方法表示它需要可以比较T 或其任何超类型的东西 ,从而使我们能够提供一个比较器,该比较器知道如何比较对象,而不必用EqualityComparator<Number>
对其进行包装。 EqualityComparator<Number>
。
产量原则
有句老话说:“有一只手表的男人总是知道现在几点;有两把手表的男人永远不确定。” 由于该语言同时支持上限和下限通配符,因此我们如何知道要使用哪个通配符?何时使用?
有一个简单的规则称为get-put Principle ,它告诉我们要使用哪种通配符。 在Naftalin和Wadler的关于泛型, Java Generics和Collections的精美著作中(见参考资料),get-put原则说:
当您仅从结构中获取值时,请使用extends
通配符;仅将值放入结构中时,请使用super
通配符;当两者都使用时,请不要使用通配符。
将get-put原理应用于Box
类或Collections类之类的容器类时,最容易理解,因为获取或放置的概念与这些类的作用自然相关:存储事物。 因此,如果我们想应用get-put原理来创建从一个Box
复制到另一个Box
的方法,则最通用的形式如清单8所示,其中上限使用通配符,而下限使用通配符。界通配符用于目的地:
清单8.使用上限和下限通配符的Box复制方法
public static<T> void copy(Box<? extends T> from, Box<? super T> to) {
to.put(from.get());
}
在前面显示的containsSame()
方法的情况下,我们如何应用get-put原理,在该方法中,对框使用上限的通配符,对比较器使用下限的通配符? 第一部分很简单:我们正在从另一个框中获取值,因此我们需要使用extends
通配符。 但是第二部分并不清楚—因为比较器不是容器,所以感觉不像是从数据结构中获取或放入数据结构。
当数据类型显然不是像集合这样的容器类时,考虑get-put原理的方法是,即使EqualityComparator
不是数据结构,也仍然可以将值“放入”中-将值传递给其中一种方法的意义。 在containsSame()
方法中,您将Box
用作值的产生器(从Box
检索值),并将比较器用作值的使用者(将值传递给比较器)。 因此,对Box
使用extends
通配符是有意义的,而对比较器则使用super
通配符。
我们可以在Collections.sort()
的声明中看到get-put原理在起作用,如清单9所示:
清单9.使用下界通配符的另一个示例
public static <T extends Comparable<? super T>> void sort(List<T>list) { ... }
在这里,我们说我们可以对一个List
进行排序,该List
由实现Comparable
任何类型参数化。 但是,我们不仅可以将sort()
限制为元素可与自己进行比较的列表,而且可以走得更远—我们还可以对知道如何将自身与父类型进行比较的元素列表进行排序。 因为我们将值放入比较器中以确定两个元素的相对顺序,所以get-put原理告诉我们我们要在此处使用超级通配符。
看似循环的引用( T
扩展了由T
参数)实际上根本不是循环的。 它只是表示要对List<T>
进行排序的约束, T
必须实现接口Comparable<X>
,其中X
是T
或它的超类型之一。
规则的最后一部分(在获取和放置时都不要使用通配符)从前两部分开始。 如果可以放置T
或其任何子类型,并且可以获取T
或其任何超类型,那么唯一可以获取和放置的就是T
本身。
将有界通配符放在返回值之外
有时很想在方法的返回类型中使用有界通配符。 但是最好避免这种诱惑,因为返回有界通配符会“污染”客户端代码。 如果要返回Box<? extends T>
Box<? extends T>
,则接收返回值的变量的类型必须为Box<? extends T>
Box<? extends T>
,这增加了在调用者上处理有界通配符的负担。 有界通配符在API中而不是在客户端代码中使用时效果最佳。
摘要
有界通配符对于使通用API更加灵活非常有用。 正确使用有界通配符的最大障碍是人们认为我们不需要使用它们! 在某些情况下,需要使用下限通配符,而在某些情况下,则需要使用上限通配符,并且可以使用get-put原理确定应使用的通配符。
翻译自: https://www.ibm.com/developerworks/java/library/j-jtp07018/index.html