Java核心技术1-泛型2

  |  

摘要: 本文是《Java核心技术 10th》中关于泛型的要点总结第二部分。

【对算法,数学,计算机感兴趣的同学,欢迎关注我哈,阅读更多原创文章】
我的网站:潮汐朝夕的生活实验室
我的公众号:算法题刷刷
我的知乎:潮汐朝夕
我的github:FennelDumplings
我的leetcode:FennelDumplings


使用泛型机制编写的程序代码要比那些杂乱地使用 Object 变量,然后再进行强制类型转换的代码具有更好的安全性和可读性。泛型对于集合类尤其有用,例如,ArrayList 就是一个无处不在的集合类。

泛型很像 C++ 中的模板。与 Java 一样,在 C++ 中,模板也是最先被添加到语言中支持强类型集合的。后续发现模板还有其他的用武之地。Java 也一样,泛型在程序中有很多除了集合外的其它用途。

但注意,从表面上看,Java 的泛型类虽然类似于 C++ 的模板类,唯一明显的不同是 Java 没有专用的 template 关键字。但是,在本文中可以看到,这两种机制有着本质的区别。

本文是《Java核心技术1》第10版 Chap8 中关于泛型的要点总结的第二部分,主要的要点如下:

  • 泛型的约束与局限性
  • 泛型类型的继承规则
  • 通配符类型
  • 泛型与反射

泛型的约束与局限性

使用 Java 泛型时需要考虑的一些限制。大多数限制都是由类型擦除引起的。

(1) 不能用基本类型实例化类型参数

不能用类型参数代替基本类型。例如,没有 Pair<double>,只有 Pair<Double>

原因是类型擦除。擦除之后,Pair 类含有 Object 类型的域,而 Object 不能存储 double 值。

(2) 运行时类型查询只适用于原始类型

虚拟机中的对象总有一个特定的非泛型类型。因此,所有的类型查询只产生原始类型。getClass 返回的是原始类型。

以下两行均会 Error:

1
2
if(a instanceof Pair<String>)
if(a instanceof Pair<T>)

强制类型转换会 Warning:

1
Pair<String> p = (Pair<String>) a;

(3) 不能创建参数化类型的数组

不能实例化参数化类型的数组。

1
Pair<String>[] table = new Pair<String>[10];

声明参数化类型的数组变量还是合法的,只是不能用 new Pair<String>[10] 初始化。

如果要收集参数化类型对象,只有一种安全方法:使用 ArrayList<Pair<String>>

(4) Varargs 警告

Java 不支持泛型类型的数组。那么如何向参数个数可变的方法传递一个泛型类型的实例就是一个问题了。

首先看一个参数可变的方法

1
2
3
4
5
Public static <T> void addAll(Collection<T> coll, T... ts) {
for(T t: ts) {
coll.add(t);
}
}

考虑以下调用:

1
2
3
4
Collection<Pair<String>> table = ...;
Pair<String> pair1 = ...;
Pair<String> pair2 = ...;
addAll(table, pair1, pair2);

为了调用这个方法,Java 虚拟机必须建立一个 Pair<String> 数组,这就违反了前面的规则。不过,对于这种情况,规则有所放松,只会得到警告而不是错误。

有两种方法可以抑制这个警告:

(1) 在包含 addAll 调用的方法增加注解 @SuppressWarnings("unchecked")
(2) @SafeWarargs 直接标注 addAll 方法。

(5) 不能实例化类型变量

new T(...)new T[...]T.class 是非法的。因为类型擦除会使得 T 变成 Object。

Java8 之后,最好的办法是让调用者提供构造器表达式,例如:

1
2
3
4
5
public static <T> Pair<T> makePair(Supplier<T> constr) {
return new Pair<>(constr.get(), constr.get());
}

Pair<String> p = Pair.makePair(String::new);

makePair 方法接收 Supplier<T> 这是一个函数式接口,表示一个无参数且返回类型为 T 的函数。

