MENU

Generics

2020 年 12 月 21 日 • 阅读: 4961 • 英语

从 J2SE 5.0 引入,这一期待已久的类型系统增强,允许一种类型或方法操作多种类型对象,还提供编译时类型安全保证。它为集合框架添加了编译时类型安全,并且免除了显式类型转换的苦事。

Introduction

JDK 5.0 为 Java 编程语言引入了几个新扩展,其中之一便是泛型。

这是一份介绍泛型的学习路径。你可能熟悉于其它语言里的类似结构,尤其是 C++ 模板。如果是这样,你会发现泛型与它们既相似又有很大不同。如果你没有接触过其他类似结构,那最好;你可以轻装上阵,不用澄清某些概念误解。

泛型允许你在类型上抽象。最常见的例子是容器类型,比如集合框架中那些。

下面是一个典型用法:

List myIntList = new LinkedList();
myIntList.add(Integer.valueOf(0));
Integer x = (Integer) myIntList.iterator().next();

第三行的强制类型转换有点烦人。通常来说,开发者知道特定 List 应该放哪种数据,但显式类型转换却不可或缺。编译器只保证迭代器返回一个 Object。要确保赋值给 Integer 变量是类型安全的,显式转换必不可少。

当然,强制类型转换带来的不仅是混乱,还可能导致运行时错误,因为人总会犯错。

开发者如果能真正表达他们的意愿,限制 List 只包含特定类型数据该有多好啊。这就是泛型背后的核心思想。下面是一个使用泛型实现上述想法的代码片段:

List<Integer> myIntList = new LinkedList<>();
myIntList.add(Integer.valueOf(0));
Integer x = myIntList.iterator().next();

注意变量 myIntList 的类型声明。List<Integer> 指定了这不是一个任意的 List,而是容纳 IntegerList。我们称 List 是一个能够接收类型参数的泛型接口——本例中,参数是 Integer。我们在创建 list 对象时也指定了类型参数。

再注意,第三行的强制类型转换已经没有了。

现在你可能会认为我们只是把混乱换了个地方。虽然第三行的 Integer 没有了,第一行又出现了类型参数 Integer。实际情况与你的理解有很大不同。编译器现在有能力检查程序的类型正确性了。当我们把 myIntList 声明为 List<Integer>,编译器会保证在任何时间和地点使用 myIntList,它的类型都保持一致。相反,强制类型转换意味着程序员认为在代码中的某个点,变量类型应该是怎样的。

引入泛型的最终结果是,程序的可读性和健壮性得到提高,尤其是在大型项目中。

Defining Simple Generics

下面是从 java.util 包下摘录的 ListIterator 接口定义的代码:

public interface List<E> {
    void add(E x);
    Iterator<E> iterator();
}

public interface Iterator<E> {
    E next();
    boolean hasNext();
}

除了尖括号里包含的东西,其他地方你应该都很熟悉。那些便是接口上的 形式类型参数 声明。

