2.6 泛型

泛型通过在编译时检测到更多的代码Bug,从而使你的代码更加稳定。

2.6.1 泛型的作用

概括地说,泛型支持类型(类和接口)在定义类、接口和方法时可以作为参数。就像在方法声明中使用的形式参数一样,类型参数提供了一种输入可以不同但代码可以重用的方式。所不同的是,形式参数的输入是值,类型参数输入的是类型。

使用泛型对比非泛型代码有很多好处:

1.在编译时更强的类型检查

如果代码违反了类型安全,Java编译器将针对泛型和问题错误采用强大的类型检查。修正编译时的错误比修正运行时的错误更加容易。

2.消除了强制类型转换

没有泛型的代码片需要强制转化,比如:

当重新编写使用泛型时,代码不需要强转:

3.使编程人员能够实现通用算法

通过使用泛型,程序员可以实现工作在不同类型集合的通用算法,并且可定制、类型安全、易于阅读。

2.6.2 泛型类型

泛型类型是参数化类型的泛型类或接口。下面通过一个Box类的例子来说明这个概念。

1.一个简单的Box类

观察下面的例子:

它的方法接受或返回一个Object,你可以自由地传入任何你想要的类型,只要它不是原始的类型之一即可。在编译时,没有办法验证如何使用这个类。代码的一部分可以设置Integer并期望得到Integer ,而代码的另一部分可能会由于错误地传递一个String而导致运行错误。

2.一个泛型版本的Box类

泛型类定义语法如下:

类型参数部分用<>包裹,制定了类型参数(或称为类型变量)T1、T2、…、Tn。

下面是泛型版本代码的例子:

可以看到,所有的Object都被T代替了。类型变量可以是非基本类型的任意类型,即任意的类、接口、数组或其他类型变量。

这个技术同样适用于泛型接口的创建。

3.类型参数命名规范

按照惯例,类型参数名称是单个大写字母,用来区别普通的类或接口名称。常用的类型参数名称如下:

· E:元素,主要由Java集合(Collections)框架使用。

· K:键,主要用于表示映射中的键的参数类型。

· V:值,主要用于表示映射中的值的参数类型。

· N:数字,主要用于表示数字。

· T:类型,主要用于表示第一类通用型参数。

· S:类型,主要用于表示第二类通用类型参数。

· U:类型,主要用于表示第三类通用类型参数。

· V:类型,主要用于表示第四类通用类型参数。

4.调用和实例化一个泛型

从代码中引用泛型Box类,必须执行一个泛型调用,用具体的值(比如Integer)取代T:

   Box<Integer> integerBox;

泛型调用与普通的方法调用类似,所不同的是传递参数是类型参数,在本例中就是传递Integer到Box类。

Type Parameter和Type Argument的区别

编码时,提供type argument的一个原因是为了创建参数化类型。因此,Foo<T>中的T是一个type parameter,而Foo<String>中的String是一个type argument。

与其他变量声明类似,代码实际上没有创建一个新的Box对象。它只是声明integerBox在读到Box<Integer>时,保存一个“Integer的Box”的引用。

泛型的调用通常被称为一个参数化类型。

实例化类,使用new关键字:

5.菱形(Diamond)

从Java SE 7开始,泛型可以使用空的类型参数集<>,只要编译器能够确定或推断该类型参数所需的类型参数即可。这对尖括号<>被非正式地称为“菱形(Diamond)”,例如:

6.多类型参数

下面是一个泛型Pair接口和一个泛型OrderedPair:

创建两个OrderedPair实例:

在代码“new OrderedPair<String, Integer>”中,实例K作为一个String、V作为一个Integer。因此,OrderedPair构造函数的参数类型是String和Integer。由于有自动装箱机制,因此可以有效地传递一个String和int到这个类。

可以使用菱形(diamond)来简化代码:

7.参数化类型

也可以用参数化类型(例如,List<String>的)来替换类型参数(即K或V)。例如,使用OrderedPair<K,V>:

8.原生类型

原生类型是没有类型参数的泛型类和泛型接口,如泛型Box类:

要创建参数化类型的Box<T>,需要为形式类型参数T提供实际的类型参数:

如果想省略实际的类型参数,就需要创建一个Box<T>的原生类型:

因此,Box是泛型Box<T>的原生类型。但是,非泛型的类或接口类型不是原始类型。

JDK为了保证向后兼容,允许将参数化类型分配给原始类型:

如果将原始类型与参数化类型进行管理,就会得到警告:

如果使用原始类型调用相应泛型类型中定义的泛型方法,也会收到警告:

警告显示原始类型绕过泛型类型检查,将不安全代码的捕获推迟到运行时。因此,开发人员应该避免使用原始类型。

2.6.3 泛型方法

泛型方法是引入其自己的类型参数的方法。这类似于声明泛型类型,但类型参数的范围仅限于声明它的方法。允许使用静态和非静态泛型方法以及泛型类构造函数。

泛型方法的语法包括一个类型参数列表,在尖括号内,它出现在方法的返回类型之前。对于静态泛型方法,类型参数部分必须出现在方法的返回类型之前。