传统解法是通过反射调用 Class.newInstance 方法构造泛型对象。但注意直接 T.class 是非法的,因为会被擦除为 Object.class。需要写一个 API 通过参数得到 Class 对象:

1
2
3
4
5
6
7
8
9
public static <T> Pair<T> makePair(Class<T> cl) {
try {
return new Pair<>(cl.newInstance(), cl.newInstance());
} catch (Exception ex) {
return null;
}
}

Pair<String> p = Pair.makePair(String.class);

这里 Class 类本身是泛型。String.class 是一个 Class<String> 的实例(也是唯一的实例)。因此,makePair 方法能够推断出 pair 的类型。

(6) 不能构造泛型数组

除了不能实例化泛型实例,实例化泛型数组也不行。

由于数组会填充 null,因此构造时看上去是安全的。但是数组本身的类型如果是泛型的化会被擦除,例如:

1
2
3
4
public static <T extends Comparable> T[] minmax(T... a) {
T[] mm = new T[2];
...
}

由于类型擦除,这个方法中构造的永远是 Comparable[2]。

如果用 Object[] 之后再用类型转换:

1
2
3
4
5
6
7
public static <T extends Comparable> T[] minmax(T... a) {
Object[] mm = new Object[2];
...
return (T[]) mm;
}

String ss = ArrayAlg.minmax("Tom", "Harry", "Dick");

咋编译时不会有警告,当 Object[] 引用赋给 Comparable[] 变量时,会 ClassCastException 异常。

此时解法依然是构造器表达式和利用反射的 Array.newInstance 两种。

(1) 使用构造器表达式

1
2
3
4
5
6
public static <T extends Comparable> T[] minmax(IntFunction<T[]> constr, T... a) {
T[] mm = constr.apply(2);
...
}

String ss = ArrayAlg.minmax(String[]::new, "Tom", "Dick", "Harry");

构造器表达式 String::new 指示一个函数,给定所需的长度,会构造一个指定长度的 String 数组。

(2) 利用反射,调用 Array.newInstance

1
2
3
public static <T extends Comparable> T[]S minmax(T... a) {
T[] mm = (T[]) Array.newInstance(a.getClass().getComponentType(), 2);
}

(7) 泛型类的静态上下文中类型变量无效

不能在静态域或方法中引用类型变量。例如下面的静态域和静态方法都是不行的。

创建 Singleton<Random>Singleton<String> 后,由于类型擦除,只剩下 Singleton 类,只包含一个 singleInstance 域。

1
2
3
4
5
6
7
public class Singleton<T> {
private static T singleInstance;

public static T getSingleInstance() {
...
}
}

(8) 不能抛出或捕获泛型类的实例

既不能抛出也不能捕获泛型类对象。泛型类扩展 Throwable 也不合法。

例如下面这两段代码都是错的:

1
2
3
public class Problem<T> extends Exception { // 错误,不能 extends Throwable
...
}
1
2
3
4
5
6
7
public static <T extends Throwable> void doWork(Class<T> t) {
try {
...
} catch (T e) { // 错误,不能捕获类型变量
Logger.global.info(...);
}
}

但是在异常规范中使用类型变量是可以的,例如:

1
2
3
4
5
6
7
8
public static <T extends Throwable> void doWork(T t) throws T {
try {
...
} catch (Throwable realCause) { // 错误,不能捕获类型变量
t.initCause(realCause);
throw t;
}
}

(9) 可以消除对受查异常的检查

Java 异常处理的一个基本原则是,必须为所有受查异常提供一个处理器。不过可以利用泛型消除这个限制。关键在于以下方法:

1
2
3
4
@SuppressWarnings("unchecked")
public static <T extends Throwable> void throwAs (Throwable e) throws T {
throw (T) e;
}

假设这个方法再累 Block 中,考虑以下调用:

1
Block.<RuntimeException>throwAs(t);

编译器会认为 t 是一个非受查异常。

