自从将泛型添加到JDK 5中以来,泛型一直是一个有争议的话题。有人说,泛型通过扩展类型系统的作用范围以及因此编译器验证类型安全性的能力来简化编程。 其他人则说,他们增加的复杂性超出了他们的价值。 我们都曾经历过一些泛型难题,但是泛型中最棘手的部分是通配符。
通配符基础
泛型是用未知类型表达对类或方法的行为的类型约束的一种手段,例如“此方法的参数x
和y
的类型必须相同,”,“您必须提供相同类型,以这两种方法的一个参数,”或‘的返回值foo()
是相同的类型的参数bar()
’。
通配符(一种类型参数应该放在的时髦问号)是一种用未知类型表示类型约束的方法。 它们不是泛型的原始设计的一部分(源自Generic Java(GJ)项目); 在从JSR 14形成到最终发布的五年中,随着设计过程的进行,添加了它们。
通配符在类型系统中起重要作用; 它们为通用类指定的类型族提供了有用的类型绑定。 对于泛型类ArrayList
,类型ArrayList<?>
是任何(引用)类型T
的ArrayList<T>
的超类型(原始类型ArrayList
和根类型Object
,但这些超类型对执行类型的用处远不如此)推理)。
通配符类型List<?>
与原始类型List
和具体类型List<Object>
。 说一个变量x
类型为List<?>
意味着存在某个类型T
,其中x
的类型为List<T>
,即使我们不知道其元素具有哪种特定类型,该x
也是同质的。 这并不是说内容可以是任何东西,那就是我们不知道是什么的内容的类型约束-但我们知道, 有一个约束。 另一方面,原始类型List
是异构的; 我们不能对其元素施加任何类型约束,而具体类型List<Object>
意味着我们明确知道它可以包含任何对象。 (当然,泛型类型系统没有“列表内容”的概念,但是从诸如List
的集合类型的角度理解泛型是最容易的。)
通配符在类型系统中的用途部分是由于泛型类型不是协变的。 数组是协变的; 因为Integer
是Number
的子类型,所以数组类型Integer[]
是Number[]
的子类型,因此,只要需要Number[]
的值,就可以提供Integer[]
值。 另一方面,泛型不是协变的。 List<Integer>
不是List<Number>
的子类型,尝试在需要List<Number>
地方提供List<Integer>
是类型错误。 这不是偶然的事件,也不是每个人都认为是的错误,但是泛型和数组的不同行为确实引起了很多混乱。
我收到通配符-现在怎么办?
清单1显示了一个简单的容器类型Box
,它支持put
和get
操作。 Box
由类型参数T
参数化,该类型参数T
表示Box
内容的类型。 Box<String>
只能包含String
类型的元素。
清单1.简单的通用Box类型
public interface Box<T> {
public T get();
public void put(T element);
}
通配符的一个好处是,它们使您可以编写可对通用类型的变量进行操作的代码,而无需知道它们的确切类型界限。 例如,假设您有一个类型为Box<?>
的变量,例如清单2中方法unbox()
中的box
参数,那么unbox()
对已处理过的盒子有什么作用?
清单2.带通配符参数的Unbox方法
public void unbox(Box<?> box) {
System.out.println(box.get());
}
事实证明,它可以做很多事情:它可以调用get()
方法,并且可以调用从Object
继承的任何方法(例如hashCode()
)。 它唯一不能做的就是调用put()
方法,这是因为在不知道此Box
实例的类型参数T
情况下,它无法验证这种操作的安全性。 因为box
是Box<?>
,而不是原始Box
,所以编译器知道有一些T
用作box
的类型参数,但是由于它不知道T
是什么,因此它不会让您调用put()
因为它无法验证这样做不会违反Box
的类型安全约束。 (实际上,在一种特殊情况下,您可以调用put()
:当您传递null
文字时。我们可能不知道T
表示什么类型,但是我们知道null
文字对于任何引用类型都是有效值。)
是什么unbox()
了解的返回类型box.get()
它知道某个未知T
值为T
,因此最好的结论是, get()
的返回类型是擦除未知类型T
,在无界通配符的情况下为Object
。 因此,清单2中的box.get()
表达式具有Object
类型。
通配符捕获
清单3显示了一些看起来应该起作用但不起作用的代码。 它需要一个通用Box
,提取值,然后尝试将值放回同一Box
。
清单3.开箱即用后,您将无法放回原处
public void rebox(Box<?> box) {
box.put(box.get());
}
Rebox.java:8: put(capture#337 of ?) in Box<capture#337 of ?> cannot be applied
to (java.lang.Object)
box.put(box.get());
^
1 error
这段代码看起来应该工作,因为输出的值是可以返回的正确类型,但是编译器会生成有关“ capture#337 of?”的(非常令人困惑的)错误消息。 与Object
不兼容。
“捕获的#337个”到底是什么? 意思? 当编译器遇到类型为通配符的变量(例如rebox()
的box
参数rebox()
,它将知道必须存在一些T
,而box
是Box<T>
。 它不知道T
代表什么类型,但是可以为该类型创建一个占位符以引用T
必须是的类型。 该占位符称为该特定通配符的捕获 。 在这种情况下,编译器已分配名称“ capture#337 of?” box
类型中的通配符。 每个变量声明中每次出现的通配符都会获得不同的捕获,因此在通用声明foo(Pair<?,?> x, Pair<?,?> y)
,编译器将为每个捕获的捕获分配一个不同的名称。之所以使用这四个通配符,是因为任何未知类型参数之间都没有关系。
该错误消息告诉我们的是,我们无法调用put()
因为它无法验证put()
的实际参数的类型与它的形式参数的类型兼容-因为它的形式形式参数未知。 在这种情况下,因为?
本质上是指“? box.get()
Object”,编译器已经得出结论, box.get()
的类型是Object
,而不是“ capture#337 of?”,并且它不能静态地验证Object
是该Object
的可接受值。占位符“?的捕获#337”标识的类型。
捕获助手
尽管看起来编译器已经丢弃了一些有用的信息,但是我们可以使用一个技巧来使编译器重新构造该信息并在此处与我们一起使用,即为未知的通配符类型命名。 清单4显示了rebox()
的实现以及通用的辅助方法,该方法可以完成此任务:
清单4.“捕获帮助器”惯用语
public void rebox(Box<?> box) {
reboxHelper(box);
}
private<V> void reboxHelper(Box<V> box) {
box.put(box.get());
}
辅助方法reboxHelper()
是一种通用方法 ; 泛型方法引入了其他类型参数(放在返回类型之前的尖括号中),这些参数通常用于在方法的参数和/或返回值之间制定类型约束。 但是,在reboxHelper()
的情况下,泛型方法不使用type参数指定类型约束。 它允许编译器(通过类型推断)为box类型的type参数命名。
捕获助手技巧使我们能够解决编译器在处理通配符方面的局限性。 当rebox()
调用reboxHelper()
,它知道这样做是安全的,因为对于某些未知T
它自己的box
参数必须是Box<T>
。 因为类型参数V
是在方法签名引入并且不依赖于任何其他类型的参数,它可以代表任何未知类型为好,这样一个Box<T>
一些未知T
很可能会成为一个Box<V>
为一些未知的V
(这类似于在演算,其允许重命名绑定变量的α-还原的原理。)现在,表达式box.get()
在reboxHelper()
不再具有类型Object
,它具有式V
-并且它是允许将V
传递给Box<V>.put()
。
首先,我们可以将rebox()
声明为通用方法,例如reboxHelper()
,但这被认为是错误的API设计风格。 这里的主要设计原则是“如果您永远不会使用名称来引用它,请不要给出名称。” 对于泛型方法,如果类型参数在方法签名中仅出现一次,则它可能应该是通配符而不是命名的类型参数。 通常,带有通配符的API比带有通用方法的API更简单,并且类型名称在更复杂的方法声明中的泛滥可能会使声明的可读性降低。 由于可以根据需要随时使用私有捕获帮助程序来重新命名该名称,因此这种方法使您有机会保持API的清洁,而不会丢弃有用的信息。
类型推断
捕获助手的技巧取决于几件事:类型推断和捕获转换。 Java编译器不会在很多地方执行类型推断,但它所做的一处是为通用方法推断类型参数。 (其他语言在很大程度上依赖于类型推断,将来我们可能会在Java语言中添加其他类型推断功能。)可以根据需要指定type参数的值,但前提是您可以命名类型-捕获类型不可表示。 因此,此技巧唯一可行的方法是编译器为您推断类型。 捕获转换使编译器可以为捕获的通配符制造一个占位符类型名称,以便类型推断可以将其推断为该类型。
解析对泛型方法的调用时,编译器将尝试为类型参数推断出最具体的类型。 例如,使用此通用方法:
public static<T> T identity(T arg) { return arg };
这个电话:
Integer i = 3;
System.out.println(identity(i));
编译器可以推断出T
为Integer
, Number
,Serializable或Object
,但是它选择Integer
因为这是最适合约束的类型。
构造通用实例时,可以使用类型推断来减少某些冗余。 例如,使用我们的Box
类,创建Box<String>
要求您两次指定类型参数String
:
Box<String> box = new BoxImpl<String>();
即使在IDE能够为您完成某些工作的情况下,这种违反DRY原则(不要重复自己)的行为也会令人讨厌。 但是,如果实现类BoxImpl
提供了如清单5所示的通用工厂方法(无论如何是个好主意),则可以减少客户端代码中的这种冗余:
清单5.通用工厂方法,使您可以避免重复指定类型参数
public class BoxImpl<T> implements Box<T> {
public static<V> Box<V> make() {
return new BoxImpl<V>();
}
...
}
如果使用BoxImpl.make()
工厂实例化Box
,则只需指定一次type参数:
Box<String> myBox = BoxImpl.make();
通用的make()
方法为某些V
类型返回Box<V>
,并且返回值在需要Box<String>
的上下文中使用。 编译器确定String
是V
可以满足的最特定类型,并且满足类型约束,因此在这里将V
推断为String
。 您仍然可以选择手动指定V
的值,如下所示:
Box<String> myBox = BoxImpl.<String>make();
除了节省一些击键之外,此处说明的工厂方法技术相对于构造函数还有其他优点:您可以为它们提供更多的描述性名称,它们可以返回已命名返回类型的子类型,并且不一定需要为每个实例创建新实例。调用,实现不可变实例的共享。 (见有效的Java,在第1项相关信息以获得更多关于静态工厂的好处。)
结论
通配符肯定很棘手; Java编译器发出的一些最令人困惑的错误消息与通配符有关,而Java语言规范中一些最复杂的部分与通配符有关。 但是,如果使用得当,它们将非常强大。 此处显示的两个技巧-捕获助手技巧和通用工厂技巧-都利用了通用方法和类型推断,如果正确使用它们,可以掩盖很多复杂性。
翻译自: https://www.ibm.com/developerworks/java/library/j-jtp04298/index.html