在 JSE 5.0 中新增了泛型(Generics)功能,而其许多 API 都根据这个新功能重新改写了,例如 List、Map、Set 等相关类。即使不了解泛型的新功能,也可以按照 J2SE 1.4 或旧版本的语法来使用这些类,但编译时会出现一些警告(Warnings)。
泛型解决的不只是然你少写了几个类的程序代码,还在于让你定义安全的泛型类(Generics Class)。泛型提供编译时期检查,你不会因为将对象置入某个容器(Contatiner)而失去其类型。
1、泛型入门
1.1 没有泛型之前
考虑设计下面的 BooleanFoo 与 IntegerFoo 两个类,这是两个很无聊的类,但足以说明需求。
1 | package com.sunzn.generics; |
1 | package com.sunzn.generics; |
观察 BooleanFoo 与 IntegerFoo 两个类,其中除了声明成员的类型、参数行的类型与方法返回值的类型不同之外,剩下的程序代码完全相同。或许有点小聪明的程序设计人员会将第一个类的内容复制到第二个文件中,然后用编辑器的取代功能一次取代所有的类型名称(即将 Boolean 取代为 Integer)。
虽然是有些小聪明,但如果类中的逻辑要修改,就需要修改这两个文件,泛型(Generics)的需求就在此产生。当定义类时,发现到好几个类的逻辑其实都相同,只是其中所涉及的类型不一样时,使用复制、粘贴,取代的功能来编写程序,只会让你增加不必要的文件管理困扰。
由于 Java 中所有的类最上层都继承自 Object 类,可以定义类似下面的 ObjectFoo 类来取代之前的 BooleanFoo 和 IntegerFoo 类。
1 | package com.sunzn.generics; |
由于 Java 中所有定义的类,都以 Object 为最上层的父类,所以用它来实现泛型功能是一个不错的考虑。在 J2SE 1.4 或之前版本上,大部分的开发人员都会这么做。只要编写如范例 ObjectFoo 所示的类,然后可以按照如下的方法使用它:
1 | package com.sunzn.generics; |
看来还不错,但是设定至 foo1 或 foo2 的 Integer 或 Boolean 实例会失去其类型信息,从 getFoo() 返回的是 Object 类型的实例,你必须转换它的实现类型。问题出在这里,粗心的程序设计人员往往会忘记了要做这个动作,或者是转换类型时用错了类型(像是该用 Boolean 却用了 String)。例如:
1 | ObjectFoo foo3 = new ObjectFoo(); |
由于语法上并没有错误,所以编译器检查不出上面的程序有错误,真正的错误要在执行时期才会发生,这时 ClassCastException 就会出来搞怪。在使用 Object 设计泛型程序时,程序人员要再细心一些。例如在 J2SE 1.4 或旧版本上,所有存入 List、Map、Set 容器中的实例都会失去其类型信息,要从这些容器中取回对象并加以实现,就得记住取回的对象是什么类型。
1.2 定义泛型类
由于 Java 中所有定义的类,都以 Object 为最上层的父类,所以在 J2SE 5.0 之前,Java 程序设计人员可以使用 Object 定义类以解决以上的需求。为了让定义出来的类可以更加通用,传入的值或返回的实例都以 Object 类型为主。当要取出这些实例来使用时,必须记得将之转换为原来的类型或适当的接口,这样才可以实现对象上的方法。
然而使用 Object 来编写泛型类留下了一些问题,因为必须要转换类型或接口,粗心的程序设计人员往往会忘记了要做这个工作,或者是转换类型或接口时用错了类型或接口(像是该用 Boolean 却用了 String),但由于语法上是可以的,所以编译器检查不出错误,因而执行时期就会发生 ClassCastException。
在 J2SE 5.0 之后,提出了针对泛型设计的解决方案,要定义一个简单的泛型类是简单的。下面看 GenericFoo 是如何取代 ObjectFoo 的类定义的。
1 | package com.sunzn.generics; |
在 GenericFoo 中,使用<T>来声明一个类型持有者(Holder)名称 T,接着可以用 T 这个名称作为类型代表来声明成员、参数或返回值类型,然后可以如下面的范例所示来使用这个类。
1 | package com.sunzn.generics; |
与单纯使用 Object 声明类型所不同的地方在于,使用泛型所定义的类在声明及配置对象时,可以使用尖括号一并指定泛型类类型持有者 T 真正的类型,而类型或接口转换就不再需要了。getFoo() 所设定的参数或返回的类型,就是在声明及配置对象时在<>之间所指定的类型。定义的泛型类在使用时多了一层安全性,可以省去 ClassCastException 的发生,编译器可以帮助你做第一层防线。例如下面的程序会被检查出错误:
1 | GenericFoo<Boolean> foo1 = new GenericFoo<>(); |
foo1 使用 getFoo() 方法返回的是 Boolean 类型的实例,若要将这个实例指定给 Integer 类型的变量,显然在语法上不合,编译器这时检查出错误:
1 | GenericFooDemo.java:7: incompatible types |
如果使用泛型,但声明及配置对象时不同时指定类型呢?那么默认会使用 Object 类型,不过就要自己转换对象的接口类型了。例如 GenericFoo 可以这样声明与使用:
1 | GenericFoo<> foo3 = new GenericFoo<>(); |
但编译时编译器会提出警告,告诉你这可能是不安全的实现:
1 | Note:GenericFooDemo.java uses unchecked or unsafe operations. |
回过头来看看下面的声明:
1 | GenericFoo<Boolean> foo1 = new GenericFoo<>(); |
GenericFoo<Boolean> 声明的 foo1 与 GenericFoo<Integer> 声明的 foo2 是相同的类型吗?答案是否定的。基本上 foo1 与 foo2 是两个不同的类型,foo1 是 GenericFoo<Boolean> 类型,而 foo2 是 GenericFoo<Integer> 类型,所以不可以将 foo1 所参考的实例指定给 foo2,或是将 foo2 所参考的实例指定给 foo1,要不然编译器会报以下错误:
1 | incompatible types |
程序编写风格: 自定义泛型类时,类型持有者名称可以使用 T(Type),如果是容器的元素可以使用 E(Element),若键值匹配可以使用 K(Key)与 V(Value),Annotation 时可以使用 A,可以参考 J2SE 5.0 API 文件说明上的命名方式。
1.3 几个定义泛型的例子
可以在定义泛型类时,声明多个类型持有者,像下面的范例类声明了两个类型持有者 T1 与 T2。
1 | package com.sunzn.generics; |
可以使用 GenericFoo2 类,分别以 Integer 与 Boolean 设定 T1 与 T2 的真正类型:
1 | GenericFoo2<Integer, Boolean> foo = new GenericFoo2<>(); |
泛型可以用于声明数组类型。如下所示范例:
1 | package com.sunzn.generics; |
可以像下面的方式来使用 GenericFoo3 类。
1 | String[] strs = { "A", "B", "C" }; |
注意,可以使用泛型机制来声明一个数组。例如下面这样是可行的:
1 | package com.sunzn.generics; |
但是不可以使用泛型来建立数组的实例。例如以下是不可行的:
1 | package com.sunzn.generics; |
如果已经定义了一个泛型,若要用这个类在另一个泛型类中声明成员该如何做?举个实例,假设已经定义了 GenericFoo 类,现在想要设计一个新的类,其中包括了 GenericFoo 的类实例作为其成员,可以按照下面范例的方式设计。
1 | package com.sunzn.generics; |
这样,就可以保留类型持有者 T 的功能。一个使用的例子如下:
1 | GenericFoo<Integer> foo = new GenericFoo<>(); |
2、泛型高级语法
泛型的语法元素其实是很基本的,只不过将这种语法来回扩展之后,可以编写出相当复杂的泛型定义,然而无论再怎么复杂的写法,基本语法元素大致不变:限制泛型可用类型、使用类型通配符(Wildcard),以及泛型的扩充与继承 这几个语法。
2.1 限制泛型可用类型
在定义泛型类时,默认可以使用任何的类型来实例化泛型类中的类型持有者,但假设想要限制使用泛型类时,如何用某个特定类型或其子类来实例化类型持有者呢?
可以在定义类型持有者时,同时使用 extends 指定这个类型持有者实例化,实例化的对象必须是扩充自某个类型或实现某接口,范例如下:
1 | package com.sunzn.generics; |
ListGenericFoo 在声明类型持有者时,一并指定这个持有者实例化的对象,必须是实现 java.util.List 接口(interface)的类。在限定持有者时,无论要限定的对象是接口或类,都是使用 extends 关键词。范例中使用 extends 限定类型持有者实例化的对象,必须是实现 List 接口的类,像 java.util.LinkedList 与 java.util.ArrayList 就实现了 List 接口。例如下面的程序片段是合法的使用方式:
1 | ListGenericFoo<LinkedList> foo1 = new ListGenericFoo<>(); |
但如果不是实现 List 的类,编译时就会发生错误。例如下面的程序片段通不过编译:
1 | ListGenericFoo<HashMap> foo3 = new ListGenericFoo<>(); |
因为 java.util.HashMap 并没有实现 List 接口(事实上 HashMap 实现了 Map 接口),编译器会在编译时检查出这个错误:
1 | type parameter java.util.HashMap is not within its bound |
HashMap 并没有实现 List 接口,所以无法作为实例化类型持有者的对象。事实上,当没有使用 extends 关键词限定类型持有者时,默认是 Object 下的所有子类都可以实例化类型持有者。也就是说,在定义泛型类时如果只写以下代码:
1 | public class GenericFoo<T> { |
其实就相当于以下的定义方式:
1 | public class GenericFoo<T extends Object> { |
由于 Java 中所有的实例都继承自 Object 类,所以定义时若只写
- **【扩展说明】**实际上由于 List、Map、Set 与实现这些接口的相关类,都已经用新的泛型功能重新改写过了,实际编写时会更复杂一些。例如还可以再详细定义范例 ListGenericFoo:
1 | package com.sunzn.generics; |
- 这么定义之后,就只能使用 ArrayList<String> 来实例化 ListGenericFoo 了。例如:
1 | ListGenericFoo<ArrayList<String>> foo = new ListGenericFoo<>(); |
2.2 类型通配符(Wildcard)
依然以范例 GenericFoo 来进行说明,假设使用 GenericFoo 类来像下面这样声明名称:
1 | package com.sunzn.generics; |
1 | GenericFoo<Integer> foo1 = null; |
那么名称 foo1 就只能参考 GenericFoo
1 | foo1 = new GenericFoo<Integer>(); |
现在有一个需求,希望有一个参考名称 foo 可以像下面这样接受所指定的实例:
1 | foo = new GenericFoo<ArrayList>(); |
简单地说,你想要有一个 foo 名称可以参考的对象,其类型持有者实例化的对象是实现 List 接口的类或其子类。要声明这么一个参考名称,可以使用 ?
(通配符)。?
代表未知类型,并使用 extends 关键词来作限定。例如:
1 | GenericFoo<? extnds List> foo = null; |
<? extnds List>
表示类型未知,只知道是实现 List 接口的类,所以如果类型持有者实例化的对象不是实现 List 接口的类,则编译器会报告错误。例如以下这行无法通过编译:
1 | GenericFoo<? extends List> foo = new GenericFoo<HashMap>(); |
因为 HashMap 没有实现 List 接口,所以建立的 GenericFoo<HashMap>
实例不能指定给 foo 名称来参考,编译器会报告以下的错误:
1 | incompatible types |
使用 ?
来作限定有时是很有用的,例如若想要自定义一个 showFoo() 方法,方法的内容实现是针对 String 或其子类的实例而制定的。例如:
1 | public void showFoo(GenericFoo foo) { |
如果只作以上的声明,那么像 GenericFoo<Integer>
、GenericFoo<Boolean>
等类型都可以传入至方法中。如果不希望任何类型都可以传入 showFoo() 方法中,可以使用以下的方式来限定:
1 | public void showFoo(GenericFoo<? extends String> foo) { |
这样,如果粗心的程序设计人员传入了你不想要的类型,例如GenericFoo<Boolean>
类型的实例,则编译器都会告诉他这是不可行的。在声明名称时如果指定了<?>
而不使用 extends,则默认是允许 Object 及其下的子类,也就是所有的 Java 对象了。那为什么不直接使用 GenericFoo 声明?何必要用 GenericFoo<?>
来声明?使用通配符有一点要注意的是,通过使用通配符声明的名称所参考的对象,你没办法再对它加入新的信息,你只能取得它当中的信息或是移除当中的信息。例如:
1 | package com.sunzn.generics; |
所以使用<?>或是<? extends SomeClass>的声明方式,意味着只能通过该名称来取得所参考实例的信息,或者是删除某些信息,但不能增加它的信息。
除了可以向下限制,也可以向上限制,只要使用 super 关键词。例如:
1 | GenericFoo<? super StringBuilder> foo = null; |
这样,foo 就只接受 StringBuilder 及其上层的父类类型,也就是只能接受 GenericFoo<StringBuilder> 与 GenericFoo<Object> 的实例。
2.3 扩充泛型类和实现泛型接口
可以扩充一个泛型类,保留其类型持有者,并新增自己的类型持有者,例如:
1 | package com.sunzn.generics; |
再写一个子类 SubGenericFoo4 扩充 GenericFoo4。
1 | package com.sunzn.generics; |
如果决定要保留类型持有者,则父类上声明的类型持有者数目在继承下来时必须写齐全。也就是说,在 SubGenericFoo4 中,父类上 GenericFoo4 上出现的 T1 与 T2 在 SubGenericFoo4 中都要出现,如果不保留类型持有者,则继承下来的 T1 与 T2 自动变为 Object。建议是父类的类型持有者都要保留。
接口实现也是类似,如下所示:
1 | package com.sunzn.generics; |
然后定义实现类实现 IFoo 接口,实现时保留所有的类型持有者。
1 | package com.sunzn.generics; |
有的人一看到尖括号就开始头疼,老实说我也是这些人当中的一个。Java 新增的泛型语法虽然基本,但根据语法展开来的写法却可以写的很复杂。不建议使用泛型时将程序代码写得太复杂,像是递归了 n 层尖括号的程序代码,看来真得很令人头痛。新的泛型功能有其好处,但编写程序时也要同时考虑可读性,因为可读性有时反而是开发程序时比较注重的。