下面代码会把所有异常都转换为编译器认为的非受查异常:

1
2
3
4
5
try {
...
} catch (Throwable t) {
Block.<RuntimeException>throwAs(t);
}

把上面的 throwAs 方法包装到一个抽象类中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public abstract class Block {
public abstract void body() throws Exception;

public Thread toThread() {
return new Thread()
{
public void run() {
try {
body();
} catch (Throwable t) {
Block.<RuntimeException>throwAs(t);
}
}
};
}

@SuppressWarnings("unchecked")
public static <T extends Throwable> void throwAs(Throwable e) throws T {
throw (T) e;
}
}

上面是抽象类 Block,我们可以覆盖 body 方法来提供一个具体动作,调用 toThread 时会得到 Thread 类的对象,其 run 方法不会介意受查异常。

下面的带运行一个线程,会抛出一个受查异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Test {
public static void main(String[] args) {
new Block()
{
public void body() throws Exception {
Scanner in = new Scanner(new File("ququx"), "UTF-8");
while(is.hasNext()) {
System.out.println(in.next());
}

}
}
.toThread().start();
}
}

现在还没有名为 ququx 的文件,运行这个程序时,会得到一个栈轨迹,其中包含一个 FileNotFoundException。

正常情况下,代码中必须捕获线程 run 方法中的所有受查异常,把它们包装到非受查异常中,因为 run 方法声明为不抛出任何受查异常。

但这里没有做这种包装,只是抛出异常,并让编译器认为这不是一个受查异常。相当于通过使用泛型类、类型擦除和 @SuppressWarnings 注解,绕过了 Java 类型系统的部分限制。

(10) 注意擦除后的冲突

当泛型类被擦除时,无法创建引发冲突的条件。

下面以 Pair<T> 的 equals 方法为例,假设 equals 定义如下:

1
2
3
4
5
public class Pair<T> {
public boolean equals(T value) {
return first.equals(value) && second.equals(value);
}
}

考虑 Pair<String>,此时有两个 equals 方法:

1
2
boolean equals(String) // 在 Pair<T> 中定义
boolean equals(Object) // 从 Object 继承

而方法 boolean equals(T) 擦除后就是 boolean equals(Object),这就与 Object.equals 冲突了。补救办法是重新命名引发错误的方法。

泛型规范说明还提到一个原则:要想支持擦除的转换,就需要强行限制一个类或类型变量不能同时成为两个接口类型的子类,而这两个接口是同一接口的不同参数化。例如:

1
2
class Employee implements Comparable<Employee> {...}
class Manager extends Employee implements Comparable<Manager> {...}

上面代码中 Manager 类是错的,Manager 会实现 Comparable<Employee>Comparable<Manager>,这是同一接口的不同参数化。

这一原则与类型擦除的关系并不十分明确。毕竟,下列非泛型版本是合法的:

1
2
class Employee implements Comparable {...}
class Manager extends Employee implements Comparable{...}

原因有可能是与合成的桥方法产生冲突:实现了 Comparable<X> 的类可以获得一个桥方法。

1
2
3
public int compareTo(Object other)  {
return compareTo((X) other);
}

对于不同类型的 X 不能有两个这样的桥方法。


泛型类型的继承规则

B 是 A 的子类,Pair<A>Pair<B> 的关系

Manager 是 Employee 的子类,但是 Pair<Manager>Pair<Employee> 是没有什么关系的。例如下面代码:

1
2
Manager[] a = ..
Pair<Employee> result = ArrayAlg.minmax(a); // 错误

由于 minmax 返回 Pair<Manager> 而不是 Pair<Employee>Pair<Manager> 是不能转换为 Pair<Employee> 的,这样的赋值就不合法。

注意泛型与 Java 数组之间的重要区别。可以将一个 Manager[] 数组赋给一个类型为 Employee[] 的变量

1
2
Manager[] = managerBuddies = {ceo, cfo};
Employee[] = employeeBuddies = managerBuddies;

