Java泛型通配符使用指南:如何避免? super T和? extends T的常见错误
使用 Java™ 语言的泛型时,通配符让人感到困惑。其中一个最常见的错误是在使用有界通配符的两种形式(“? super T”和“? extends T”)时出现错误。你是否犯过这样的错误呢?不要沮丧,即便专家也可能会犯这种错误,本月 Brian Goetz 将展示如何避免这个错误。

在 Java 语言里,数组具有协变性,原因在于一个 Integer 本身也是一个 Number,所以一个 Integer 数组也就同时是一个 Number 数组。然而,泛型并非如此,比如 List 并不等同于 List 。人们会争论某些选择是“正确的”,而某些选择是“错误的”。当然,每种选择都有其自身的优点和缺点。然而,有一点是毫无疑问的,那就是存在着两种使用差别极小的语义构造派生类型的类似机制,而这会引发大量的错误和误解。
有界通配符是语言提供的一种工具,它包括一些有趣的“? extends T”通用类型说明符。这种工具可用于处理协变性缺乏的情况,即有界通配符允许类在声明方法参数或返回值时具有协变性,或者相反,允许类在声明方法参数或返回值时具有逆变性(contravariant)。了解何时使用有界通配符是泛型较复杂的方面。然而,使用有界通配符的压力通常在库作者身上,而非库用户。最常见的有界通配符错误是忘记使用它们,这样会限制类的使用,或者强制用户不得不重用现有的类。
有界通配符的作用
让我们从一个简单的泛型类开始,这个类被称为 Box,它是一个值容器。Box 持有一个具有已知类型的值。其接口如下:public interface Box{ public T get(); public void put(T element); }
泛型不具备协变性,所以 Box 不等同于 Box 。虽然 Integer 属于 Number ,但对于 Box 这样的简单泛型类而言,这不是问题,且常常被忽略。因为 Box 的接口完全被指定为 T 类型的变量,而非通过 T 泛型化的类型。直接处理类型变量能够实现多态性。清单 1 展示了这种多态性的两个示例,一是获取 Box 的内容并将其作为一个 Number,二是将一个 Integer 放入 Box 中。
清单 1. 通过泛型类利用固有的多态性
BoxiBox 被创建为一个新的 BoxImpl 实例,其参数为 3;接着获取 iBox 的值并赋值给 Number 类型的 num;然后创建 BoxnBox 为一个新的 BoxImpl 实例,参数为 3.2;再将整数 3 赋值给 Integer 类型的 i;最后将 i 放入 nBox 中。
使用简单的 Box 类,这让我们确信能够没有协变性。因为在需要实现多态的地方,数据已经具备某种形式,这样编译器就能够应用恰当的子类型规则。
然而,若期望 API 不但可以处理 T 类型的变量,还能处理经 T 泛型化的类型,那么事情就会变得更为复杂。倘若期望给 Box 添加一个新的方法,此方法能获取另一个 Box 的内容并将其放置到如清单 2 所示的 Box 中:
清单 2. 扩展的 Box 接口并不灵活
公共接口 Box 具有以下功能:可以获取元素;能够放入一个元素;可以放入另一个 Box。
这个扩展 Box 存在的问题在于,它仅仅能够把内容放置到类型参数与原本的 box 完全一致的 Box 里。所以,清单 3 里的代码是无法完成编译的。
清单 3. 泛型不具备协变性
BoxnBox 被创建为一个新的 BoxImpl 对象;BoxiBox 也被创建为一个新的 BoxImpl 对象;然后 nBox 尝试将 iBox 放入其中,但是出现了错误。
显示一条错误消息,表明在 Box 中无法找到 put(Box)这个方法。若认为泛型不具有协变性,那么这条错误是合理的;一个 Box 并非 Box,即便 Integer 是 Number,但这导致 Box 类的“泛型性”比我们预期的要弱。要提高泛型代码的有效性,能够指定一个上限或者下限,而不是指定某个泛型类型参数的精确类型。这样可以通过有界通配符来达成,其形式为“? extends T”或者“? super T”。并且要知道,有界通配符仅仅能够用作类型参数,而无法作为类型本身,所以需要一个有界的命名的类型变量。在清单 4 里,对 put() 的签名进行了修改,使其能够使用一个上限通配符,即 Box extends T>。这意味着 Box 的类型参数既可以是 T,也可以是 T 的任何子类。
清单 4. 对清单 3 的 Box 类的改进解释了协变性
公共接口 Box 包含以下方法:获取元素的方法 get();放置元素的方法 put(T element);放置另一个 Box(该 Box 中的元素类型为 T 的子类型)的方法 put(Box extends T> box)。
现在,清单 3 中的代码能够被编译并且执行。因为现在 put()的参数可以是类型为 T 或者 T 的子类型的 Box。又因为 Integer 是 Number 的子类型,所以编译器能够解析方法引用 put(Box),这是因为 Box 与有界通配符 Box extends Number>相匹配。
容易犯清单 3 里的 Box 错误,即便专家也难以避免。在平台类库中,很多地方使用的是 Collection,而非 Collection extends T>。在 java.util.concurrent 包的 AbstractExecutorService 里,invokeAll()的参数起初是一个 Collection>。但是,使用 invokeAll() 存在很大麻烦。因为这需要由 Callable 参数化的集合来持有任务集,而不能由实现 Callable 的类来参数化集合。在 Java 6 里,这种签名被改成了 Collection extends Callable> 。这仅仅是为了展示很容易犯这样的错误,而正确的修复方式是让 invokeAll() 包含一个 Collection extends Callable extends T>> 的参数。这个参数无疑更加难看,但不会给客户机带来麻烦。
下限通配符
大多数上面的有界通配符都被进行了限定;“? extends T”这个符号给类型加上了一个上限。然而,虽然比较少见,却依然可以使用“? super T”符号给类型添加一个下限,其意思是“类型 T 以及它的任何超类”。当你想要指定一个回调对象(例如一个比较器)或者存放某个值的数据结构时,就可以使用下限通配符。
假设我们期望增强 Box,让它能够和另一个 box 的内容进行对比。可以借助 containsSame() 方法以及 Comparator 回调对象的定义来对 Box 进行扩展,就如同清单 5 所展示的那样。
清单 5. 尝试向 Box 添加一个比较方法
- 用于判断是否包含相同元素的方法 containsSame(Box extends T> other, EqualityComparator comparator),其中 EqualityComparator 接口包含比较两个元素是否相同的方法 compare(T first, T second)。
可以用一个通配符来定义 containsSame() 中另一种 box 的类型,这样就能避免之前遇到的问题。然而,还是会碰到一个类似的问题,即比较器参数必须是 EqualityComparator。这也就意味着我们无法编写像清单 6 那样的代码。
清单 6. 使用清单 5 中的比较方法会导致失败
定义了一个静态的 EqualityComparator 类型的变量 sameObject,其具体实现为一个匿名内部类,在该类的 compare 方法中,通过判断两个对象 o1 和 o2 是否相等(即 o1 == o2)来确定它们是否相同。创建了两个 BoxImpl 类型的对象 iBox 和 nBox。然后调用 nBox 的 containsSame 方法,传入 iBox 和 sameObject 作为参数,该方法用于判断 nBox 是否包含与 iBox 相同的对象(通过 sameObject 的比较逻辑来判断),并将结果存储在布尔变量 b 中。
在这里使用 EqualityComparator 是很合理的。因为可以使用泛型来指定,所以客户机无需为每一种可能的 Box 类型创建单独的比较器!解决办法是使用下限通配符“? super T”。使用 compareTo()方法进行扩展的正确 Box 类如下所示:如清单 7 。
清单 7 表明,清单 5 中的比较操作在使用有界通配符之后,其灵活性得到了提升。
- 能够比较两个 T 类型的元素是否相同。
使用一个下限通配符后,containsSame()方法意味着能够比较 T 或者 T 的任何超类型的工具。这样就使得我们可以提供一个能够比较对象的比较器,并且无需使用 EqualityComparator 来对其进行封装。
get-put 原则
有一个流传很久的笑话,说的是:佩戴一只手表的人,常常能够知道时间;而佩戴两只手表后,反而难以确定时间了。因为 Java 语言同时支持上限和下限通配符,所以就会产生这样一个问题,那就是如何判断在什么时候使用哪一种通配符呢?
这里有一条被称为 get-put 原则的简单规则,它阐释了应当使用哪一种通配符。get-put 原则首次出现在 Naftalin 和 Wadler 所著的有关泛型的《Java Generics and Collections》一书中(参见参考资料)。它的描述如下:当仅从某个结构中获取值时,使用 extends 通配符;当仅将值放入某个结构时,使用 super 通配符;当同时进行以上两种操作时,不要使用通配符。
当应用于 Box 等容器类或 Collections 类时,get-put 原则是很容易理解的。因为 get 和 put 的概念与这些类的作用存在着自然的联系,这些类的作用就是存储内容。所以,要是期望运用 get-put 原则去创建一个能够在 Box 之间进行复制的方法,那么最常见的形式就如同清单 8 所展示的那样,在其中,复制源采用上限通配符,而目标则采用下限通配符。
清单 8. 同时使用上限和下限通配符的 Box 复制方法
先从 from 获取元素,然后将获取到的元素放入 to 中。
如果将前面的 containsSame() 方法应用于 get-put 原则,该方法对 box 使用了上限通配符而对比较器使用了下限通配符。第一步较为简单,需要从其他 box 获取一个值,所以使用一个 extends 通配符。但第二步较为复杂。因为比较器并非容器,所以它与从一个数据结构中获得值或向其中存入值的情况有所不同。
当数据类型并非一个明显的容器类(比如集合)时,要这样考虑 get - put 原则:虽然 EqualityComparator 不是一个数据结构,但依然可以向它“存入”值,也就是将值传递给它的一个方法。在 containsSame() 方法里,用 Box 当作值的生成器,也就是从 Box 那里获取值。同时,用比较器当作值的使用者,把值传递给比较器。所以能够对 Box 使用 extends 通配符,而对比较器使用 super 通配符。
我们能够看到,在 Collections.sort() 的声明里运用了 get-put。如下所示,如清单 9 中所呈现的那样:
清单 9. 使用下限通配符的另一个示例
静态的 void 方法 sort 接收一个 List 类型的参数 list ,在方法内部进行相关操作 。
在这里,能够对 List 进行排序,因为它是由实现了 Comparable 的任何类型来参数化的。然而,我们并没有将 sort() 的域仅仅限制在具有可相互比较元素的列表上,而是更进一步,即我们也可以对那些能够与其超类型进行比较的列表元素进行排序。值被放入到比较器中以决定两个元素的相对顺序,基于此,get-put 原则告知我们在此处需要使用一个超通配符。
表面上看起来像是循环引用,即 T 对经过 T 参数化的内容进行扩展,但实际上并非真正的循环。这仅仅表示对 List 排序存在限制,T 必须要实现接口 Comparable,并且 X 要么是 T,要么是 T 的一个超类型。
接着是 get-put 原则的最后一部分。当同时进行 get 和 put 操作时,不要使用通配符。如果能够存入 T 或者 T 的任意子类型,那么就可以获取 T 或者 T 的任意超类型。而唯一能够同时获取或存入的是 T 本身。
不要对返回值使用通配符
有时希望在方法的返回类型中使用有界通配符。然而最好不要这么做,因为返回的有界通配符往往会给客户机代码带来不良影响。若某方法返回一个 Box
结束语
有界通配符对提高泛型 API 的灵活性很有帮助。最大的绊脚石在于认为没必要使用它们。有些情况适宜用下限通配符,有些情况适宜用上限通配符,可通过 get-put 原则来判断该用哪种通配符。