在下面的例子中,Util类包含一个泛型方法compare,用于比较两个Pair对象:

compare方法的调用方式如下:

其中,compare方法的类型通常可以省略,因为编译器将推断所需的类型:

2.6.4 有界类型参数

有时可能希望限制可在参数化类型中用作类型参数的类型。例如,对数字进行操作的方法可能只想接受Number或其子类的实例。这时就需要用到有界类型参数。

1.声明有界类型参数

要声明有界类型参数,先要列出类型参数的名称,然后是extends关键字,后面跟着它的上限,比如下面例子中的Number:

上面的代码将会编译失败,报错如下:

除了限制可用于实例化泛型类型的类型之外,有界类型参数还允许调用边界中定义的方法:

在上面的例子中,isEven方法通过n调用Integer类中定义的intValue方法。

2.多个边界

前面的示例说明了使用带有单个边界的类型参数,但是类型参数其实是可以有多个边界的:

具有多个边界的类型变量是绑定中列出的所有类型的子类型。如果其中一个边界是类,就必须首先指定它。例如:

如果未首先指定绑定A,就会出现编译时错误:

注意

在有界类型参数中的extends既可以表示“extends”(类中的继承),也可以表示“implements”(接口中的实现)。

2.6.5 泛型的继承和子类型

在Java中,只要类型兼容就可以将一种类型的对象分配给另一种类型的对象。例如,可以将Integer分配给Object,因为Object是Integer的超类之一:

在面向对象的术语中,这种关系被称为“is-a”。由于Integer是一种Object,因此允许赋值。但是Integer同时也是一种Number,所以下面的代码也是有效的:

在泛型中也是如此。可以执行泛型类型调用,将Number作为其类型参数传递。如果参数与Number兼容,就允许任何后续的add调用:

现在考虑下面的方法:

通过查看其签名,可以看到上述方法接受一个类型为Box<Number>的参数。也许你可能会想当然地认为这个方法也能接收Box<Integer>或Box<Double>,答案是否定的,因为Box<Integer>和Box<Double>并不是Box<Number>的子类型。在使用泛型编程时,这是一个常见的误解,虽然Integer和Double是Number的子类型。

图2-4展示了泛型和子类型之间的关系。

图2-4 泛型和子类型之间的关系

可以通过扩展或实现泛型类或接口来对其进行子类型化。一个类或接口的类型参数与另一个类或参数的类型参数之间的关系由extends和implements子句确定。

以Collections类为例,ArrayList<E>实现了List<E>,而List<E>扩展了Collection<E>,所以ArrayList<String>是List<String>的子类型,同时它也是Collection<String>的子类型。只要不改变类型参数,就会在类型之间保留子类型关系。图2-5展示了这些类的层次关系。

图2-5 泛型类及子类

现在假设我们想要定义自己的列表接口PayloadList,它将泛型类型P的可选值与每个元素相关联。它的声明可能如下:

以下是PayloadList参数化的List<String>的子类型:

· PayloadList<String,String>

· PayloadList<String,Integer>

· PayloadList<String,Exception>

这些类的关系图如图2-6所示。

图2-6 泛型类及子类之间的关系

2.6.6 通配符

通配符(?)通常用于表示未知类型。通配符可用于各种情况:

· 作为参数、字段或局部变量的类型。

· 作为返回类型。

在泛型中,通配符不用于泛型方法调用,泛型类实例创建或超类型的类型参数。

1.上限有界通配符

可以使用上限通配符来放宽对变量的限制。例如,要编写一个适用于List<Integer>、List<Double>和List<Number>的方法,可以通过使用上限有界通配符来实现这一点。比如下面的例子:

可以指定为Integer类型:

   List<Integer> li = Arrays.asList(1, 2, 3);
   System.out.println("sum = " + sumOfList(li));

输出结果为:

   sum = 6.0

可以指定为Double类型:

   List<Double> ld = Arrays.asList(1.2, 2.3, 3.5);
   System.out.println("sum = " + sumOfList(ld));

输出结果为:

   sum = 7.0
2.无界通配符

无界通配符类型通常用于定义未知类型,比如List<?>。

无界通配符通常有两种典型的用法。

第一种是使用Object类中提供的功能实现的方法。考虑以下方法printList:

printList只能打印一个Object实例列表,不能打印List<Integer>、List<String>、List<Double>等,因为它们不是List<Object>的子类型。

第二种是当代码使用泛型类中不依赖于类型参数的方法。例如List.size或List.clear。实际上,经常使用Class<?>,因为Class<T>中的大多数方法都不依赖于T。比如下面的例子:

因为List<A>是List<?>的子类,所以可以打印出任何类型:

   List<Integer> li = Arrays.asList(1, 2, 3);
   List<String>  ls = Arrays.asList("one", "two", "three");
   printList(li);
   printList(ls);

因此,要区分场景来选择使用List<Object>或是List<?>。如果想插入一个Object或者是任意Object的子类,就可以使用List<Object>,但只能在List<?>中插入null。