但注意:数组带有特别的保护,如果试图将一个低级别的雇员存储到 employeeBuddies[0],虚拟机将会抛出 ArrayStoreException 异常。

Pair<A> 与 Pair

永远可以将参数化类型转换为一个原始类型。例如,Pair<Employee> 是原始类型 Pair 的一个子类型。

在与遗留代码衔接时这个转换很重要。

转换成原始类型后还是有可能会产生类型错误的。例如:

1
2
3
Pair<Manager> managerBuddies = new Pair<>(ceo, cfo);
Pair rawBuddies = managerBuddies;
rawBuddies.setFirst(new File("..."));

泛型类可以扩展或实现其它的泛型类

例如 ArrayList<T> 类实现 List<T> 接口。

这样 ArrayList<Manager> 就可以转换为 List<Manager>。而 ArrayList<Manager>ArrayList<Employee>List<Employee> 无关。

这几个泛型列表类型中的子类型间的关系图如下:


通配符类型

固定的泛型类型系统的限制还是很多的,用起来有点不方便。对此有一个巧妙的解决方案:通配符类型。

通配符类型的概念

通配符类型中,允许类型参数变化。例如下面的通配符类型表示任何泛型 Pair 类型,它的类型参数是 Employee 的子类。

1
Pair<? extends Employee>

Pair<Manager>Pair<? extends Employee> 的子类型。使用通配符的子类型的关系如下图:

对比下面两个方法:

1
2
3
4
5
6
7
public static void printBuddies(Pair<Employee> p) {
Employee first = p.getFirst();
Employee second = p.getSecond();
System.out.println(first.getName() + " and " + second.getName() + " are buddies.");
}

public static void printBuddies(Pair<? extends Employee> p)

第一个方法是不能将 Pair<Manager> 传给这个方法的。但是使用通配符类型的第二个方法是可以的。

回看之前提到过的将 Pair<Manager> 对象赋值给 Pair<Employee> 引用不合法的问题,原因是 Pair<Manager> 不能转换为 Pair<Employee>

如果使用通配符类型会怎么样:

1
2
3
Pair<Manager> managerBuddies = new Pair<>(ceo, cfo);
Pair<? extends Employee> wildcardBuddies = managerBuddies;
wildcardBuddies.setFirst(lowlyEmployee);

对 setFirst 的调用会有报错,因为通过 ? extends Employee 编译器只知道应该调用某个 Employee 的子类,但不知道具体是什么类型,他会拒绝传递任何特定类型。

getFirst 没有这个问题,将其 ? extends Employee 的返回值赋给 Employee 的引用是合法的。

通配符的超类型限定

通配符限定与类型变量限定十分类似,但是,还有一个附加的能力,即可以指定一个超类型限定。写法如下:

1
? super Manager

这个通配符限制为 Manager 的所有超类型。

带有超类型限定的通配符可以为方法提供参数,但不能使用返回值。例如:

1
2
void setFirst(? super Manager) 
? super Manager getFirst()

对于 setFirst: 编译器无法知道 setFirst 方法的具体类型,因此调用这个方法时不能接受类型为 Employee 或 Object 的参数。只能传递 Manager 类型或某个子类型对象。

对于 getFirst: 如果调用 getFirst,不能保证返回对象的类型,只能赋给一个 Object。

带有超类型限定的通配符的各个类的关系图如下:

下面的代码中,给定一个 Manager 数组,从中找到奖金最高和最低的,放入一个 Pair 对象中。

使用了带有超类型限定的通配符后,将可以接受任何适当的 Pair:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void minmaxBonus(Manager[] a, Pair<? super Manager> result) {
if(a.length == 0) {
return;
}
Manager min = a[0];
Manager max = a[0];
for(int i = 0; i < a.length; i++) {
if(min.getBonus() > a[i].getBonus()) {
min = a[i];
}
if(max.getBonus() < a[i].getBonus()) {
max = a[i];
}
}
result.setFirst(min);
result.setSecond(max);
}