在整个泛型声明里,那些你使用一般类型的地方都可以使用类型参数(尽管有许多重要限制;见 The Fine Print

Introduction 里,我们见到了泛型声明 List调用,比如 List<Integer>。在调用里(通常称为 参数化类型),所有形式类型参数(本例中的 E)都被替换为 实际类型参数(本例中的 Integer)。

你可能会设想 List<Integer> 代表 List 的一个版本。在这个版本中,E 被统一替换成 Integer

public interface IntegerList {
    void add(Integer x);
    Iterator<Integer> iterator();
}

这种想象很有帮助,但也会引起误解。

有帮助的一面是,参数化类型 List<Integer> 的确包含像上述解释那样的方法。

而它会引起误解,是因为泛型声明永远不会真正以这种方式展开。它不会产生多份代码拷贝——源码中没有,二进制中没有,硬盘中没有,内存中也没有。如果你是 C++ 程序员,你就会理解这与模板非常不同。

像普通类或接口声明一样,泛型声明也是编译成单个类文件,永久使用。

类型参数等价于方法或构造器中使用的普通参数。非常像方法拥有 形式参数 描述它可以操作的数据类型,泛型声明上的形式类型参数也是如此。当方法被调用时,实际参数 取代了形式参数,随后方法体被执行。当泛型声明被调用时,形式类型参数也会被实际类型参数取代。

留意下命名传统。我们推荐你使用精炼(最好是单字母)且有意义的名称命名形式类型参数。最好不要使用小写字母,以区分形式类型参数与普通类型和接口。许多容器类型使用 E,代表 element,就像上例那样。接下来我们会见到更多其他常规命名。

Generics and Subtyping

让我们检验一下你对泛型的理解。下面的代码片段合法吗?

List<String> ls = new ArrayList<>();
List<Object> lo = ls;

第一行肯定合法,不好判断的是第二行。这可总结为:String ListObject List 吗。大多数人直觉性地认为,“当然!”

好,再看下面的代码:

lo.add(new Object());
String s = ls.get(0); // Attempts to assign an Object to a String!

我们起了两个别名 lslo。访问 ls,它是 String List,而通过 lo,我们可以向其中插入任意对象。最终,ls 就并不只包含 String 了,当我们试图从中取出什么东西时,我们会得到令人不悦的惊喜。

Java 编译器当然会阻止这种行为。第一段代码的第二行在编译时就会报错。

总结一下,如果 FooBar 的子类型(子类或子接口),G 是某个泛型类型声明,G<Foo> 并非 G<Bar> 的子类型。这可能是你学习泛型最困难的事情,因为它违反了我们大脑深处的直觉。

不要为集合是不可变的感到羞愧,可能是直觉引导我们那样去想。

例如,机动车管理局向人口统计局提交了一份驾驶员清单,这听起来是合理的。我们认为 List<Driver>List<Person>,假定 DriverPerson 的子类型。实际上,提交的仅仅是驾驶员注册表的一份 拷贝。否则,统计局可能把不是驾驶员的人添加进车管局清单,产生腐败。

遇到这种情况,我们就需要更有弹性的泛型类型。迄今为止我们见到的规则都太局限了。

Wildcards

考虑编写一段打印集合中所有元素的常规代码。下面可能是你使用旧版语言编写的代码(比如,5.0 之前):

void printCollection(Collection c) {
    Iterator i = c.iterator();
    for (int j = 0; j < c.size(); j++) {
        System.out.println(i.next());
    }
}

下面是首次尝试使用泛型(和新 for 循环语法)编写的代码:

void printCollection(Collection<Object> c) {
    for (Object e : c) {
        System.out.println(e);
    }
}

问题是新版还不如旧版有用。旧版可以接收任何集合作为参数,而新版只能接收 Collection<Object>,我们之前说过,它 并非 所有集合的超类!

所以所有集合类型的超类 什么呢?它写作 Collection<?>(读作 “未知集合”),其元素可以匹配任何类型。自然地,它叫作 通配类型。我们可以这样编写代码:

void printCollection(Collection<?> c) {
    for (Object e : c) {
        System.out.println(e);
    }
}

现在,我们便可给它传递任何类型的集合了。注意方法内部,我们仍能从 c 中读取元素并把它赋值给 Object。这样做总是安全的,因为无论集合的真实类型是什么,它包含的总是对象。然而向集合中添加任意类型对象并不是安全的:

Collection<?> c = new ArrayList<String>();
c.add(new Object()); // Compile time error

由于我们不知道 c 中的元素代表什么类型,我们就不能向其中添加对象。add() 方法接收的参数是 E,即集合元素类型。当实际类型参数是 ? 时,它代表某种未知类型。我们传给 add 的任何参数必须是这种未知类型的子类。又因为我们无从得知它是什么类型,所以我们不能传递任何对象。唯一的例外是 null,它是每个类的成员。

另一方面,给定 List<?>,我们可以 get() 并使用它的返回值。返回值是未知类型,但它总是一个 Object。因此,把它赋值给 Object 变量或传递给接收 Object 的方法总是安全的。

Bounded Wildcards

考虑一个简单的画图程序,它可以绘制诸如长方形和圆形的形状。为了在程序内表示这些类型,你可能会定义像下面这样的类继承层级:

abstract class Shape {
    public abstract void draw(Canvas c);
}

class Circle extends Shape {
    private int x, y, radius;

    @Override
    public void draw(Canvas c) {
        // ...
    }
}

class Rectangle extends Shape {
    private int x, y, width, height;

    @Override
    public void draw(Canvas c) {
        // ...
    }
}

这些形状可以被绘制在 Canvas 上:

class Canvas {
    public void draw(Shape s) {
        s.draw(this);
    }
}

任何绘画都是由多个形状构成的。假定它们被表示成 List,那么在 Canvas 里添加一个绘制所有形状的方法使用上会非常方便:

public void drawAll(List<Shape> shapes) {
    for (Shape s : shapes) {
        s.draw(this);
    }
}

现在类型规则说 drawAll() 的参数必须是 List<Shape>:它不能,例如,接收 List<Circle>。不幸的是,方法做的只是从 List 中读取 Shape,所以它并不该拒绝 List<Circle>。我们真正想要的是让方法接收任何 Shape 类型的 List:

public void drawAll(List<? extends Shape> shapes) {
    // ...
}

这里有个很小但非常重要的不同:我们用 List<? extends Shape> 代替了 List<Shape>。现在 drawAll() 就可以接收任何包含 Shape 子类的 List 了,所以现在我们就可以按照意愿在 List<Circle> 上调用该方法了。

List<? extends Shape> 是一个 边界限定通配符。这样做的代价是你无法在方法体内写入 shapes。例如,下面的代码不被允许:

public void addRectangle(List<? extends Shape> shapes) {
    // Compile-time error!
    shapes.add(0, new Rectangle());
}

你应该能够想清楚上面的代码为什么不合法。shapes.add() 的第二个参数是 ? extends Shape——Shape 的一个未知子类。由于我们不知道它的具体类型,所以无从得知它是否是 Rectangle 的子类;它可能是也可能不是,所以传递 Rectangle 是不安全的。

边界限定通配符恰恰是处理车管局把数据交给人口统计局的例子所需要的。我们假定数据是以映射表示的,映射的键是姓名(字符串),值是人(Person 或它的子类,如 Driver)。记 Map<K,V> 是一个泛型,它接收两个类型参数,分别代表键和值。

我们再注意一下形式类型参数的命名约定—— K 代表键,V 代表值。

public class Census {
    public static void addRegistry(Map<String, ? extends Person> registry) {
}
...

Map<String, Driver> allDrivers = ... ;
Census.addRegistry(allDrivers);

Generic Methods

考虑编写一个方法,接收对象数组和集合,把数组中的所有对象放到集合中去。下面是首次尝试:

static void fromArrayToCollection(Object[] a, Collection<?> c) {
    for (Object o : a) {
        c.add(o); // compile-time error
    }
}

现在,你应该学会了不再犯初学者的错误,把 Collection<Object> 作为集合参数类型。你可能已经意识到又或许没有,使用 Collection<?> 同样不能工作。回想一下,我们不能把对象硬推到未知类型集合里。

解决这个问题的方案是使用 泛型方法。就像类型声明,方法声明也可以泛型化——把一到多个参数类型参数化。

static <T> void fromArrayToCollection(T[] a, Collection<T> c) {
    for (T o : a) {
        c.add(o); // Correct
    }
}

我们可以以任意集合类型调用该方法,只要它的元素类型是数组元素类型的超类。

Object[] oa = new Object[100];
Collection<Object> co = new ArrayList<>();
// T inferred to be Object
fromArrayToCollection(oa, co);

String[] sa = new String[100];
Collection<String> cs = new ArrayList<>();
// T inferred to be String
fromArrayToCollection(sa, cs);

// T inferred to be Object
fromArrayToCollection(sa, co);

Integer[] ia = new Integer[100];
Float[] fa = new Float[100];
Number[] na = new Number[100];
Collection<Number> cn = new ArrayList<>();

// T inferred to be Number
fromArrayToCollection(ia, cn);

// T inferred to be Number
fromArrayToCollection(fa, cn);

// T inferred to be Number
fromArrayToCollection(na, cn);

// T inferred to be Object
fromArrayToCollection(na, co);

// compile-time error
fromArrayToCollection(na, cs);

注意到我们无需传递实际类型参数给泛型方法。编译器会基于实际参数为我们推断出类型参数。它通常会推断出使调用类型正确的最具体的类型。

新的问题是:什么时候应该使用泛型方法,什么时候应该使用通配符泛型。为了理解答案,我们来检验一下 Collection 库中的一些方法。

interface Collection<E> {
    public boolean containsAll(Collection<?> c);
    public boolean addAll(Collection<? extends E> c);
}

我们也可以使用泛型方法:

interface Collection<E> {
    public <T> boolean containsAll(Collection<T> c);
    public <T extends E> boolean addAll(Collection<T> c);
    // Hey, type variables can have bounds too!
}

但是,containsAlladdAll 的类型参数 T 都只使用了一次。它们的返回值并不依赖类型参数,方法里的其他参数也不依赖(本例中仅有一个参数)。这告诉我们类型参数被用来实现多态;它的唯一作用是允许在不同调用点使用众多实际参数类型。如果是这样,你应该使用通配符泛型。通配符泛型被设计来支持子类型弹性化,就是我们在这里试图表达的。

泛型方法允许类型参数表达一或多个方法参数和/或返回值之间的依赖。如果不存在这种依赖,泛型方法不应该被使用。

泛型方法和通配符泛型可以串行使用。下面是 Collections.copy() 的签名:

class Collections {
    public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    ...
}

注意两个参数类型之间的依赖。任何从源列表 src 拷贝的对象,必须可以赋值给目标列表 dest 的元素类型,T 的某个超类。所以 src 的元素类型可以是 T 类型的任何子类——我们不关心是哪个。copy 签名使用类型参数表达了依赖,并且串行使用了通配符泛型。

我们可以不使用通配符泛型以另一种方式编写方法签名:

class Collections {
    public static <T, S extends T> void copy(List<T> dest, List<S> src) {
    ...
}

这样可以工作,但第一个类型参数不仅作为 dest 的类型,还作为第二个类型参数 S 的边界,而 S 自身只被使用一次,作为 src 的类型——没有其他东西依赖它。这是一个信号,我们可以用通配符泛型代替 S。使用通配符泛型相比显式声明类型参数更清晰简洁,因此只要可以,应该首先使用它。

通配符泛型的另一个优势是它可以被用在方法签名之外,作为属性,局部变量和数组的类型。下面是一个例子。

回到形状绘制问题,假设我们想保存绘制请求历史。我们可以维护一个位于 Shape 类内部的静态变量 history,让 drawAll() 把传入的参数放进历史属性里。

static List<List<? extends Shape>> history = new ArrayList<>();

public void drawAll(List<? extends Shape> shapes) {
    history.add(shapes);
    for (Shape s : shapes) {
        s.draw(this);
    }
}

最后我们再一次记录类型参数的命名约定。如果没有其他更具体的名称区分类型,我们使用 T。它经常用在泛型方法上。如果有多个类型参数,我们倾向于选择字母表中 T 的邻居,如 S。如果泛型方法位于泛型类内部,最好不要在类和方法上使用相同的名称,以避免混淆。同样的规则适用于内部泛型类。

Interoprating with Legacy Code

迄今为止,我们的所有示例都假定了一个理想世界,其中的每个人都使用着支持泛型的最新 Java 版本。

哎,事实上这并非如此。数百万行使用早期 Java 版本编写的代码,不能在一夜之间转换。

在后面的 Converting Legacy Code to Use Generics 章节,我们将解决转换旧代码以支持泛型的问题。在这一章,我们会专注于一个更简单的问题:怎样实现遗留代码和泛型代码间的互操作。这个问题包含两部分:在泛型代码中使用遗留代码,以及在遗留代码中使用泛型代码。

Using Legacy Code in Generic Code

如何在享受泛型便利的同时使用遗留代码呢?

作为一个例子,假定你想使用 com.Example.widgets 包。Example.com 员工维护着一个库存控制系统,其中的关键代码如下:

package com.Example.widgets;

public interface Part {...}

public class Inventory {
    /**
     * Adds a new Assembly to the inventory database.
     * The assembly is given the name name, and 
     * consists of a set parts specified by parts. 
     * All elements of the collection parts
     * must support the Part interface.
     **/ 
    public static void addAssembly(String name, Collection parts) {...}
    public static Assembly getAssembly(String name) {...}
}

public interface Assembly {
    // Returns a collection of Parts
    Collection getParts();
}

现在你想添加新代码使用上面的 API。如果能确保调用 addAssembly() 时总能提供正确的参数将是极好的——即传进去的总是包含 PartCollection。当然,泛型就是为它量身定做的:

package com.mycompany.inventory;

import com.Example.widgets.*;

public class Blade implements Part {
    ...
}

public class Guillotine implements Part {
}

public class Main {
    public static void main(String[] args) {
        Collection<Part> c = new ArrayList<Part>();
        c.add(new Guillotine()) ;
        c.add(new Blade());
        Inventory.addAssembly("thingee", c);
        Collection<Part> k = Inventory.getAssembly("thingee").getParts();
    }
}

当我们调用 addAssembly 时,它期待第二个参数是 Collection,而实际参数是 Collection<Part>。代码能够工作,但这是为什么呢?毕竟,集合不只是用来容纳 Part 对象的,从根本上来说,编译器不可能知道 Collection 指向何种类型。

在恰当的泛型代码中,Collection 将总是和类型参数搭配使用,没有携带类型参数的泛型称为 raw 类型。

大多数人的第一印象是 CollectionCollection<Object> 等价。然而,就像我们之前看到的那样,在需要 Collection<Object> 的地方传递 Collection<Part> 是不安全的。更准确的说,Collection 类型代表包含未知元素类型的集合,就像 Collection<?>

但等等,那样也不对!考虑调用 getParts(),它返回 Collection。随后返回值被赋值给类型为 Collection<Part>k。如果返回值的类型是 Collection<?>,那赋值语句将会发生错误。

实际情况是赋值语句是合法的,但它会产生 unchecked warning。警告是必须的,因为编译器无法保证它的正确性。我们无法检查遗留代码来确保 getAssembly() 返回包含 Parts 的集合。代码中使用的类型是 Collection,向该集合插入任何类型的对象都是合法的。

所以,这不应该是个错误吗?理论上说,应该;但出于现实考虑,如果泛型代码想要调用遗留代码,它就不得不被允许。这取决于你,开发者,说服自己,赋值是正确的,因为 getAssemmbly() 说它返回的是包含 Parts 的集合,尽管类型签名没有显示。

所以原始类型和通配符类型很像,但它们没有严格的类型检查。这是一个经过反复思考后的设计决定,为了让泛型代码能够操纵现存的遗留代码。

在泛型代码内调用遗留代码本身是危险的。一旦你混淆了泛型和非泛型遗留代码,泛型系统提供的所有安全保障就不复存在了。但是,使用还是比不使用好,至少你知道你手中的代码是类型一致的。

当非泛型代码远多余泛型代码时,混淆不可避免。如果你必须混合使用两种代码,请务必注意 unchecked warning,仔细思考你能否保证产生警告的代码是安全的。

如果你犯了个错误,警告处的代码确实不是类型安全的,那会发生什么呢?让我们考虑这种情况,在此过程中,我们将会深入了解编译器的工作原理。

Erasure and Translation

public String loophole(Integer x) {
    List<String> ys = new LinkedList<>();
    List xs = ys;
    xs.add(x); // Compile-time unchecked warning
    return ys.iterator().next();
}

这里,我们分别给 String 和空白的旧式列表起了别名。我们向列表中插入了 Integer,随后试图拿出一个 String。这显然是错误的。如果我们忽略那个警告,尝试执行代码,程序将在我们试图使用错误类型的地方失败。在运行时,代码的行为就像这样:

public String loophole(Integer x) {
    List ys = new LinkedList();
    List xs = ys;
    xs.add(x);
    return (String) ys.iterator().next(); // Run-time error
}

当我们从列表里拿出一个元素,并试图把它转为 String 时,我们得到的将是 ClassCastException。泛型版本的 loophole() 也会这样。

原因是泛型是由 Java 编译器实现的称为 类型擦除 的前端转换器。你大体上可以把它当成一种源码到源码的翻译,凭借它,泛型版本的 loophole() 被转换成非泛型版本。

结果是,Java 虚拟机的类型安全和完整性不会遭到破坏,即便存在 unchecked warning。

总的来说,类型擦除去掉(擦掉)了所有泛型类型信息。尖括号之间的所有类型信息都被扔掉了,所以举例来说,像 List<String> 这样的参数化类型会被转换成 List。所有仍在使用类型变量的地方会被它们的上边界取代(通常是 Object)。并且,产生类型错误的地方,一个恰当类型的强制转换会被插入,就像 loophole 的最后一行。

类型擦除的全部细节超出了这篇教程的范围,但我们给出的简单描述已经很接近事实。了解一点底层细节很有帮助,尤其是你想做一些像把已有 API 转为泛型版本的精益的事情(见 Converting Legacy Code to Use Generics),又或者你仅仅想懂得事情为何以这种方式发生。

Using Generic Code in Legacy Code

现在让我们反向思考,假设 Example.com 选择在已有 API 中引入泛型,但他们的一些客户端还没有这么做。所以现在的代码看起来像这样:

package com.Example.widgets;

public interface Part { 
    ...
}

public class Inventory {
    /**
     * Adds a new Assembly to the inventory database.
     * The assembly is given the name name, and 
     * consists of a set parts specified by parts. 
     * All elements of the collection parts
     * must support the Part interface.
     **/ 
    public static void addAssembly(String name, Collection<Part> parts) {...}
    public static Assembly getAssembly(String name) {...}
}

public interface Assembly {
    // Returns a collection of Parts
    Collection<Part> getParts();
}

客户端代码像这样:

package com.mycompany.inventory;

import com.Example.widgets.*;

public class Blade implements Part {
...
}

public class Guillotine implements Part {
}

public class Main {
    public static void main(String[] args) {
        Collection c = new ArrayList();
        c.add(new Guillotine()) ;
        c.add(new Blade());

        // 1: unchecked warning
        Inventory.addAssembly("thingee", c);

        Collection k = Inventory.getAssembly("thingee").getParts();
    }
}

客户端代码编写时间早于 API 引入泛型,但它使用了 com.Example.widgets 包和 Collection 库,后两者都使用了泛型。客户端代码中所有使用泛型类型声明的地方都是 raw 类型。

第 19 行会产生 unchecked warning,因为 raw 类型 Collection 被传给了需要 Parts 类型 Colleciton 的地方,此时编译器无法确保 raw Collection 就是 Parts Collection

作为替代方案,你可以在编译客户端代码时使用 1.4 版本标志,确保不产生警告。但那样,你就无法使用任何 JDK 5.0 之后引入的语言新特性了。

The Fine Print

A Generic Class is Shared by All Its Invocations

下面的代码片段会输出什么?

List<String> l1 = new ArrayList<>();
List<Integer> l2 = new ArrayList<>();
System.out.println(l1.getClass() == l2.getClass());

你可能倾向于回答 false,但那是错的。它输出 true,因为泛型类的所有实例拥有相同的运行时 class,不管它们的实际类型参数是什么。

事实上,泛型类的实质就是对它所有可能的类型参数保持相同的行为。同一个类可以拥有许多不同类型。

最终,所有实例之间也共享同一个类的静态变量和方法。这就是在静态方法或静态初始化块,在静态变量声明或初始化中,可以合法的引用类型声明的类型参数的原因。

Casts and InstanceOf

所有实例共享同一泛型类的另一个潜在影响是,通常情况下,询问一个对象是否是泛型类的特定调用的实例是没有意义的:

Collection cs = new ArrayList<String>();
// Illegal.
if (cs instanceof Collection<String>) { ... }

类似地,像下面这样的强制类型转换

// Unchecked warning.
Collection<String> cstr = (Collection<String>) cs;

会产生 unchecked warning,因为运行时系统不会为你检查它的类型。

对于类型变量也一样

<T> T badCast(T t, Object o) {
    // Unchecked warning.
    return (T) o;
}

类型变量不存在于运行时。这意味着它们不会消耗时间和空间性能,这很好。不幸的是,这也意味着你无法信赖它们完成转型。

Arrays

数组对象的元素类型不能是类型变量或参数化类型,除非它是一个(无边界)通配符类型。你可以声明元素类型是类型变量或参数化类型的数组 类型,但不能是数组 对象

当然这很让人恼火,但它对避免下面的情况很有必要:

// Not really allowed.
List<String>[] lsa = new List<String>[10];
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
// Unsound, but passes run time store check
oa[1] = li;

// Run-time error: ClassCastException.
String s = lsa[1].get(0);

如果允许创建参数化类型数组,上面的例子在编译时就不会出现任何未检查警告,然而会在运行时失败。我们把类型安全作为泛型的首要设计目标。特别地,语言被设计来保证,如果你的整个应用在使用 javac - source 1.5 编译,并且没有未检查警告的情况下顺利通过,它应该是类型安全的。

但是,你仍可使用通配符数组。下面的代码作为上例的变体,没有使用元素参数化的数组对象和数组类型。结果,想从数组中拿出 String,我们必须显式转换。

// OK, array of unbounded wildcard type.
List<?>[] lsa = new List<?>[10];
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
// Correct.
oa[1] = li;
// Run time error, but cast is explicit.
String s = (String) lsa[1].get(0);

下一变体产生了编译时错误,我们没有创建元素是参数化类型的数组对象,但仍然声明了包含参数化元素类型的数组类型。

// Error.
List<String>[] lsa = new List<?>[10];

类似地,尝试创建元素类型是类型变量的数组对象会导致编译期错误:

<T> T[] makeArray(T t) {
    return new T[100]; // Error
}

由于运行时不存在类型变量,因此没有办法确定真实的数组类型。

避开这种限制的方法是下一章将要介绍的,Class Literals as Runtime-Type Tokens

Class Literals as Runtime-Type Tokens

JDK 5.0 的变更之一就是 java.lang.Class 支持泛型了。它是除了容器类又一个使用泛型的有趣例子。

既然 Class 有一个类型参数 T,你可能要问,T 代表什么?它代表 Class 对象表示的类型。

例如,String.class 的类型是 Class<String>Serializable.class 的类型是 Class<Serializable>。这可以用来改进反射代码的类型安全。

特别地,由于 Class 类的 newInstance() 返回 T 了,你可以通过反射创建类型明确的对象。

例如,假定你需要编写一个完成数据库查询的工具方法,给定一个 SQL 字符串,返回数据库中匹配的对象集合。

一种方式是显式传入工厂对象,就像这样:

interface Factory<T> {
    T make();
}

<T> Collection<T> select(Factory<T> factory, String statement) {
    Collection<T> result = new ArrayList<>();

    /* Run sql query using jdbc */
    for (/* Iterate over jdbc results. */) {
        T item = factory.make();
        /* Use reflection and set fields of all item's
         * from sql results.
         */
        result.add(item);
    }

    return result;
}

你可以这样调用它

select(new Factory<EmpInfo>() {
        public EmpInfo make() {
            return new EmpInfo();
        }
    }, "selection string");

或者声明一个 EmpInfoFactory 实现 Factory 接口

class EmpInfoFactory implements Factory<EmpInfoFactory> {
    public EmpInfo make(){
        return new EmpInfo();
    }
}

然后调用它

select(getMyEmpInfoFactory(), "selection string");

这种方案缺点如下:

  • 要么需要在调用处使用冗长的匿名工厂类,或者
  • 需要先声明工厂类,随后在调用处传递它们的实例,不管怎样,这很不自然。

使用类字面量作为工厂对象会自然些,字面量可以用作反射。现在(没有泛型)的代码看上去像这样:

Collection emps = sqlUtility.select(EmpInfo.class, "select * from emps");
...
public static Collection select(Class c, String sqlStatement) { 
    Collection result = new ArrayList();
    /* Run sql query using jdbc. */
    for (/* Iterate over jdbc results. */) { 
        Object item = c.newInstance(); 
        /* Use reflection and set fields of all item's
         * from sql results.
         */
        result.add(item); 
    } 
    return result; 
}

然而,它不能返回给我们需要的明确的集合类型。现在 Class 引入了泛型,我们可以这样写:

Collection<EmpInfo> 
    emps = sqlUtility.select(EmpInfo.class, "select * from emps");
...
public static <T> Collection<T> select(Class<T> c, String sqlStatement) { 
    Collection<T> result = new ArrayList<T>();
    /* Run sql query using jdbc. */
    for (/* Iterate over jdbc results. */) { 
        T item = c.newInstance(); 
        /* Use reflection and set fields of all item's
         * from sql results.
         */
        result.add(item);
    } 
    return result; 
} 

上面的代码能够以类型安全的方式返回给我们明确的集合类型。

这种使用类字面量作为运行时类型符号的技术非常有用。例如,它已成为新 API 中操纵注解的习惯用法。

More Fun with Wildcards

这一章,我们将考虑一些更高级的通配符用法。我们已经看到了边界限定通配符在从数据结构读取数据的例子中非常有用。现在我们反向思考一个只读数据结构。接口 Sink 是一个简单示例。

interface Sink<T> {
    void flush(T t);
}

我们可以想象像下面这样使用它。writeAll() 方法被设计来将集合 coll 中的元素全部冲到 snk 中,并返回最后一个被冲走的元素。

public static <T> T writeAll(Collection<T> coll, Sink<T> snk) {
    T last = null;
    for (T t : coll) {
        last = t;
        snk.flush(last);
    }
    return last;
}

Sink<Object> s;
Collection<String> cs;
String str = writeAll(cs, s); // Illegal call.

正如注释所言,对 writeAll() 的调用是非法的,因为编译器不能推断出恰当的类型参数;StringObject 都不能作为 T,因为 CollectionSink 的元素类型必须相同。

我们可以使用通配符更改方法签名修复这个错误。

public static <T> T writeAll(Collection<? extends T> coll, Sink<T> snk) {...}
...
// Call is OK, but wrong return type. 
String str = writeAll(cs, s);

调用现在是合法的,但赋值不正确,推断出的返回值类型是 Object,因为 Ts 的元素类型匹配,其类型正是 Object

解决方法是使用一种我们没有见过的边界限定通配符:下边界 限定通配符。语法 ? super T 表示 T 的某个未知超类(或者 T 自身;记住超类关系是自反的)。我们已经使用过两种边界限定通配符,? extends T 表示 T 的某个未知子类。

public static <T> T writeAll(Collection<T> coll, Sink<? super T> snk) {
    ...
}
String str = writeAll(cs, s); // Yes! 

使用这一语法,调用是合法的,并且推断出的类型是想要的 String

现在我们看一个更实际的例子。java.util.TreeSet<E> 代表元素是 E 的有序树结构。创建 TreeSet 的一种方式是传递一个 Comparator 对象给它的构造器。那个 Comparator 会被用作对 TreeSet 的元素以想要的顺序排序。

TreeSet(Comparator<E> c)

Comparator 接口很基本:

interface Comparator<T> {
    int compare(T fst, T snd);
}

假定我们想创建一个 TreeSet<String> 并给它传递合适的比较器——用于比较 String。它可以是 Comparator<String> 也可以是 Comparator<Object>。但是,构造器签名不允许我们传递 Comparator<Object>。我们需要更改方法签名,使用下边界限定通配符获得我们想要的弹性:

TreeSet(Comparator<? super E> c)

这样更改就可以使用任何恰当的比较器了。

作为使用下边界限定通配符的最后一个例子,让我们看看 Collections.max() 方法,它返回作为参数传入的集合的最大元素。现在,max() 能正常工作的必要条件是,传入的集合的所有元素必须实现 Comparator 接口。进一步说,它们必须能够互相比较。

下面是首次尝试泛化方法签名:

public static <T extends Comparable<T>> T max(Colleciton<T> coll)

方法的参数是包含类型 T 的集合,T 实现了 Comparable 接口,可以和自身比较,返回值类型也是 T。但是,代码的限制性太强了。想知道为什么吗,考虑一种可以和任意对象比较的类型:

class Foo implements Comparable<Object> { ... }

Collection<Foo> cf = ... ;
Collection.max(cf); // Should work.

cf 的任何元素都可以和其它元素比较,因为它可以和任何 Object 比较,具体到 Foo 也是。然而,使用上述签名,我们的调用被拒绝了。推断出的类型一定是 Foo,但 Foo 没有实现 Comparable<Foo>

没有必要指定 T 必须和 自身 比较。需要的只是 T 能够和它的某个超类比较。像下面这样:

public static <T extends Comparable<? super T>>
    T max(Collection<T> coll)

注意 Collections.max() 的实际签名比这更复杂。在下一章 Converting Legacy Code to Use Generics 我们将继续讨论。下面的推论几乎适用于所有使用 Comparable,并且想要处理任意类型的地方:你总会想使用 Comparable<? super T>

总结一下,如果你有一个仅使用一个类型参数 T 作为参数的 API,你需要充分利用下边界限定通配符(? super T)。相反,如果 API 只返回 T,你应该使用上边界限定通配符(? extends T)来给你的客户端更多弹性。

Wildcard Capture

你现在应该清楚,对于下面的代码:

Set<?> unknownSet = new HashSet<String>();
/* Add an element t to a Set s */
public static <T> void addToSet(Set<T> s, T t) { ... }

下面的调用是非法的。

addToSet(unkownSet, "abc"); // Illegal.

它和传入 String Set 没有区别,重要的是传入的参数表达式是未知 Set 类型,它不能被保证是 String Set,或者任何特定类型的 Set。

现在,考虑下面的代码:

class Collections {
    public static <T> Set<T> unmodifiableSet(Set<T> set) {
        // ...
        return set;
    }
}
Set<?> s = Collections.unmodifiiableSet(unknownSet); // This works! Why?

它似乎不应该被允许;然而,对于这一特定调用,允许它是完全安全的。毕竟,unmodifiableSet() 可以接受任何 Set,无论它的元素类型是什么。

由于这种情况出现地相对频繁,于是制定了一条规则,在某些非常特定的情况下,如果代码可以被证明安全,它就被允许。这条规则叫 通配符捕获,它允许编译器把未知类型通配符推断为泛型方法的一个类型参数。

Converting Legacy Code to Use Generics

之前,我们展示了如何实现新旧代码的互操作。现在,是时候解决更困难的问题了,如何泛化旧代码。

如果你决定把旧代码转为泛型版本,你需要仔细思考你将如何修改 API。

你需要确定泛型 API 不受过度限制;它必须继续支持原始约定。让我们再考虑一些 java.util.Collection 中的例子。泛化前的代码像这样:

interface Collection {
    public boolean containsAll(Collection c);
    public boolean addAll(Collection c);
}

初次尝试泛化后像这样:

interface Collection<E> {
    public boolean containsAll(Collection<E> c);
    public boolean addAll(Collection<E> c);
}

尽管它确实是类型安全的,但它不再遵循 API 的原始锲约。containsAll() 方法起初接受任何类型的集合。现在它只有接受真正包含 E 的集合,但:

  • 传入集合的静态类型可能不同,也许因为调用者不知道之前传进去的集合类型,也许它是 Collection<S>,而 SE 的子类。
  • 使用不同类型的集合调用 containsAll() 应该完全合法。常规情况下,它会返回 false

说到 addAll(),我们应该可以添加任何包含 E 的子类的集合。正确处理这一问题的方法我们已经在 Genetic Methods 见过。

你也要保证修改后的 API 能和旧客户端保持二进制兼容。这意味着类型擦除后必须和原始,未泛化的代码等价。大多数情况下,这是很自然的,但也有一些微妙情况。我们将检验我们已经遇到的最微妙的情况,Collections.max() 方法。就像我们在 More Fun with Wildcards 看到的,max() 可能正确的签名是:

public static <T extends Comparable<? super T>>
    T max(Collection<T> coll)

这很好,除了在类型擦除后:

public static Comparable max(Collection coll)

这和原始签名有何不同呢:

public static Object max(Collection coll)

我们本可以将签名具体化,但其实并没有,并且所有调用 Collections.max() 的旧二进制类文件仍然依赖返回 Object 签名的版本。

我们可以强制改变擦除行为,通过显式指定形式类型参数 T 的父类。

public static <T extends Object & Comparable<? super T>>
    T max(Collection<T> coll)

本例中我们见到了 多边界 类型参数,通过使用 T1 & T2 ... & Tn 语法。拥有多边界的类型变量被视为所有边界的子类。当多边界被使用,边界中第一个提到的类型将被作为擦除后的变量类型。

最后,我们应该还记得 max 只从输入集合中读取数据,所以应该适用于任何包含 T 子类的集合。

这帮我们引出了 JDK 里真正的方法签名:

public static <T extends Object & Comparable<? super T>>
    T max(Collection<? extends T> coll)

现实中很少有如此复杂的情况,但库设计专家在转换已有 API 时,应准备好非常仔细地思考这些问题。

另一个需要注意的问题是 协变返回,它表示,改善子类方法的返回值类型。你无法在旧式 API 中使用到这个特性。要了解问什么,请看一个示例。

假定你的原始 API 是这种形式:

public class Foo {
    // Factory. Should create and instance of
    // whatever class it is declared in.
    public Foo create() {}
}

public class Bar extends Foo {
    // Actually creates a Bar.
    public Foo create() {}
}

为了使用协变返回,你这样修改它:

public class Foo {
    // Factory. Should create and instance of
    // whatever class it is declared in.
    public Foo create() {}
}

public class Bar extends Foo {
    // Actually creates a Bar.
    public Bar create() {}
}

现在,假定你的一个第三方客户端写了如下代码:

public class Baz extends Bar {
    // Actually creates a Baz.
    public Foo create() {}
}

Java 虚拟机不直接支持改变返回值的方法重写。这一特性是编译器支持的。因此,除非 Baz 类被重新编译,否则它将无法正确覆盖 Barcreate() 方法。更进一步,Baz 必须被修改,因为这样的代码会被拒绝——Bazcreate() 方法的返回值不是 Barcreate() 方法返回值的子类。

Acknowledgements

Erik Ernst, Christian Plesner Hansen, Jeff Norton, Mads Torgersen, Peter von der Ahe 和 Philip Wadler 向这份教程贡献了材料。

感谢 David Biesack, Bruce Chapman, David Flanagan, Neal Gafter, Orjan Petersson, Scott Seligman, Yoshiki Shibata 和 Kresten Krab Thorup 向这份教程的早期版本提供了有价值的反馈。向所有被我忘记的人说声抱歉。

Updated

本文译自 Generics,译者 LOGI。

TG 大佬群 QQ 大佬群

最后编辑于: 2021 年 01 月 13 日
返回文章列表 文章二维码
本页链接的二维码
打赏二维码
添加新评论

Loading captcha...

已有 1 条评论
  1. Spoience Spoience   Windows 10 x64 Edition  Google Chrome 87.0.4280.88

    看你的Github一直在活动呐,向大佬学习,我这条咸鱼该死的三天打鱼两天晒网@(阴险)