3.下限有界通配符

下限有界通配符将未知类型限制为该类型的特定类型或超类型。使用下限有界通配符的语法为<? super A>。

假设要编写一个将Integer对象放入列表的方法,为了最大限度地提高灵活性,希望该方法可以处理List<Integer>、List<Number>或者是List<Object>等可以保存Integer值的方法。

比如下面的例子将数字1到10添加到列表的末尾:

4.通配符及其子类

可以使用通配符在泛型类或接口之间创建关系。

给定以下两个常规(非泛型)类:

下面的代码是成立的:

此示例显示常规类的继承遵循此子类型规则:如果B扩展A,那么类B是类A的子类型。此规则不适用于泛型类型:

图2-7 List<Integer>和List<Number>之间的关系

尽管Integer是Number的子类型,但是List<Integer>并不是List<Number>的子类型。

为了在这些类之间创建关系,以便代码可以通过List<Integer>的元素访问Number的方法,需要使用上限有界通配符:

因为Integer是Number的子类型,而numList是Number对象的列表,所以intList(Integer对象列表)和numList之间存在关系。图2-8显示了使用上限和下限有界通配符声明的多个List类之间的关系。

图2-8 多个List类之间的关系

2.6.7 类型擦除

泛型被引入到Java语言中,以便在编译时提供更严格的类型检查并支持泛型编程。为了实现泛型,Java编译器将类型擦除应用于:

· 如果类型参数是无界的,则用泛型或对象替换泛型类型中的所有类型参数。因此,生成的字节码仅包含普通的类。

· 如有必要,插入类型铸件以保持类型安全。

· 生成桥接方法以保留扩展泛型类型中的多态性。

类型擦除能够确保不为参数化类型创建新类,因此泛型不会产生运行时开销。

1.擦除泛型类型

在类型擦除过程中,Java编译器将擦除所有的类型参数,并在类型参数有界时将其替换为第一个绑定,如果类型参数为无界,就替换为Object。

考虑以下表示单链表中节点的泛型类:

因为类型参数T是无界的,所以Java编译器将其替换为Object:

在以下示例中,泛型Node类使用有界类型参数:

Java编译器将有界类型参数T替换为第一个绑定类Comparable:

2.擦除泛型方法

Java编译器还会擦除泛型方法参数中的类型参数。请考虑以下泛型方法:

因为T是无界的,所以Java编译器将会将它替换为Object:

假设定义了以下类:

可以使用泛型方法绘制不同的图形:

Java编译器将会将T替换为Shape:

2.6.8 使用泛型的一些限制

使用泛型,需要考虑以下一些限制。

1.无法使用基本类型实例化泛型

请考虑以下参数化类型:

创建Pair对象时,不能将基本类型替换为类型参数K或V:

只能将非基本类型替换为类型参数K和V:

此时,Java编译器会自动装箱,将8转为Integer.valueOf(8),将'a'转为Character('a'):

2.无法创建类型参数的实例

无法创建类型参数的实例。例如,以下代码导致编译时错误:

作为解决方法,可以通过反射创建类型参数的对象:

可以按如下方式调用append方法:

3.无法声明类型为类型参数的静态字段

类的静态字段是类的所有非静态对象共享的类级变量。因此,不允许使用类型参数的静态字段。考虑以下类:

若允许类型参数的静态字段,则以下代码将混淆:

静态字段os由phone、pager、pc共享,那么os的实际类型是什么呢?它不能同时是Smartphone、Pager或者TabletPC,因此无法创建类型参数的静态字段。

4.无法使用具有参数化类型的强制转换或instanceof

因为Java编译器会擦除通用代码中的所有类型参数,所以无法验证在运行时使用泛型类型的参数化类型:

传递给rtti方法的参数化类型集是:

   S = { ArrayList<Integer>, ArrayList<String> LinkedList<Character>}

运行时不跟踪类型参数,因此无法区分ArrayList<Integer>和ArrayList<String>,最多是使用无界通配符来验证列表是否为ArrayList:

通常,除非通过无界通配符进行参数化,否则无法强制转换为参数化类型。例如:

在某些情况下,编译器知道类型参数始终有效并允许强制转换。例如:

   List<String> l1 = ...;
   ArrayList<String> l2 = (ArrayList<String>)l1;  // 正确
5.无法创建参数化类型的数组

无法创建参数化类型的数组。例如,以下代码无法编译:

以下代码说明将不同类型插入到数组中时会发生什么:

如果使用通用列表尝试相同的操作,就会出现问题:

如果允许参数化列表数组,那么前面的代码将无法抛出所需的ArrayStoreException。

6.无法创建、捕获或抛出参数化类型的对象

泛型类不能直接或间接扩展Throwable类。例如,以下类将无法编译:

方法无法捕获类型参数的实例:

但是可以在throws子句中使用类型参数:

7.类型擦除到原生类型的方法无法重载

类不能有两个重载方法,因为它们在类型擦除后具有相同的签名。观察下面的例子:

上述例子将产生编译时错误。