总结:带有超类型限定的通配符可以向泛型对象写入、带有子类型限定的通配符可以从泛型对象读取

<T extends Comparable<? super T>>

Comparable 本身是个泛型类型,声明如下:

1
2
3
public interface Comparable<T> {
public int compareTo(T other);
}

这里类型变量指示了 other 参数的类型。例如,String 类实现 Comparable <String>,它的 compareTo 方法被声明为

1
public int compareTo(String other)

在接口是一个泛型接口之前,other 是一个 Object,并且这个方法的实现需要强制类型转换。

考虑 ArrayAlg 的 min 方法,如果写成下面这样:

1
public static <T extends Comparable<T>> T min(T[] a)

如果计算一个 String 数组的最小值,T 就是 String 类型的,而 String 是 Comparable<String> 的子类型。

但是,处理一个 LocalDate 对象的数组时,会出现问题:LocalDate 实现了 ChronoLocalDate,而 ChronoLocalDate 扩展了 Comparable<ChronoLocalDate>。因此,LocalDate 实现的是 Comparable<ChronoLocalDate> 而不是 Comparable<LocalDate>此时的解法是写成下面这样:

1
public static <T extends Comparable<? super T>> T min(T[] a)

此时 compareTo 方法被声明为:

1
int compareTo(? super T)

这个声明有可能使用类型 T 的对象,也可能使用 T 的超类型对象。

子类型限定作为一个函数式接口的参数类型

在文章 Java核心技术1-lambda表达式 中,我们在函数式接口中举了 Collection.removeIf 的例子。这里我们继续以这个方法为例子。

1
default boolean removeIf(Predicate<? super E> filter)

这个方法会删除所有满足给定谓词条件的元素,例如:

1
2
3
ArrayList<Employee> staff = ...;
Predicate<Object> oddHashCode = obj -> obj.hashCode() % 2 != 0;
staff.removeIf(oddHashCode);

希望传入 Predicate<Object> 而不只是 Predicate<Employee>,super 通配符起到的就是这个作用。

无限定通配符

无限定的通配符也是有的,写法如下:

1
2
3
4
5
6
7
8
Pair<? >
```

`Pair<? >` 看起来好像与原始的 Pair 一样。但还是有很大区别的。`pair<? >` 有以下方法:

```java
? getFirst()
void setFirst(?)

其中:

  • jgetFirst 的返回值只能赋值给一个 Object
  • jsetFirst 方法不能被调用,甚至不能被 Object 调用。

Pair<? > 与 Pair 的本质区别在于:可以用任意 Object 对象调用原始 Pair 类的 setFirst 方法。

例子: 测试一个 pair 是否包含一个 null 引用

1
2
3
public static boolean hasNulls(Pair<?> p) {
return p.getFirst() == null || p.getSecond() == null;
}

通过将 hasNulls 换成一个泛型方法,可以避免使用通配符类型,不过通配符版本可读性更好。

1
public static <T> boolean hasNulls(Pair<T> p)

通配符捕获

交换成对元素的方法:

1
public static void swap(Pair<?> p)

通配符不是类型变量,因此不能在代码中用 ? 作为一种类型。因此下列写法不行:

1
2
3
? tmp = p.getFirst();
p.setFirst(p.getSecond());
p.setSecond(tmp);

但是交换时又必须临时保存一个元素,这里有个泛型的辅助方法的方案:

1
2
3
4
5
public static <T> void swapHelper(Pair<T> p) {
T tmp = p.getFirst();
p.setFirst(p.getSecond());
p.setSecond(tmp);
}

这里 swapHelper 是一个泛型方法,而 swap 不是,它有固定的 Pair<? > 的参数。

现在 swap 就可以写成下面这样:

1
2
3
public static void swap(Pair<?> p) {
swapHelper(p);
}

此时 swapHelper 方法的参数 T 捕获通配符。


Share