本章概要
- 构建复杂模型
- 泛型擦除
- C++ 的方式
- 迁移兼容性
- 擦除的问题
- 边界处的动作
构建复杂模型
泛型的一个重要好处是能够简单安全地创建复杂模型。例如,我们可以轻松地创建一个元组列表:
TupleList.java
import java.util.ArrayList;
public class TupleList<A, B, C, D>
extends ArrayList<Tuple4<A, B, C, D>> {
public static void main(String[] args) {
TupleList<Vehicle, Amphibian, String, Integer> tl =
new TupleList<>();
tl.add(TupleTest2.h());
tl.add(TupleTest2.h());
tl.forEach(System.out::println);
}
}
相关类:
Amphibian.java
public class Amphibian {
}
Tuple.java
public class Tuple {
public static <A, B> Tuple2<A, B> tuple(A a, B b) {
return new Tuple2<>(a, b);
}
public static <A, B, C> Tuple3<A, B, C>
tuple(A a, B b, C c) {
return new Tuple3<>(a, b, c);
}
public static <A, B, C, D> Tuple4<A, B, C, D>
tuple(A a, B b, C c, D d) {
return new Tuple4<>(a, b, c, d);
}
public static <A, B, C, D, E>
Tuple5<A, B, C, D, E> tuple(A a, B b, C c, D d, E e) {
return new Tuple5<>(a, b, c, d, e);
}
}
Tuple2.java
public class Tuple2<A, B> {
public final A a1;
public final B a2;
public Tuple2(A a, B b) {
a1 = a;
a2 = b;
}
public String rep() {
return a1 + ", " + a2;
}
@Override
public String toString() {
return "(" + rep() + ")";
}
}
Tuple3.java
public class Tuple3<A, B, C> extends Tuple2<A, B> {
public final C a3;
public Tuple3(A a, B b, C c) {
super(a, b);
a3 = c;
}
@Override
public String rep() {
return super.rep() + ", " + a3;
}
}
Tuple4.java
public class Tuple4<A, B, C, D>
extends Tuple3<A, B, C> {
public final D a4;
public Tuple4(A a, B b, C c, D d) {
super(a, b, c);
a4 = d;
}
@Override
public String rep() {
return super.rep() + ", " + a4;
}
}
Tuple5.java
public class Tuple5<A, B, C, D, E>
extends Tuple4<A, B, C, D> {
public final E a5;
public Tuple5(A a, B b, C c, D d, E e) {
super(a, b, c, d);
a5 = e;
}
@Override
public String rep() {
return super.rep() + ", " + a5;
}
}
TupleTest2.java
public class TupleTest2 {
static Tuple2<String, Integer> f() {
return tuple("hi", 47);
}
static Tuple2 f2() {
return tuple("hi", 47);
}
static Tuple3<Amphibian, String, Integer> g() {
return tuple(new Amphibian(), "hi", 47);
}
static Tuple4<Vehicle, Amphibian, String, Integer> h() {
return tuple(
new Vehicle(), new Amphibian(), "hi", 47);
}
static Tuple5<Vehicle, Amphibian,
String, Integer, Double> k() {
return tuple(new Vehicle(), new Amphibian(),
"hi", 47, 11.1);
}
public static void main(String[] args) {
Tuple2<String, Integer> ttsi = f();
System.out.println(ttsi);
System.out.println(f2());
System.out.println(g());
System.out.println(h());
System.out.println(k());
}
}
这将产生一个功能强大的数据结构,而无需太多代码。
下面是第二个例子。每个类都是组成块,总体包含很多个块。在这里,该模型是一个具有过道,货架和产品的零售商店:
Suppliers.java
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
public class Suppliers {
// Create a collection and fill it:
public static <T, C extends Collection<T>> C
create(Supplier<C> factory, Supplier<T> gen, int n) {
return Stream.generate(gen)
.limit(n)
.collect(factory, C::add, C::addAll);
}
// Fill an existing collection:
public static <T, C extends Collection<T>> C fill(C coll, Supplier<T> gen, int n) {
Stream.generate(gen)
.limit(n)
.forEach(coll::add);
return coll;
}
// Use an unbound method reference to
// produce a more general method:
public static <H, A> H fill(H holder,
BiConsumer<H, A> adder, Supplier<A> gen, int n) {
Stream.generate(gen)
.limit(n)
.forEach(a -> adder.accept(holder, a));
return holder;
}
}
Store.java
import java.util.*;
import java.util.function.*;
class Product {
private final int id;
private String description;
private double price;
Product(int idNumber, String descr, double price) {
id = idNumber;
description = descr;
this.price = price;
System.out.println(toString());
}
@Override
public String toString() {
return id + ": " + description +
", price: $" + price;
}
public void priceChange(double change) {
price += change;
}
public static Supplier<Product> generator =
new Supplier<Product>() {
private Random rand = new Random(47);
@Override
public Product get() {
return new Product(rand.nextInt(1000), "Test",
Math.round(
rand.nextDouble() * 1000.0) + 0.99);
}
};
}
class Shelf extends ArrayList<Product> {
Shelf(int nProducts) {
Suppliers.fill(this, Product.generator, nProducts);
}
}
class Aisle extends ArrayList<Shelf> {
Aisle(int nShelves, int nProducts) {
for (int i = 0; i < nShelves; i++) {
add(new Shelf(nProducts));
}
}
}
class CheckoutStand {
}
class Office {
}
public class Store extends ArrayList<Aisle> {
private ArrayList<CheckoutStand> checkouts =
new ArrayList<>();
private Office office = new Office();
public Store(
int nAisles, int nShelves, int nProducts) {
for (int i = 0; i < nAisles; i++) {
add(new Aisle(nShelves, nProducts));
}
}
@Override
public String toString() {
StringBuilder result = new StringBuilder();
for (Aisle a : this) {
for (Shelf s : a) {
for (Product p : s) {
result.append(p);
result.append("\n");
}
}
}
return result.toString();
}
public static void main(String[] args) {
System.out.println(new Store(5, 4, 3));
}
}
Store.toString()
显示了结果:尽管有复杂的层次结构,但多层的集合仍然是类型安全的和可管理的。令人印象深刻的是,组装这样的模型并不需要耗费过多精力。
Shelf 使用 Suppliers.fill()
这个实用程序,该实用程序接受 Collection (第一个参数),并使用 Supplier (第二个参数),以元素的数量为 n (第三个参数)来填充它。 Suppliers 类将会在本章末尾定义,其中的方法都是在执行某种填充操作,并在本章的其他示例中使用。
泛型擦除
当你开始更深入地钻研泛型时,会发现有大量的东西初看起来是没有意义的。例如,尽管可以说 ArrayList.class
,但不能说成 ArrayList<Integer>.class
。考虑下面的情况:
public class ErasedTypeEquivalence {
public static void main(String[] args) {
Class c1 = new ArrayList<String>().getClass();
Class c2 = new ArrayList<Integer>().getClass();
System.out.println(c1 == c2);
}
}
ArrayList<String>
和 ArrayList<Integer>
应该是不同的类型。不同的类型会有不同的行为。例如,如果尝试向 ArrayList<String>
中放入一个 Integer
,所得到的行为(失败)和向 ArrayList<Integer>
中放入一个 Integer
所得到的行为(成功)完全不同。然而上面的程序认为它们是相同的类型。
下面的例子是对该谜题的补充:
import java.util.*;
class Frob {
}
class Fnorkle {
}
class Quark<Q> {
}
class Particle<POSITION, MOMENTUM> {
}
public class LostInformation {
public static void main(String[] args) {
List<Frob> list = new ArrayList<>();
Map<Frob, Fnorkle> map = new HashMap<>();
Quark<Fnorkle> quark = new Quark<>();
Particle<Long, Double> p = new Particle<>();
System.out.println(Arrays.toString(list.getClass().getTypeParameters()));
System.out.println(Arrays.toString(map.getClass().getTypeParameters()));
System.out.println(Arrays.toString(quark.getClass().getTypeParameters()));
System.out.println(Arrays.toString(p.getClass().getTypeParameters()));
}
}
根据 JDK 文档,Class.getTypeParameters() “返回一个 TypeVariable 对象数组,表示泛型声明中声明的类型参数…” 这暗示你可以发现这些参数类型。但是正如上例中输出所示,你只能看到用作参数占位符的标识符,这并非有用的信息。
残酷的现实是:
在泛型代码内部,无法获取任何有关泛型参数类型的信息。
因此,你可以知道如类型参数标识符和泛型边界这些信息,但无法得知实际的类型参数从而用来创建特定的实例。如果你曾是 C++ 程序员,那么这个事实会让你很沮丧,在使用 Java 泛型工作时,它是必须处理的最基本的问题。
Java 泛型是使用擦除实现的。这意味着当你在使用泛型时,任何具体的类型信息都被擦除了,你唯一知道的就是你在使用一个对象。因此,List<String>
和 List<Integer>
在运行时实际上是相同的类型。它们都被擦除成原生类型 List
。
理解擦除并知道如何处理它,是你在学习 Java 泛型时面临的最大障碍之一。这也是本节将要探讨的内容。
C++ 的方式
下面是使用模版的 C++ 示例。你会看到类型参数的语法十分相似,因为 Java 是受 C++ 启发的:
// generics/Templates.cpp
#include <iostream>
using namespace std;
template<class T> class Manipulator {
T obj;
public:
Manipulator(T x) { obj = x; }
void manipulate() { obj.f(); }
};
class HasF {
public:
void f() { cout << "HasF::f()" << endl; }
};
int main() {
HasF hf;
Manipulator<HasF> manipulator(hf);
manipulator.manipulate();
}
/* Output:
HasF::f()
*/
Manipulator 类存储了一个 T 类型的对象。manipulate()
方法会调用 obj 上的 f()
方法。它是如何知道类型参数 T 中存在 f()
方法的呢?C++ 编译器会在你实例化模版时进行检查,所以在 Manipulator<HasF>
实例化的那一刻,它看到 HasF 中含有一个方法 f()
。如果情况并非如此,你就会得到一个编译期错误,保持类型安全。
用 C++ 编写这种代码很简单,因为当模版被实例化时,模版代码就知道模版参数的类型。Java 泛型就不同了。下面是 HasF 的 Java 版本:
public class HasF {
public void f() {
System.out.println("HasF.f()");
}
}
如果我们将示例的其余代码用 Java 实现,就不会通过编译:
class Manipulator<T> {
private T obj;
Manipulator(T x) {
obj = x;
}
// Error: cannot find symbol: method f():
public void manipulate() {
obj.f();
}
}
public class Manipulation {
public static void main(String[] args) {
HasF hf = new HasF();
Manipulator<HasF> manipulator = new Manipulator<>(hf);
manipulator.manipulate();
}
}
因为擦除,Java 编译器无法将 manipulate()
方法必须能调用 obj 的 f()
方法这一需求映射到 HasF 具有 f()
方法这个事实上。为了调用 f()
,我们必须协助泛型类,给定泛型类一个边界,以此告诉编译器只能接受遵循这个边界的类型。这里重用了 extends 关键字。由于有了边界,下面的代码就能通过编译:
public class Manipulator2<T extends HasF> {
private T obj;
Manipulator2(T x) {
obj = x;
}
public void manipulate() {
obj.f();
}
}
边界 <T extends HasF>
声明 T 必须是 HasF 类型或其子类。如果情况确实如此,就可以安全地在 obj 上调用 f()
方法。
我们说泛型类型参数会擦除到它的第一个边界(可能有多个边界,稍后你将看到)。我们还提到了类型参数的擦除。编译器实际上会把类型参数替换为它的擦除,就像上面的示例,T 擦除到了 HasF,就像在类的声明中用 HasF 替换了 T 一样。
你可能正确地观察到了泛型在 Manipulator2.java 中没有贡献任何事。你可以很轻松地自己去执行擦除,生成没有泛型的类:
class Manipulator3 {
private HasF obj;
Manipulator3(HasF x) {
obj = x;
}
public void manipulate() {
obj.f();
}
}
这提出了很重要的一点:泛型只有在类型参数比某个具体类型(以及其子类)更加“泛化”——代码能跨多个类工作时才有用。因此,类型参数和它们在有用的泛型代码中的应用,通常比简单的类替换更加复杂。但是,不能因此认为使用 <T extends HasF>
形式就是有缺陷的。例如,如果某个类有一个返回 T 的方法,那么泛型就有所帮助,因为它们之后将返回确切的类型:
public class ReturnGenericType<T extends HasF> {
private T obj;
ReturnGenericType(T x) {
obj = x;
}
public T get() {
return obj;
}
}
你必须查看所有的代码,从而确定代码是否复杂到必须使用泛型的程度。
我们将在本章稍后看到有关边界的更多细节。
迁移兼容性
为了减少潜在的关于擦除的困惑,你必须清楚地认识到这不是一个语言特性。它是 Java 实现泛型的一种妥协,因为泛型不是 Java 语言出现时就有的,所以就有了这种妥协。它会使你痛苦,因此你需要尽早习惯它并了解为什么它会这样。
如果 Java 1.0 就含有泛型的话,那么这个特性就不会使用擦除来实现——它会使用具体化,保持参数类型为第一类实体,因此你就能在类型参数上执行基于类型的语言操作和反射操作。本章稍后你会看到,擦除减少了泛型的泛化性。泛型在 Java 中仍然是有用的,只是不如它们本来设想的那么有用,而原因就是擦除。
在基于擦除的实现中,泛型类型被当作第二类类型处理,即不能在某些重要的上下文使用泛型类型。泛型类型只有在静态类型检测期间才出现,在此之后,程序中的所有泛型类型都将被擦除,替换为它们的非泛型上界。例如, List<T>
这样的类型注解会被擦除为 List,普通的类型变量在未指定边界的情况下会被擦除为 Object。
擦除的核心动机是你可以在泛化的客户端上使用非泛型的类库,反之亦然。这经常被称为“迁移兼容性”。在理想情况下,所有事物将在指定的某天被泛化。在现实中,即使程序员只编写泛型代码,他们也必须处理 Java 5 之前编写的非泛型类库。这些类库的作者可能从没想过要泛化他们的代码,或许他们可能刚刚开始接触泛型。
因此 Java 泛型不仅必须支持向后兼容性——现有的代码和类文件仍然合法,继续保持之前的含义——而且还必须支持迁移兼容性,使得类库能按照它们自己的步调变为泛型,当某个类库变为泛型时,不会破坏依赖于它的代码和应用。在确定了这个目标后,Java 设计者们和从事此问题相关工作的各个团队决策认为擦除是唯一可行的解决方案。擦除使得这种向泛型的迁移成为可能,允许非泛型的代码和泛型代码共存。
例如,假设一个应用使用了两个类库 X 和 Y,Y 使用了类库 Z。随着 Java 5 的出现,这个应用和这些类库的创建者最终可能希望迁移到泛型上。但是当进行迁移时,它们有着不同的动机和限制。为了实现迁移兼容性,每个类库与应用必须与其他所有的部分是否使用泛型无关。因此,它们不能探测其他类库是否使用了泛型。因此,某个特定的类库使用了泛型这样的证据必须被”擦除“。
如果没有某种类型的迁移途径,所有已经构建了很长时间的类库就需要与希望迁移到 Java 泛型上的开发者们说再见了。类库毫无争议是编程语言的一部分,对生产效率有着极大的影响,所以这种代码无法接受。擦除是否是最佳的或唯一的迁移途径,还待时间来证明。
擦除的问题
因此,擦除主要的正当理由是从非泛化代码到泛化代码的转变过程,以及在不破坏现有类库的情况下将泛型融入到语言中。擦除允许你继续使用现有的非泛型客户端代码,直至客户端准备好用泛型重写这些代码。这是一个崇高的动机,因为它不会骤然破坏所有现有的代码。
擦除的代价是显著的。泛型不能用于显式地引用运行时类型的操作中,例如转型、instanceof 操作和 new 表达式。因为所有关于参数的类型信息都丢失了,当你在编写泛型代码时,必须时刻提醒自己,你只是看起来拥有有关参数的类型信息而已。
考虑如下的代码段:
class Foo<T> {
T var;
}
看上去当你创建一个 Foo 实例时:
Foo<Cat> f = new Foo<>();
class Foo 中的代码应该知道现在工作于 Cat 之上。泛型语法也在强烈暗示整个类中所有 T 出现的地方都被替换,就像在 C++ 中一样。但是事实并非如此,当你在编写这个类的代码时,必须提醒自己:“不,这只是一个 Object“。
另外,擦除和迁移兼容性意味着,使用泛型并不是强制的,尽管你可能希望这样:
class GenericBase<T> {
private T element;
public void set(T arg) {
element = arg;
}
public T get() {
return element;
}
}
class Derived1<T> extends GenericBase<T> {
}
class Derived2 extends GenericBase {
} // No warning
// class Derived3 extends GenericBase<?> {}
// Strange error:
// unexpected type
// required: class or interface without bounds
public class ErasureAndInteritance {
@SuppressWarnings("unchecked")
public static void main(String[] args) {
Derived2 d2 = new Derived2();
Object obj = d2.get();
d2.set(obj); // Warning here!
}
}
Derived2 继承自 GenericBase,但是没有任何类型参数,编译器没有发出任何警告。直到调用 set()
方法时才出现警告。
为了关闭警告,Java 提供了一个注解,我们可以在列表中看到它:
@SuppressWarnings("unchecked")
这个注解放置在产生警告的方法上,而不是整个类上。当你要关闭警告时,最好尽可能地“聚焦”,这样就不会因为过于宽泛地关闭警告,而导致意外地遮蔽掉真正的问题。
可以推断,Derived3 产生的错误意味着编译器期望得到一个原生基类。
当你希望将类型参数不仅仅当作 Object 处理时,就需要付出额外努力来管理边界,并且与在 C++、Ada 和 Eiffel 这样的语言中获得参数化类型相比,你需要付出多得多的努力来获得少得多的回报。这并不是说,对于大多数的编程问题而言,这些语言通常都会比 Java 更得心应手,只是说它们的参数化类型机制相比 Java 更灵活、更强大。
边界处的动作
因为擦除,我发现了泛型最令人困惑的方面是可以表示没有任何意义的事物。例如:
import java.lang.reflect.*;
import java.util.*;
public class ArrayMaker<T> {
private Class<T> kind;
public ArrayMaker(Class<T> kind) {
this.kind = kind;
}
@SuppressWarnings("unchecked")
T[] create(int size) {
return (T[]) Array.newInstance(kind, size);
}
public static void main(String[] args) {
ArrayMaker<String> stringMaker = new ArrayMaker<>(String.class);
String[] stringArray = stringMaker.create(9);
System.out.println(Arrays.toString(stringArray));
}
}
即使 kind 被存储为 Class<T>
,擦除也意味着它实际被存储为没有任何参数的 Class。因此,当你在使用它时,例如创建数组,Array.newInstance()
实际上并未拥有 kind 所蕴含的类型信息。所以它不会产生具体的结果,因而必须转型,这会产生一条令你无法满意的警告。
注意,对于在泛型中创建数组,使用 Array.newInstance()
是推荐的方式。
如果我们创建一个集合而不是数组,情况就不同了:
import java.util.*;
public class ListMaker<T> {
List<T> create() {
return new ArrayList<>();
}
public static void main(String[] args) {
ListMaker<String> stringMaker = new ListMaker<>();
List<String> stringList = stringMaker.create();
}
}
编译器不会给出任何警告,尽管我们知道(从擦除中)在 create()
内部的 new ArrayList<>()
中的 <T>
被移除了——在运行时,类内部没有任何 <T>
,因此这看起来毫无意义。但是如果你遵从这种思路,并将这个表达式改为 new ArrayList()
,编译器就会发出警告。
本例中这么做真的毫无意义吗?如果在创建 List 的同时向其中放入一些对象呢,像这样:
import java.util.*;
import java.util.function.*;
public class FilledList<T> extends ArrayList<T> {
FilledList(Supplier<T> gen, int size) {
Suppliers.fill(this, gen, size);
}
public FilledList(T t, int size) {
for (int i = 0; i < size; i++) {
this.add(t);
}
}
public static void main(String[] args) {
List<String> list = new FilledList<>("Hello", 4);
System.out.println(list);
// Supplier version:
List<Integer> ilist = new FilledList<>(() -> 47, 4);
System.out.println(ilist);
}
}
即使编译器无法得知 add()
中的 T 的任何信息,但它仍可以在编译期确保你放入 FilledList 中的对象是 T 类型。因此,即使擦除移除了方法或类中的实际类型的信息,编译器仍可以确保方法或类中使用的类型的内部一致性。
因为擦除移除了方法体中的类型信息,所以在运行时的问题就是_边界_:即对象进入和离开方法的地点。这些正是编译器在编译期执行类型检查并插入转型代码的地点。
考虑如下这段非泛型示例:
public class SimpleHolder {
private Object obj;
public void set(Object obj) {
this.obj = obj;
}
public Object get() {
return obj;
}
public static void main(String[] args) {
SimpleHolder holder = new SimpleHolder();
holder.set("Item");
String s = (String) holder.get();
}
}
如果用 javap -c SimpleHolder 反编译这个类,会得到如下内容(经过编辑):
set()
和 get()
方法存储和产生值,转型在调用 get()
时接受检查。
现在将泛型融入上例代码中:
public class GenericHolder2<T> {
private T obj;
public void set(T obj) {
this.obj = obj;
}
public T get() {
return obj;
}
public static void main(String[] args) {
GenericHolder2<String> holder = new GenericHolder2<>();
holder.set("Item");
String s = holder.get();
}
}
从 get()
返回后的转型消失了,但是我们还知道传递给 set()
的值在编译期会被检查。下面是相关的字节码:
所产生的字节码是相同的。对进入 set()
的类型进行检查是不需要的,因为这将由编译器执行。而对 get()
返回的值进行转型仍然是需要的,只不过不需要你来操作,它由编译器自动插入,这样你就不用编写(阅读)杂乱的代码。
get()
和 set()
产生了相同的字节码,这就告诉我们泛型的所有动作都发生在边界处——对入参的编译器检查和对返回值的转型。这有助于澄清对擦除的困惑,记住:“边界就是动作发生的地方”。