泛型

单一继承层次结构的限制太多,你必须从该层继承来生成符合方法参数要求的对象。若将方法的参数从类改为接口,就可以解除该限制,泛化到支持该接口的任何实现类。

即便是接口也还是有诸多限制。一旦指定了接口,它就要求你的代码必须使用特定的接口。而我们希望编写更通用的代码,能够适用“非特定的类型”,而不是一个具体的接口或类。

这就是泛型的概念,是 Java 5 的重大变化之一。泛型可以生成参数化类型,这样你编写的组件(通常是集合)可以适用于多种类型。术语“泛型”的意思是“适用或者可兼容大批的类”。在编程语言中,泛型的初衷是通过解除类或方法所受的类型限制,尽可能在编写类或方法时拥有丰富的表达力。你将在本章看到,Java 的泛型实现并没有那么全面,甚至可能会质疑“泛型”这个词是否合适用来描述这一功能。

简单泛型

泛型最重要的初衷之一是用于创建集合类。正如在之前集合章节中所见,集合是一种可以用来持有其他类型对象的对象。几乎所有程序在运行过程中都会涉及到一组对象,因此集合是可复用性最高的类库之一。

我们先看一个只能持有单个对象的类。这个类可以明确指定其持有的对象的类型:

class Automobile {}
public class Holder1 {
    private Automobile a;
    public Holder1(Automobile a) { this.a = a;}
    Automobile get() { return a;}
}

这个类的复用性不高,因为它无法用来持有任何其他东西,我们并不想为每个遇到的类型都写一份相同的代码。

在 Java 5 之前,可以通过持有一个 Object 对象来实现:

public class ObjectHolder {
    private Object a;
    public ObjectHolder(Object a) { this.a = a;}
    public void set(Object a) { this.a = a;}
    public Object get() { return a;}
    public static void main(String[] args) {
        ObjectHolder h2 = new ObjectHolder(new Automobile());
        Automobile a = (Automobile) h2.get();
        h2.set("Not an automobile");
        String s = (String) h2.get();
        h2.set(1); // 自动装箱为Integer
        Integer x = (Integer) h2.get();
    }
}

现在,ObjectHolder 可以持有任何类型的对象,在上面的示例中,一个 ObjectHolder 先后持有了三种不同类型的对象。

一个集合中存储多种不同类型的对象的情况很少见,通常而言,我们只会用集合存储同一种类型的对象。泛型的主要目的之一是指定集合能持有的对象类型,并且通过编译器来强制执行该规范

因此,与其使用 Object ,我们更希望先指定一个类型占位符,稍后再决定具体使用什么类型。要实现这个目的,你需要在类名的尖括号内放置一个类型参数,然后在使用该类的时候再将其替换为实际的类型。如下例所示,其中 T 就是类型参数:

创建 GenericHolder 对象时,必须指明要持有的对象的类型,将其置于尖括号内。然后就只能在 GenericHolder 中存储该类型(或其子类,继承机制仍然适用于泛型)的对象了。当你调用 get() 取值时,直接就是正确的类型。

Java 7 之后,h 定义的等式右边不用再次声明,可以使用更简单的形式:

一般来说,你可以认为泛型和其他类型差不多,只不过它们碰巧有类型参数罢了。在使用泛型时,你只需要指定它们的名称和类型参数列表即可。

元组库

有时一个方法需要能返回多个对象。而 return 语句只能返回单个对象,解决方法就是创建一个对象,用它打包想要返回的多个对象。当然,可以在每次需要的时候,专门创建一个类来完成这样的工作。但是有了泛型,我们就可以一劳永逸。同时,还获得了编译时的类型安全。

这个概念称为元组(tuple),它将一组对象一起包装进了一个对象。该对象的接收方可以读取其中的元素,但不能往里放入新元素。(这个概念也被称为数据传输对象(Data Transfer Object)或者信使(Messenger))

元组一般无长度限制,其中的每个对象都可以是不同的类型。不过我们会指定每个元素的类型,并且保证接收方读取元素值时得到的是正确的类型。对于多种不同长度的问题,通过创建多个不同的元组来解决。下面是一个可以存储两个对象的元组:

构造器会获取要保存的对象。元组则隐式地按顺序存放各个元素。

可以利用继承机制实现长度更长的元组,只需添加更多的类型参数:

然后定义一些类来使用一下元组:

使用元组时,需要为函数定义长度合适的元组来作为返回值,然后创建该元组并返回。注意方法定义中的返回类型声明:

有了泛型,你可以很容易地创建元组,令其返回一组任意类型的对象。

可以看到,public 字段上的 final 定义防止该字段在构造完成后被重新赋值。

在上面的程序中,new 表达式有些啰嗦。本章稍后会介绍,如何利用泛型方法简化它们。

栈类

在集合章节中,已经见过用 LinkedList 实现的栈:onjava.Stack类。可以看出创建栈所需的方法在 LinkedList 中已经存在了。Stack 由一个泛型类(Stack<T>)和另一个泛型类(LinkedList<T>)组合构成。注意在该例中,泛型类型和普通类型基本没有不同(只有少量区别)。

可以不使用 LinkedList 实现自定义的内部链式存储机制:

内部类 Node 同样是泛型,并且有着自己的类型参数。

该实例使用了末端哨兵(end sentinel)来检查栈何时为空,末端哨兵在 LinkedStack 完成构造后被创建,每次调用 push() 都会创建一个新的 Node<T>,并被链接到前一个 Node<T>。调用 pop() 时总会返回 top.item,然后丢弃当前的 Node<T>,并移动到下一个 Node<T>,直到命中末端哨兵,这时就会停止移动。这种情况下,如果继续调用 pop(),就会一直返回 null,表明栈已空。

RandomList

作为容器的另一个例子,假设我们需要一个持有特定类型对象的列表,每次调用它的 select() 方法时都随机返回一个元素。如果希望这种列表可以适用于各种类型,就需要使用泛型:

由于 RandomList 继承自 ArrayList,因此它有 ArrayList 中的全部行为,这里仅仅添加了 select() 方法。

泛型接口

泛型同样可用于接口。例如,生成器(generator) 是一种用于创建对象的类,实际上也是工厂方法设计模式的一种特殊形式。不过,当使用生成器创建新的对象时,它不需要任何参数,而工厂方法一般需要参数。生成器无需额外的信息就知道如何创建新对象。

一般而言,一个生成器只定义一个方法,即用于生成新对象的方法。java.util.function 库将生成器定义为 Supplier,其中的生成方法则称为 get()get() 的返回类型被参数化为 T。

为了演示 Supplier,我们需要定义几个类。下面是个咖啡相关的继承体系:

实现一个生成不同随机 Coffee 对象类型的 Supplier<Coffee>:

参数化的 Supplier 接口会确保 get() 方法返回的是参数类型。CoffeeSupplier 同样实现了 Iterable 接口,因此它可以在 for-in 语句中使用。它还需要知道何时终止循环,这正是第二个构造函数的作用。

下面是另一个实现 Supplier<T> 接口的例子,它负责生成 Fibonacci 数列:

虽然我们在 Fibonacci 类的里里外外使用的都是 int 类型,但是其参数类型却是 Integer。这个例子引出了 Java 泛型的一个局限性:基本类型无法作为类型参数。不过 Java 5 具备自动装箱和拆箱的功能,可以很方便地在基本类型和相应的包装类之间进行转换,正如在本例中看到的那样。

若想更进一步,实现 Iterable 的斐波那契数列,一种选择是重写这个类,令其实现 Iterable 接口,但是你并不是总能拥有源代码的控制权,所以除非必要,否则不要重写。另一种选择是创建一个适配器(Adapter)来生成所需的接口,该设计模式之前已介绍。

适配器有多种实现方式,比如,可以通过继承生成适配器类:

在 for-in 语句中使用 IterableFibonacci,需要为构造器设置一个边界,这样 hasNext() 才知道何时返回 false。

泛型方法

前面都是对整个类的参数化,其实还可以对内部的方法进行参数化。类自身是否泛型,与是否存在参数化方法无关。

泛型方法改变着方法的行为,而不受类的影响。作为准则,请“尽可能”使用泛型方法。相比于泛型化整个类,泛型化单个方法通常会更清晰。

如果方法是 static 的,则无法访问该类的泛型类型参数,因此,如果使用了泛型类型参数,则它必须是泛型方法。

要定义一个泛型方法,需要将泛型参数列表放在返回值之前,如下所示:

虽然类和类中的方法都可以同时被参数化,但该 GenericMethods 类并未被参数化。这里,只有 f() 方法具有类型参数。

对于泛型类,必须在实例化该类时指定类型参数。使用泛型方法时,通常不需要指定参数类型,因为编译器会检测出来,这称为类型参数推断(type argument inference)。因此,对 f() 的调用看起来像普通的方法调用,并且 f() 看起来像不断地被重载。

可变参数和泛型方法

泛型方法和可变参数列表可以共存:

这里的 makeList() 方法实现了和标准库中的 java.util.Arrays.asList() 一样的功能。

@safeVarargs 注解表示我们承诺不会对变长参数列表做任何修改,实际也是如此,因为我们只会对它进行读取。如果没有此注解,编译器将无法知道这些并会发出警告。

发出的警告为:Possible heap pollution from parameterized vararg type,意思是由于泛型的类型擦除,运行时 args 是一个 Object[] 类型的数组,而不是 T[],这意味着可以在运行时向数组中插入不正确类型的对象,从而导致类型安全问题。

通用 Supplier

这是一个为任意具有无参构造方法的类生成 Supplier 的类。为了减少键入,它还包括一个用于生成 BasicSupplier 的泛型方法:

该类提供了为符合以下条件的类生成对象的基本实现:

  1. 该类是 public 的,因为 BasicSupplier 在独立的包中,所以相关的类必须具有 public 权限,而不仅仅是包级访问权限。

  2. 该类具有无参构造器。要创建某个 BasicSupplier 对象,需要调用 create() 方法,并传入你想要生成的类型的类型标记(token)。泛型的 create() 方法提供了更为方便的语法 BasicSupplier.create(Mytype.class),而不是麻烦的 new BasicSupplier<Mytype>(Mytype.class)

例如,下面是一个简单的具有无参构造器的类:

CountedObject 类可以跟踪自身创建了多少个实例,并通过 toString() 报告这些实例的数量。 BasicSupplier 可以轻松地为 CountedObject 创建 Supplier:

泛型方法减少了产生 Supplier 对象所需的代码量。 Java 泛型强制传递 Class 对象,以便在 create() 方法中将其用于类型推断。

简化元组使用

使用类型参数推断和静态导入,就可以将前述的元组重写为一个更加通用的库。下面是用一个重载的静态方法来创建元组:

然后修改 TupleTest.java 用来测试 Tuple.java

注意,f() 方法返回了参数化的 Tuple2 对象,而 f2() 则返回了未参数化的 Tuple2 对象。由于返回值并没有以参数化的方式使用,因此编译器未对 f2() 方法产生警告。某种意义上,它被“向上转型”成了未参数化的 Tuple2。不过,如果试图获取 f2() 的结果并放入参数化的 Tuple2,编译器就会产生警告。

Set 实用工具

再举一个泛型方法的例子,考虑由 Set 所表示的那些数学关系。这些数学关系可以很方便地定义成可用于所有不同类型的泛型方法:

前三个方法将第一个参数的引用复制到一个新的 HashSet 对象中,从而复制了该参数,这样作为参数的 Set 就不会被直接修改了。因此返回值是一个新的 Set 对象。

这四个方法表达了 Set 的一系列数学运算:union() 返回一个由两个参数合并而成的 Set,intersection() 返回由两个参数的元素交集组成的 Set,difference() 从超集中移除子集中所包含的全部元素,而 complement() 则返回由两个参数的交集之外的所有元素组成的 Set。下面是一个包含不同颜色的 enum,作为演示这些方法的一个简单示例:

为了方便起见(不必全限定所有名称),将其静态导入到以下示例中。该示例可以通过 EnumSet 轻松地根据枚举生成 Set,此处为静态方法 Enum.range() 指定了选取范围的头尾边界元素,用于创建结果 Set。

下面示例用 Sets.difference() 演示了 java.util 中的各种 Collection 和 Map 类之间的区别:

构建复杂模型

泛型的一个重要好处是能够简单安全地创建复杂模型。例如,我们可以轻松地创建一个元组列表:

这样,无需太多代码就可以生成相当强大的数据结构。

下面是第二个示例。尽管每个类都是构建块,但总的模块数还是很多。此处的模型为一个带有通道、货架以及货物的商店:

Store.toString() 方法可以看出效果:尽管有复杂的层次结构,但多层的集合仍然是类型安全的和可管理的。令人印象深刻的是,组装这样的模型并不需要耗费过多精力。

Shelf 通过工具 Supplier.fill() 接收一个 Collection 类(第一个参数),并通过 Supplier(第二个参数)将 n (第三个参数)个元素填充进该 Collection。Supplier 类中的方法执行的都是填充动作的各种变种操作。

类型擦除

当开始更深入了解泛型时,会发现有些问题看起来并不合理。例如,虽然声明 ArrayList.class 是合法的,但声明 ArrayList<Integer>.class 却不行。如下示例:

ArrayList<String>ArrayList<Integer> 应该是不同的类型。不同的类型会有不同的行为。例如,如果尝试向 ArrayList<String> 中放入一个 Integer,所得到的行为(失败)和向 ArrayList<Integer> 中放入一个 Integer 所得到的行为(成功)完全不同。然而上面的程序认为它们是相同的类型。

下面的例子是对该现象的补充:

根据 JDK 文档,Class.getTypeParameters() 会返回一个由 TypeVariable 对象组成的数组,代表由泛型声明所声明的类型变量...。这暗示可以发现参数的类型信息。但是正如上例中输出所示,你只能看到用作参数占位符的标识符,这并非有用的信息。

所以残酷的事实是:泛型代码内部并不存在有关泛型参数类型的可用信息

因此,你可以知道诸如类型参数的标识符和泛型类型的边界等信息,但是无法得知实际用于创建具体示例的类型参数。这也是使用 Java 泛型时必须处理的最基本的问题。

Java 泛型是通过类型擦除实现的。这意味着在使用泛型时,任何具体的类型信息都将被擦除。在泛型内部,你唯一知道的就是你在使用一个对象,因此 List<String>List<Integer> 在运行时实际上是相同的类型。两者的类型都被“擦除”为它们的原始类型:List。理解类型擦除并掌握必要的处理方式,是学习泛型的最大难点之一。

C++ 的实现方法

下面是使用了模板的 C++ 示例,可以看出两者参数化类型的语法十分相似:

Manipulator 类中存放了一个 T 类型的对象 obj,manipulate() 方法则调用了 obj 上的 f() 方法。它是如何知道类型参数 T 中存在 f() 方法的呢?C++ 编译器会在实例化模板的时候进行检查,这样在实例化 Manipulator<Hasf> 时,编译器就会看到 HasF 中存在方法 f()。如果情况并非如此,就会出现编译时错误,从而保证了类型的安全。

C++ 在实例化模板时,模板代码知道其自身模板参数的类型。Java 类型则不同,下面是用 Java 实现 HasF:

如果继续将示例的剩余部分也实现,就会无法编译:

因为擦除,Java 编译器无法将 manipulate() 方法必须能调用 obj 的 f() 方法这一需求映射到 HasF 具有 f() 方法这个事实上。要调用 f(),就必须为它指定边界,告诉编译器只接受符合该边界的类型。这里复用了 extends 关键字:

边界 <T extends HasF> 声明了 T 必须是 HasF 类型或其子类。如果符合这个条件,就可以安全地调用 obj 的 f() 方法。

我们说泛型类型参数会擦除到它的第一个边界(可能有多个边界,稍后你将看到)。我们还提到了类型参数的擦除。编译器实际上会把类型参数替换为它的擦除,就像上面的示例,T 擦除到了 HasF,就像在类的声明中用 HasF 替换了 T 一样。

你应该已经观察到,泛型在其中并没有任何贡献,完全可以抛开泛型,生成没有泛型的类:

这引出了很重要的一点:只有在类型参数比具体类型(及其所有子类)更加“泛型(泛化)”时,即希望代码能够跨多个类型运行时,泛型才有帮助。因此,类型参数和它们在有用的泛型代码中的应用,通常比简单的类替换更加复杂。但是,不能因此认为使用 <T extends HasF> 形式就是有缺陷的。例如,如果某个类有一个返回 T 的方法,那么泛型就有所帮助,因为它们之后将返回确切的类型:

你必须对全部代码进行评估,并判断其是否“足够复杂”到适合使用泛型的程度。

迁移的兼容性

类型擦除并不是一项语言特性,因为泛型不是 Java 语言出现时就有的,所以就有了这种妥协。

若泛型一开始就是这门语言的一部分,那么就不会用类型擦除来实现,而会通过具体化(reification)来将类型参数保持为第一类实体,这样就可以对类型参数执行基于类型的语言操作和反射操作了。

在基于类型擦除的实现中,泛型类型被视同于第二类类型处理,无法在某些重要的上下文中使用。泛型类型只在静态类型检查时期存在,在这之后,程序中所有泛型类型都会被擦除,并替换为它们的非泛型上界。例如,List<T> 这样的类型注解会被擦除为 List,而普通的类型变量则被擦除为 Object,除非制定了上界。

类型擦除的核心初衷是,希望让泛化的调用方程序可以依赖于非泛化的库正常使用,反之亦然。这通常称为迁移兼容性

因此 Java 泛型不仅必须支持向后兼容性——现有的代码和类文件仍然合法,继续保持之前的含义——而且还必须支持迁移兼容性,使得类库能按照它们自己的步调变为泛型,当某个类库变为泛型时,不会破坏依赖于它的代码和应用。

类型擦除存在的问题

因此,类型擦除存在的主要理由就是充当从非泛型代码过渡到泛型化代码的中间过程,以及在不破坏现有库的情况下,将泛型融入到 Java 语言中。类型擦除允许你继续使用现有的非泛型客户端代码,直至客户端准备好用泛型重写这些代码。

类型擦除的代价是显著的,泛型代码无法用于需要显式引用运行时类型的操作,如类型转换、 instanceof 操作,以及 new 表达式。

如下代码:

当创建 Foo 的实例后:

这里泛型语法使得看起来好像类中各处的类型 T 都被替换为 cat 类型了,如同 C++ 那样,但是并非如此,它仍然只是一个 Object。

类型擦除和迁移兼容性意味着泛型的使用并不是强制性的,尽管你可能希望这样:

Derived2 继承自 GenericBase,但并未包含泛型参数,而编译器未给出警告,直到调用 set() 时,警告才出现。

为了关闭警告,Java 提供了一个注解,可以在注解列表中看到它:

这个注解放置在产生警告的方法上,而不是整个类上。当你想要要关闭警告时,最好尽可能地“缩小范围”,这样就不会因为过于宽泛地关闭警告,而导致意外地遮蔽掉真正的问题。

可以推断,Derived3 产生的错误意味着编译器期望得到一个原生基类。

当你希望将类型参数不仅仅当作 Object 处理时,就需要付出额外努力来管理边界,并且与其他语言相比,需要付出更多,回报却更少。

边界的行为

因为类型擦除,泛型最令人困惑的方面是可以表示没有任何意义的事物。例如:

kind 看起来是被存储为 Class<T>,但是类型擦除意味着它实际上只是一个不带参数的 Class。因此,当你在使用它时,例如创建数组,Array.newInstance() 实际上并未拥有 kind 所蕴含的类型信息。所以它不会产生具体的结果,因而必须转型,这会产生一条令你无法满意的警告。

注意,对于在泛型中创建数组,使用 Array.newInstance() 是推荐的方式。

如果改为创建集合而不是数组,情况就会不同:

由于类型擦除,在 create() 内部的 new ArrayList<>() 方法中,<T> 被移除了,即在运行时,类中并没有 <T>,因此看起来似乎没有什么实际用处。但是如果将该表达式改为 new ArrayList(),编译器就会产生警告。

是否真的毫无用处,如果在创建 List 时将一些对象放入其中,会怎么样:

即使编译器无法得知 add() 中的 T 的任何信息,但它仍可以在编译期确保你放入 FilledList 中的对象是 T 类型。因此,即使擦除移除了方法或类中的实际类型的信息,编译器仍可以确保方法或类中使用的类型的内部一致性。

由于类型擦除移除了方法体中的类型信息,运行时的关键便指向了边界,即对象进入和离开某个方法的临界点。编译器会在编译时在临界点执行类型检查,并插入类型转换的代码,例如之前的 ObjectHolder.java 例子。

使用 javap -c ObjectHolder 对结果进行反编译,可以得到:

set()get() 方法分别存储和返回值。而在调用 get() 时则会对类型转换进行检查。

再看使用泛型的 GenericHolder.java 例子,调用 get() 不再需要类型转换,但是我们仍然知道传给 set() 的值经过了编译器类型检查。下面是相关字节码:

得到的字节码完全相同,set() 对传入类型的额外检查工作是编译器自动执行的,而对 get() 输出值的类型转换仍然存在,这是由编译器自动插入的。

get()set() 生成了相同的字节码,由此可知泛型所有的行为都发生在边界——包括对传入值额外的编译时检查,和对输出值插入的类型转换。记住“边界是行为发生的地方”。

对类型擦除的补偿

由于类型擦除,任何需要在运行时知道确切类型的操作都无法运行:

有时可以在编程时绕过这些问题,但是有时必须通过引入类型标签(type tag) 来补偿类型擦除导致的损失。即在类型表达式中显式地为要使用的类型传入一个 Class 对象。

对于上面的例子,类型标签可以提供动态的 isInstance()能力:

编译器保证了类型标签能够和泛型参数匹配。

创建类型实例

如上 Erased.java 例子所示,其中无法创建 new T(),部分原因是类型擦除,另一部分原因是编译器无法验证 T 中是否存在无参构造器。但在 C++ 中,这种操作可以实现,且简单安全(因为会在编译时检查):

Java 的解决方法是传入一个工厂对象,并通过它来创建新实例,Class 对象就是一个方便的工厂对象,因此如果使用了类型标签,就可以通过 newInstance() 来创建该类型的新对象(但是需要该类型有无参构造器):

可以看到,Employee 可以成功,但使用 ClassAsFactory<Integer> 就会失败,因为 Integer 中没有无参构造器。由于该错误不是在编译期捕获的,因此 Java 设计者建议使用显式工厂(Supplier),并对类型进行限制,使其仅能接收实现了该工厂的类。下面是创建工厂的两种不同方法:

IntegerFactory 自身是个实现了 Supplier<Integer> 接口的工厂,Widget 包含了一个作为工厂的内部类。注意 Fudge 并不执行任何类似工厂的操作,但是传入 Fudge::new 仍然会产生工厂的行为,因为编译器对函数方法 ::new 的调用,转变成了对 get() 的调用。

另一种方式是使用模板方法(Template Method)设计模式。如下示例,create() 是模板方法,其在子类中被重写,用来生成该类型的对象:

GenericWithCreate 中包含 element 字段,并强制通过无参构造器进行自身初始化,然后调用 abstract create() 方法。该方法可以将创建逻辑定义在子类中,同时 T 的类型也得到了确定。

泛型数组

如在 Erased.java 中所见,无法创建泛型数组。通用的解决方法是不管在何处,都用 ArrayList 来创建泛型数组:

这样就获得了数组的行为,此外还得到了泛型提供的编译时类型安全性。

有时仍然需要创建泛型类型的数组(例如 ArrayList,其内部实现使用了数组)。可以定义一个泛型引用,指向一个数组,来满足编译器的要求:

编译器接受该操作没有产生警告。但是我们永远无法创建该确切类型(包括类型参数)的数组,这点让人疑惑。所有的数组不论持有的是什么类型,都有着相同的结构(包括每个数组的大小和布局),因此似乎可以创建一个 Object 数组,并将其转换为目标数组类型。这可以通过编译,但会在运行时抛出 ClassCastException 异常:

问题在于数组会跟踪其实际类型,而该类型是在创建数组时确定的。即使 gia 被强制转换为 Generic<Integer>[] ,该信息也仅在编译时存在(并且没有 @SuppressWarnings 注解,将会收到有关该强制转换的警告)。在运行时,它仍然是一个 Object 数组,这会引起问题。成功创建泛型类型的数组的唯一方法是创建一个已擦除类型的新数组,并将其强制转换。

考虑一个简单的泛型数组包装类:

虽然无法直接声明 T[] array = new T[sz],但是可以创建一个 Object 数组,并对它进行转型。

rep() 方法返回一个 T[],对应到 main() 中的 gai.rep() 应当返回 Integer[],但是调用 rep() 并试图将其作为 Integer[] 的引用来获取,就会抛出 ClassCastException 异常,仍然是因为运行时类型实际上是 Object[]

如果注释掉 @SuppressWarnings 注解后,会产生与类型转换相关的警告,可以通过 javac -Xlint:unchecked GenericArray.java 编译得到:

由于警告会变成噪音,因此,一旦我们确认预期会出现特定警告,我们可以做的最好的办法就是使用 @SuppressWarnings 将其关闭。只有这样,在出现警告时,我们才会真的去调查是不是有问题。

由于擦除,数组的运行时类型只能是 Object[]。 如果我们立即将其转换为 T[],则在编译时会丢失数组的实际类型,并且编译器可能会错过一些潜在的错误检查。因此,最好在集合中使用 Object[],并在使用数组元素时增加转型为 T 的操作。如下例所示:

乍一看,好像只是改变了类型转换的地方。如果没有 @SuppressWarnings 注解,仍旧会产生"unchecked"警告。然而现在内部表达所用的是 Object[],而不是 T[]。在调用 get() 方法时,它会将对象转型为 T,这实际上是正确的类型,因此是安全的,不过如果调用 rep(),就会再次试图将 Object[] 转型为 T[],这仍然是错误的,并且会产生编译时警告和运行时异常。因此,没有任何方法可以推翻底层的数组类型,该类型只能是 Object[]。在内部将数组视为 Object[] 而不是 T[] 的优点是,可以减少由于忘记数组的运行时类型而意外产生 bug 的可能性(尽管大多数(也许是全部)此类错误会在运行时被迅速检测到)。

对于新代码,应该传入一个类型标记。这种情况下 GenericArray 是这样的:

这里将类型标记 Class<T> 传入了构造器,以用于擦除后的类型恢复,这样就能创建实际所需类型的数组了,尽管还是必须使用 @SuppressWarnings 关闭来自强制类型转换的警告。如在 main() 中所见,一旦得到了实际的类型,就可以将其返回,并生成想要的结果,数组的的运行时类型是确切的 T[] 类型。

在 Java 文献中通常会建议使用类型标记的技巧,但是也有人推荐使用工厂的方式(本章阐述过)。

边界

边界允许我们对泛型使用的参数类型施加约束。尽管这可以强制执行有关应用了泛型类型的规则,但潜在的更重要的效果是我们可以在绑定的类型中调用方法。

由于类型擦除移除了类型信息,对于无边界的泛型参数,仅能调用 Object 中可用的方法。不过如果能将参数类型限制在某个类型子集中,就可以调用该子集上可用的方法了。为了应用这种限制, Java 泛型复用了 extends 关键字。

当用于限定泛型类型时,extends 的含义与通常的意义截然不同。下面示例演示了边界的一些基本要素:

可以观察到 BasicBounds.java 中似乎包含一些冗余,它们可以通过继承来消除。下面可以看到,每一层继承也会增加边界的限制:

HoldItem 拥有一个对象,因此此行为将继承到 WithColor2 中,这也需要其参数符合 HasColor。紧接着两个类进一步扩展了继承结构,并增加了边界。所有方法都被继承下来,不用再重复定义。

下面是更多层级的示例:

通配符

下面例子展示了数组的一种特殊行为(协变性,Covariance),可以将派生类型的数组赋值给基类数组的引用:

该例子中首先创建了一个 Apple 数组,并将其赋值给一个指向 Fruit 数组的引用。因为 Apple 是一种 Fruit,所以 Apple 数组也应该是 Fruit 数组。

如果实际的数组类型是 Apple[],就可以将 Apple 或其子类放入该数组,编译时和运行时都没问题。但是也可以将 Fruit 对象放入该数组,这对于编译器是可以的,因为它持有 Fruit[] 的引用,编译器无理由不允许 Fruit 对象或任何它派生出来的对象放入该数组,因此在编译时是允许的。但是运行时的数组机制知道是在处理 Apple[],并会在该数组中放入异构类型时抛出异常。

向上转型用在这里不合适。你真正在做的是将一个数组赋值给另一个数组。数组的行为是持有其他对象,这里只是因为我们能够向上转型而已,所以很明显,数组对象可以保留有关它们包含的对象类型的规则。

对数组的这种赋值并不很可怕,因为可以在运行时发现插入了一个不恰当的类型。但是泛型的主要目的之一是要让这样的错误检查提前到编译时。如果试着用泛型集合代替数组:

这段代码表达的不是“无法将 Apple 集合赋值给 Fruit 集合”,记住,泛型并不是只和集合有关。这里表达的是“无法将包含 Apple 的泛型赋值给包含 Fruit 的泛型”。Java 泛型不支持协变性,尽管 Apple 是Fruit的子类,但 List<Apple> 不是 List<Fruit> 的子类。Apple 的 List 将持有 Apple 和 Apple 的子类型,Fruit 的 List 将持有任何类型的 Fruit。是的,这包括 Apple,但是它不是一个 Apple 的 List,它仍然是Fruit的 List。Apple 的 List 在类型上不等价于 Fruit 的 List,即使 Apple 是一种 Fruit 类型。

问题的本质在于,我们讨论的是集合自身的类型,而不是它所持有的元素类型。因为数组是完全在语言内部定义的,因此可以具有编译期和运行时的内建检查,但是在使用泛型时,编译器和运行时系统不知道你想用类型做什么,以及应该采用什么规则。

如果想要在这两者间建立某种向上转型的关系,可以使用通配符:

List<? extends Fruits>可以将其理解为“一种由继承自Fruit的任意类型组成的List”,但并不意味着List真的会持有任何Fruit类型。通配符引用的是明确的类型,因此它意味着“某种 flist 引用没有指定的具体类型”。因此这个被赋值的 List 必须持有诸如 Fruit 或 Apple 这样的指定类型,但是为了向上转型为 Fruit,这个类型是什么没人在意。

List 必须持有一种具体的 Fruit 或 Fruit 的子类型,但是如果你不关心具体的类型是什么,那么你能对这样的 List 做什么呢?如果不知道 List 中持有的对象是什么类型,你怎能保证安全地向其中添加对象呢?正如同 CovariantArrays.java 中的“向上转型”数组一样,你不能这样做,除非编译器能阻止类型不匹配的操作发生,而不是等到运行时系统(再来阻止)。

现在你甚至无法像刚提到的,可以持有 Apple 的 List 中添加一个 Apple。是的,但编译器并不知道这一点。List<? extends Fruit>可以合法地指向 List<Orange>,一旦进行了这种“向上转型”,就失去了向其中传递任何对象的能力,甚至传递 Object 也不行。

另一方面,如果你调用了一个返回 Fruit 的方法,则是安全的,因为你知道这个 List 中的任何对象至少具有 Fruit 类型,因此编译器允许这么做。

编译器的聪明程度

现在你可能认为不能调用任何带有参数的方法,但是考虑以下示例:

调用 contains()indexof() 都接收 Apple 对象作为参数,执行没问题。但这并不意味着编译器实际对代码进行了检查,以查看是否有某个特定的方法修改了它的对象。

通过查看 ArrayList 的文档,发现 add() 接受一个泛型参数类型的参数,而 contains()indexOf() 接受的参数类型是 Object。因此在声明了 ArrayList<? extends Fruit> 后,add() 的参数便成了 ? extends Fruit。由此可知,编译器无法得知这里需要 Fruit 的哪个具体子类型,因此便不会接受任何 Fruit 的类型。如果先将 Apple 向上转型为了 Fruit,这也没有影响,编译器会直接拒绝调用像 add() 这样参数列表中涉及通配符的方法。

contains()indexOf() 的参数类型是 Object,不涉及通配符,所以编译器允许调用它们。这意味着是由泛型类的设计者来决定哪些调用是“安全的”,从而使用 Object 类作为它们的参数类型。为了禁止对类型中使用了通配符的方法调用,需要在参数列表中使用类型参数。

下面展示一个简单的 Holder 类:

Holder 有一个以 T 为参数的 set() 方法,一个返回 T 的 get() 方法,以及一个以 Object 为参数的 equals() 方法。如果创建了 Holder<Apple>,就无法将其向上转型为 Holder<Fruit>,但是可以向上转型为 Holder<? extends Fruit>。如果调用 get(),只能返回 Fruit。如果知道此处是什么类型,就可以转型为某个具体的 Fruit 类型,并且不会产生警告,但有抛出 ClassCastException 的风险。Apple 或 Fruit 都无法用于 set() 方法,因为 set() 方法的参数也是 ? extends Fruit,这意味着编译器无法为这种“任意类型”验证安全性。

不过,equals() 方法没有问题,因为它接收 Object 作为参数。因此编译器只关心传入和返回的对象类型,并没有分析代码以检查是否执行了实际的读写。

Java 7 引入了 java.util.Objects 库,其目的之一就是使创建 equals() 和 hashCode() 方法变得更加容易。

逆变性

还有另一种方式,即利用超类通配符。可以认为是为通配符增加了边界限制,边界范围是某个类的任何基类,具体方式是 <? super MyClass> 或者使用类型参数 <? super T>(你无法给泛型参数设置超类边界,即这样声明 <T super MyClass> 是错误的)。这样可以安全地将类型对象传入泛型类型中。因此,有了超类型通配符,就可以向 Collection 写入了:

参数 apples 是由某种 Apple 的基类组成的 List,因此可以安全地向其中添加 Apple 类型或其子类型。其下界是 Apple,所以并不知道是否可以安全地像这样的 List 中添加 Fruit,因为这会使 List 对其他非 Apple 的类型也敞开怀抱,而违反了静态类型的安全性。

下面示例回顾了协变性和通配符:

readExact() 使用了精确的类型,如果在不使用通配符的情况下使用精确的类型,就可以在 List 上对该精确对象进行读写操作。对于返回值,如 f1() 所见,静态泛型方法对每个方法调用都有效兼容,因此,如果可以避开使用静态泛型方法,在需要只读的情况下并不需要协变性。

如果使用泛型类,在为该类实例化一个对象时,其参数就被确定了,如 f2() 所示,因为 fruitReader 的具体类型是 Fruit,所以可以从 List<Fruit> 读取单个 Fruit。但是 List<Apple> 应该也能生成 Fruit 对象,而 fruitReader 不允许。

为解决该问题,CovariantReader.readCovariant()方法接收List<? extends T>作为参数。从该List中读取 T 是安全的,因为已知里面的元素至少是 T,或其子类。在 f3() 可以看到可以从 List<Apple> 读取 Fruit。

无界通配符

无界通配符 <?> ,看一个例子:

很多情况都和这里看到的类似,编译器很少关心用的是原始类型还是 <?>。这种情况下 <?> 是个装饰。但它还是有用的,实际上它的意思是“我写这段代码时考虑了 Java 泛型,但并不是说要使用原始类型,只是在当前场景下,泛型参数可以持有任何类型”。

下面示例演示了无界通配符的一个重要用途,在处理多重泛型参数时,有时需要在将参数初始化为某种具体类型时,允许其中某个参数可以是任何类型:

可以看到,在 Map<?,?> 都是无界通配符的情况下,编译器看起来不会将其和原始类型 Map 区分开。另外,从前一例子中可以看出编译器对 List<?>List<? extends Object> 的处理方式并不相同。

令人困惑的是,编译器并非总是关注像 List 和 List<?> 之间的这种差异,因此它们看起来就像是相同的事物。事实上,因为泛型参数擦除到它的第一个边界,因此 List<?> 看起来等价于 List<Object> ,而 List 实际上也是 List<Object>。这两种说法都不完全正确,List 实际上指“持有任意 Object 类型的原生 List”,而 List<?> 是指“持有某种具体类型的非原生 List”,但并不知道是什么类型。

编译器何时才会关注原生类型和涉及无界通配符的类型之间的差异呢?下面示例用到了之前定义的 Holder<T> 类,该示例内部包含若干不同形式的以 Holder 为参数的方法,即以原始类型方式、带有具体的类型参数以及带有无界通配符的参数:

rawArgs() 中,编译器知道 Holder 是泛型类型,即使在这里被表示为原始类型,它也知道向 set() 中传入 Object 是不安全的。因为是原始类型,所以可以向 set() 中传入任何类型的对象,该对象会被向上转型为 Object。不论何时要使用原始类型就意味着放弃编译时检查。对 get() 的调用也有同样的问题:因为没有 T,所以结果只能是 Object。

unboundedArd() 中可以看出原生 Holder 和 Holder<?> 是不同的。该方法发现了相同的问题,但是却报告的是 error,而非 warning。因为原生 Holder 可以持有任何类型的组合,而 Holder<?> 只能持有某种具体类型组成的单类型集合,因此不能只是向其中传递 Object。

exact1()exact2() 没有使用通配符,而是用了精确的泛型参数,由于存在额外的参数,exact2() 所受限制和 exact1() 并不相同。

wildSubtype() 中,对 Holder 类型限制放宽,允许持有符合 extends T 条件的任意类型的 Holder。这意味着 T 为 Fruit 时,holder 可以是 Holder<Apple>,为了防止向其中放入 Orange,调用 set()(或任何以该类型参数为参数的方法)是不允许的。不过,因为知道从中读取的元素至少是 Fruit,因此调用 get()(或任何以该类型参数为返回值的方法)是允许的。

wildSupertype() 中,行为和 wildSubtype() 方法相反:holder 可以是一个持有 T 的任意基类的集合。因此,set() 可以将 T 作为参数,因为可以使用基类的地方,由于多态,就一定可以使用派生类。不过,调用 get() 意义不大,因为 holder 持有的类型可以是任意超类,所以唯一安全的类型是 Object。

该示例还展示了对于在 unboundedArg() 中使用无界通配符能够做什么不能做什么所做出的限制:因为你没有 T,所以不能将 set()get() 作用于 T 上。

main() 中,可以看到哪些方法可以接收哪些类型的参数,而不产生错误或警告。为了迁移兼容性,rawArgs() 可以接收所有不同变种,而不产生警告。unboundedArg() 方法同样可以接收所有类型,但是如前所述,它在方法体内部处理这些类型的方式并不相同。

如果向某个以“确切”泛型类型(无通配符)为参数的方法传入了原生 Holder 类型的引用,就会产生警告,因为具体参数所需的信息在原生类型中并不存在。如果向 exact1() 传入无界引用,会缺少用于确定返回类型的类型信息。

exact2() 所受限制最大,因为它明确需要传入 Holder<T> 和类型参数 T。如果未指定具体的参数,就会产生错误或警告。但如果显得过于受限,也可以使用通配符,这取决于你是要从泛型参数中获得类型化的返回值(如 wildSubtype() 所示),还是要向泛型参数中传入类型化参数(如 wildSupertype() 所示)。

使用确切类型而不是通配符,好处在于,可以用泛型参数做更多的事,但是通配符可以将更大范围的参数化类型作为参数。因此需要根据情况确定更适合的方法。

捕获转换

有一种特殊情况需要使用 <?> 而不是原生类型。如果向某个使用了 <?> 的方法传入了原生类型,编译器有可能会推断出具体的类型参数,因此该方法可以转而调用另一个使用了该具体类型的方法。该技巧称为捕获转换,因为它可以捕获未指定的通配符类型,并将其转化为某个具体类型:

f1() 中类型参数都是具体类型,未使用通配符或边界。f2() 中 Holder 参数是无界通配符,因此看起来似乎是未知的(类型)。但在 f2() 中,对 f1() 进行了调用,而 f1() 需要已知类型的参数,所以这里实际上是在调用 f2() 过程中捕获了参数的类型,并将其用于 f1() 的调用。

如果要将该技巧用于写入,就需要在传入 Holder<?> 的同时,额外传入一个具体的类型。捕获转换仅适用于“在方法中必须使用确切类型”的情况

注意,你无法从 f2() 方法中返回 T,因为对于 f2(),T 是未知的。

问题

基本类型不可作为类型参数

Java 泛型的限制,无法将基本类型作为类型参数,即无法创建 ArrayList<int> 这样的类型。

解决方法是使用基本类型的包装类,并结合自动装箱机制:

使用这种方法可以成功存储和读取 int 元素,而自动装箱则隐藏了转换的过程。不过如果性能成了问题,可以使用一种专门适配基本类型的集合,其中一个开源版本为 org.apache.commons.collections.primitives,下面是另一种方式,创建一个由 Byte 组成的 Set:

自动装箱可以解决部分问题,无法解决所有问题。

下面示例中,接口 FillArray 包含了一系列通过 Supplier 向数组中填入对象的方法(因为是静态方法,所以无法将类定义为泛型类)。Supplier 的实现来自下一章:

实现参数化接口

一个类无法实现同一泛型接口的两种变体。由于类型擦除,这两个变体其实是相同的接口:

因为类型擦除将 Payable<Employee>Payable<Hourly> 降级为相同的 Payable,运行时将无法区分具体类型,所以上述代码意味着将同一接口实现两次,因此 Hourly 无法编译。如果将这两处的泛型参数都移除(就像编译器在类型擦除中所做),就可以编译了(这是因为 Payable 中没有方法,EmployeeHourly 都只是在类层次结构里添加一个标记,不涉及重写方法,不会有桥接方法。但是如果Payable中定义了方法,此时编译器还是会报错,因为该方法可能冲突)。

该问题会在用到某些更底层的 Java 接口(例如 Comparable<T>)时带来困扰。

类型转换和警告

对类型参数使用类型转换或 instanceof 是没效果的,下面例子中的集合在内部将元素存储为 Object,并在读取它们时,将它们转回 T:

如果没有 @SuppressWarnings 注解,编译器会在调用 pop()stream() 时产生 "unchecked cast" 警告。由于类型擦除,编译器不知道类型转换是否安全,T 被擦除为自身的第一个边界,默认情况下为 Object,因此 pop() 实际上只是将 Object 转换成 Object。

有时使用泛型并不意味着不需要转型,这会导致编译器产生不正确的警告:

将会在附录章节了解到,readObject() 无法得知它正在读取的是什么,所以它返回了 Object,我们必须对该 Object 转型。

如果注释掉 @SuppressWarnings 注解,就会产生警告,你会被强制要求转型,但是又被告知不应该转型。为了解决这个问题,必须使用 Java 5 引入的新的转型形式,通过泛型类来转型:

注意,这里无法转型为实际的类型(List<Widget>),即无法 List<Widget>.class.cast(in.readobject())

重载

由于类型擦除,重载方法会产生相同类型的签名:

在这种情况下,只能用不同的方法名。这类问题可以被编译器发现。

基类劫持接口

假设有一个 Pet 类,并且通过实现 Comparable 接口,实现了和其他 Pet 对象进行比较的能力:

有理由试图将比较类型的范围缩小到 ComparablePet 的子类中。例如,Cat 应该只能和其他类型的 Cat 进行比较:

这是不行的,一旦为 Comparable 确定了 ComparablePet 参数,其他的实现类就再不能和 ComparablePet 之外的对象进行比较了。

从 Hamster 可以看出,可以重复实现 Comparable 中的相同接口,只要接口是完全相同的,包括参数类型。然而,这和只是在基类中重写接口(如 Gecko 所示)没什么区别。

自限定类型

在早期 Java 泛型中,有一种相当费解的习惯用法:

这好像将两面镜子彼此对立,有种无限镜面反射的效果。SelfBounded 类将泛型参数 T 作为参数,T 受边界限制,而该边界又是带着参数 T 的 SelfBounded。

这种方式再次强调了 extends 关键字在作用于边界时的意义,和它用于创建子类时的意义是完全不同的。

奇异递归泛型

泛型参数是无法直接继承的,但是可以继承一个在自身定义中用到了该泛型参数的类。即可以这样:

这种方式可以称为奇异递归泛型(CRG),其中“奇异递归”指你的类奇怪地在自身基类中出现的现象。

这其中的意义就像是“我要创建一个新类,它继承自一个泛型类型,这个泛型类型接受我的类的名字作为其参数”。泛型基类拿到子类类名后,由于 Java 泛型的重点在于参数和返回类型,因此可以生成将派生类型作为参数和返回值的基类。也可以将派生类型作为字段的类型,尽管它们将被擦除为 Object。下面的泛型类诠释了该模式:

这是个普通的泛型类型,内部的两个方法,分别用于接收和生成类型与参数类型一致的对象,以及一个用于操作存储的字段的方法(只是对该字段执行了 Object 的操作)。

可以将 BasicHolder 用于 CRG 中:

注意,Subtype 的接收参数和返回值的类型都是 Subtype,而不是基类 BasicHolder,这就是 CRG 的本质:基类用子类替换了其参数,即意味着泛型基类变成了一种为其子类实现通用功能的模板,但所实现的功能会被派生类型用于所有的参数和返回值。也就是说,最终类中使用的是具体的类型,而不是基类。因此,在 Subtype 中,set()get() 的返回值类型都是确切的 Subtype。

自限定

BasicHolder 可以将任何类型作为其参数,如:

自限定将执行额外一步,强制将泛型作为自己的边界参数使用。然后看看这样的类可以做什么以及不能做什么:

自限定要求类处于继承关系中:class A extends SelfBounded<A> {}。这会强制要求必须将你要定义的类作为参数传给基类。

自限定的参数的意义在于:它可以保证类型参数必须与要定义的类相同。正如在 B 定义中那样,也可以使用另一个 SelfBounded 参数的 SelfBounded 派生类。从 E 的定义可以看出,无法将非 SelfBounded 的类型作为类型参数。

遗憾的是,编译器未对 F 产生警告,因此自限定的用法并不是强制执行的。如果它真的很重要,可以引入并通过扩展工具来确保未使用原生类型来替代参数化类型。

注意,可以移除该限制,所有类依然可以编译,但是 E 也可以编译了:

很明显,自限定限制只能强制作用于继承关系。如果使用自限定,就应该了解这个类所用的类型参数将与使用这个参数的类具有相同的基类型。它强制任何使用该类的人都遵从这种形式。

也可以将自限定用于泛型方法:

这可以防止这个方法被应用于除上述形式的自限定参数之外的任何对象上。

参数协变性

自限定类型的价值在于可以生成协变参数类型(即方法参数的类型会随着子类而变化)。

尽管自限定类型还可以产生与子类类型相同的返回类型,但是这并不十分重要,因为协变返回类型是在 Java 5 引入:

DerivedGetter 重写了 OrdinaryGetter 中的 get() 方法,并返回原方法返回类型的子类。逻辑上,子类方法可以返回比其重写的基类方法更具体的类型,这在更早的 Java 版本是非法的。

自限定泛型实际会生成精确的派生类型作为返回值,如下 get() 所示:

然而,在非泛型代码中,参数的类型无法随子类型变化:

注意,这里重载了基类中的方法,不能使用 @Override 重写方法。

但是,使用自限定类型时,子类中只有一个方法,而该方法将派生类型作为自身参数,而不是基类类型:

编译器无法识别这种意图,因为并不存在匹配这种签名的方法。该参数实际上已经被重写了。

若没有使用自限定,普通的继承机制就会介入,并且会进行重载,就和非泛型的情况一样:

该例子与上面的 OrdinaryArguments.java 相同,输出可以看出 DerivedGS 含有 set() 的两个重载版本,如果没有使用自限定,就需要对参数类型进行重载(会生成桥接方法);如果使用自限定,最后只会有一个接收确切类型参数的方法版本。

动态类型安全

由于可以向 Java 5 之前的版本代码传递泛型集合,因此老代码仍然有可能破坏该集合。为了解决这里的类型检查问题,Java 5加入了一组实用工具:checkedCollection()checkedList()checkedMap()checkedSet()checkedSortedMap() 以及 checkedSortedSet() 这几个静态方法。这些方法都将希望动态检查的集合作为第一个参数,并将要强制确保的类型作为第二个参数。

受检查的集合在你试图插入类型不正确的对象时抛出 ClassCastException,这与泛型之前的(原生)集合形成了对比,对于后者来说,当你将对象从集合中取出时,才会通知你出现了问题。而使用 checked 集合就可以发现谁在试图插入不良对象。

下面演示了“向 dog 的列表插入一只 cat”会发生什么问题。oldStyleMethod() 代表遗留的历史代码,它以原生 List 为参数,并且还需要 @SuppressWarnings('unchecked') 注解来屏蔽产生的警告:

运行这个程序时,会发现插入一个 Cat 对于 dogs1 来说没有任何问题,而 dogs2 立即会在这个错误类型的插入操作上抛出一个异常。还可以看到,将派生类型的对象放置到将要检查基类型的 checked 集合中是没有问题的。

异常

由于擦除的原因,catch 语句不能捕获泛型类型的异常,因为在编译期和运行时都必须知道异常的确切类型。泛型类也不能直接或间接继承自 Throwable(这将进一步阻止你去定义不能捕获的泛型异常)。

但是,类型参数可以用于方法声明中的 throws 子句。这意味着可以编写能够随受检查的异常类型而变化的泛型代码:

Processor 实现了 process() 方法,并且可能抛出类型 E 的异常。process() 的结果被保存在 List<T> resultCollector 中(称为采集参数),processRunner 中包含用于执行其保存的每个 Process 对象的 processAll() 方法,该方法返回 resultCollector。

如果不能参数化所抛出的异常,那么由于检查型异常的缘故,将不能编写出这种泛化的代码。

混型

术语混型(mixin)现已有多种含义,但其最基本的概念是混合多个类的能力,以产生一个可以表示混型中所有类型的类。它将使组装多个类变得简单易行。

混型的价值之一是它们可以将特性和行为一致地应用于多个类之上。而且如果你对某个混型类做了变更,该变更会应用于所有使用了该混型的类中。正由于此,混型有一点面向切面编程 (AOP) 的味道,而切面经常被建议用来解决混型问题。

c++中的混型

C++ 中使用多重继承的原因之一就是混型。实现混型的一种方式是使用参数化类型,因为混型就是继承自其类型参数的类。由于 C++ 可以记住其模板参数的类型,因此可以轻松地混型。

下面的 C++ 示例,带有两个混型类型,一个给每个对象实例混入自带时间戳这一属性,另一个混入了序列号:

main() 方法中,mixin1 和 mixin2 所产生的类型都拥有所混入的类型的所有方法。可以将混型想象为将已有的类映射到新的子类上的功能。可以看到这种技术创建混型很简单:

可惜的是,Java 泛型不支持这样。因为类型擦除丢弃了基类的类型,泛型类无法直接继承自泛型参数

与接口混合

一种常见的方法是使用接口来达到混型的效果:

Mixin 类基本使用了代理模式(delegation),因此每个被混入的类型都需要在 Mixin 中有一个字段,而你必须在 Mixin 中编写所有必要的方法来调用转发给合适的对象上。当使用更复杂的混型时,代码量会增加很快。

使用装饰器模式

装饰器模式使用了分层的对象来动态、透明地为个别对象添加职责,该模式指定了所有用于包装你的原始对象的对象都有着相同的基础接口。某个类是可装饰的,然后你通过将其他类包装在其上,来分层叠加功能。这使得装饰器是透明的——有一个公共的消息集,不论一个对象是否被装饰,你都可以向它发送该消息集。用于装饰的类也同样可以添加方法,不过这其中有一定的局限性。

装饰器是通过使用组合和形式化结构(可装饰物/装饰器层次结构)来实现的,而混型是基于继承的。因此可以将基于参数化类型的混型当作是一种泛型装饰器机制,这种机制不需要装饰器设计模式的继承结构。

由混型产生的类包含了所有需要的方法,但是使用适配器产生的对象类型是该对象最后一层被装饰的类型。因此对于装饰器来说,其明显的缺陷是它只能有效地工作于装饰中的一层,只是对由混型提出的问题的一种局限的解决方案。

与动态代理混合

可以使用动态代理来创建一种比装饰器更贴近混型模型的机制(见反射章节对于 Java 动态代理运行机制的解释)。如果使用了动态代理,结果类的动态类型就是被混合后的合并类型。

由于动态代理的限制,每个被混入的类都必须是某个接口的实现:

由于只有动态类型包含了所有的混入类型,而没有静态类型(混入的类型在运行时存在,但静态类型是缺失的,不能直接使用minxin这个Object调用方法),所以这种方式不如 c++ 的好,因为在调用方法之前,要强制向下转型为合适的类型。不过,这明显更接近混型了。

潜在类型机制

要实现“尽量将代码写得通用一些”的目标,我们需要各种方法来解除代码中的各种类型所受的限制,同时不丢掉静态类型检查带来的好处。这样就可以写出更“泛型”的代码。

某些编程语言提供的一种解决方案称为潜在类型机制或结构化类型机制,而更古怪的术语称为鸭子类型机制,即“如果它走起来像鸭子,并且叫起来也像鸭子,那么你就可以将它当作鸭子对待。”鸭子类型机制变成了一种相当流行的术语,可能是因为它不像其他的术语那样承载着历史的包袱。

泛型代码典型地只能在泛型类型上调用少量方法,而具有潜在类型机制的语言只要求实现某个方法子集,而不是某个特定类或接口,从而放松了这种限制(并且可以产生更加泛化的代码)。正由于此,潜在类型机制使得你可以横跨类继承结构,调用不属于某个公共接口的方法。因此,实际上一段代码可以声明:“我不关心你是什么类型,只要你可以 speak()sit() 即可。”由于不要求具体类型,因此代码就可以更加泛化。

潜在类型机制是一种代码组织和复用机制。有了它,编写出的代码相对于没有它编写出的代码,能够更容易地复用。代码组织和复用是所有计算机编程的基本手段:编写一次,多次使用,并在一个位置保存代码。因为不再需要命名代码所依赖的接口,所以,使用潜在类型机制不仅可以少写代码,还能轻松地应用到更多地方。

支持潜在类型机制的语言包括 Python、C++、Ruby、SmallTalk 和 Go。Python 是动态类型语言(几乎所有的类型检查都发生在运行时),而 C++ 和 Go 是静态类型语言(类型检查发生在编译期)。因此类型检查是静态还是动态,潜在类型机制对此无要求。

Python 中的潜在类型机制

下面例子是用 Python 呈现的潜在类型机制:

Python 类中的方法将相当于 this 的引用显式地指定为第一个参数,一般习惯命名为 self。构造器调用不要求任何类型的“ new ”关键字,并且 Python 允许普通(非成员,即定义在类之外的独立函数)函数,就像 perform() 所表明的那样。

注意,在 perform(anything) 中并没有关于 anything 的类型信息,anything 只是一个标识符。它必须执行 perform() 要求执行的操作,所以相当于隐藏了一个接口。但是因为它是潜在的,所以不需要显式地写出该接口。perform() 不关心其参数的类型,因此可以向它传递任何对象,只要该对象支持 speak()sit() 方法。如果传递给 perform() 的对象不支持这些操作,那么将会得到运行时异常。

output 使用了三重引号创建带有内嵌换行符的字符串。

C++ 中潜在类型机制

用 C++ 实现一样的效果:

可以看出,C++ 与 Python 基本相同,Dog 和 Robot 并不相同,它们只是恰好有两个结构完全相同的方法。潜在类型机制可以使 perform() 同时接受这两个类型的对象。

虽然 C++ 和 Python 检查并抛出错误的时机不同,前者在编译时,后者在运行时,但这两种语言都保证了类型不会错用,因此可以被认为是强类型的语言。潜在类型机制不会违背强类型机制。

Java 中的直接潜在类型机制

由于 Java 较晚加入泛型,没有办法实现任何类型的潜在类型机制,因此没有支持这项特性。所以 java 泛型机制缺乏“泛化性”(Java 用类型擦除实现的泛型有时称为第二类泛型类型)。如果试图在 Java 8 之前实现 dog 和 robot 的例子,就必须用到一个类或接口,并将其指定在边界表达式中:

注意 perform() 的运行并不需要泛型,可以指定它接受 Performs 对象:

这里泛型不是必须的,因为这些类已经被强制实现了 Performs 接口。

对于缺少潜在类型机制的补偿

虽然 Java 没有直接支持潜在类型机制,但这不意味着泛型代码就无法跨类型层次应用。可以花费额外的功夫来创建真正意义上的泛型代码。

反射

一种方式是反射,下面是实现了潜在类型机制的反射 perform()

这两个类无直接关联,也无共同的基类(除了 Object)或接口。通过反射,CommunicateReflectively.perform() 可以动态地确定所需方法是否可用,然后进行调用。它甚至可以处理 Mime 只有一个必要方法的情况,并部分地实现了目标。

将方法应用于序列

反射固然可用,但是它将所有的类型检查都降级到了运行时,因此在很多情况下它不是我们想要的。但是可以同时拥有编译时类型检查以及潜在类型机制吗?

说明一个关于该问题的示例,假设要创建一个 apply() 方法,用于按一定顺序执行每个对象上的任意方法。这种情况不适合使用接口,想要将任意方法应用在集合中的对象上,而接口对于表述“任意方法”有太多限制。

一开始可以使用反射解决该问题,由于使用了可变参数,这种方式十分优雅:

异常被转换为 RuntimeException(运行时异常),因为没有多少办法可以从这种异常中恢复——在这种情况下,它们实际上代表着程序员的错误。

不使用 Java 8 的方法引用的原因:invoke() 有着可以接收任何数量的参数的优点,因此 apply() 也同样有该优点,在某些情况下,灵活性可能更重要。

为了测试 Apply,先创建一个 Shape 类:

然后是其子类:

然后测试 Apply:

apply() 方法可以接收任何实现了 Iterable 接口的对象,包括所有 List 这样的 Collection 类,还有如下面定义的 SimpleQueue 类:

尽管反射的方式很优雅,但是必须认识到反射的运行速度通常会慢于非反射的实现(尽管近来版本有了显著改进),因为在运行时要处理很多东西。这一点不会完全阻止尝试使用反射的方式,也是必须考虑的一点。

大部分时候应该首要考虑 Java 8 的函数式方式,只在某些仅有反射可以处理的特殊需求场景下,才考虑使用反射。下面使用 Java 8 的流和函数工具重写了 ApplyTest.java :

注意到,这里没有再使用 Apply.apply() 方法。

首先生成两个 Stream : 一个是 Shape ,一个是 Square ,并将它们展平为单个流。 尽管 Java 缺少功能语言中经常出现的 flatten() ,但是我们可以使用 flatMap(c-> c) 产生相同的结果,这种方式使用标识映射将操作简化为“ flatten ”。

然后使用了 peek() 封装了 rotate() 调用,因为 peek() 可以先执行某些操作(此处是为了它的副作用),然后保持对象的原样继续向下传递。

注意,相比于 Apply.apply(),使用 FilledList 和 shapeQ 调用 forEach() 变得很简洁。

在代码简单性和可读性方面,结果比以前的方法好得多。 并且,现在也不可能从 main() 引发异常。

Java 8 中的辅助潜在类型机制

Java 8 中的未绑定方法引用使得我们可以实现某种形式的潜在类型机制,以满足创建跨不相关类型工作的单个代码段的需求。因为 Java 最初并不是如此设计,所以结果可想而知,比其他语言中要尴尬一些。但是,至少现在成为了可能,只是缺乏令人惊艳之处。这里将其称为辅助潜在类型机制

下面重写 DogsAndRobots.java 来演示这种用法:

PerformingDogA 和 RobotA 没有实现公共接口,没有任何共性。

CommunicateA.perform() 在无限制的 P 上被泛型化。它可以是任何类型,只要有供其可用的 Consumer<P> 即可。此处,那些 Consumer<P> 表示不带参数的 P 方法的未绑定方法引用。在调用 Consumer 的 accept() 方法时,会将该方法的引用绑定到具体的执行对象,再调用该方法。由于 函数式编程一章中介绍的那样,我们可以向 CommunicateA.perform() 传递任何签名一致的未绑定方法引用。

称为“辅助”的原因时必须要显式地perform() 提供要使用的方法引用,它无法仅靠方法名来调用方法

我们创建了单个代码段 CommunicateA.perform(),可以用于任何拥有相同签名的方法引用的类型。注意,这与之前见过的其他语言中的潜在类型机制不同,因为这些语言不仅要求签名一致,还要求方法名一致。因此,这种方法甚至可以说产生了更通用的代码。最后的 Mime 就是说明了这点。

使用 Supplier 的通用方法

通过辅助潜在类型,我们可以定义本章其他部分中使用的 Suppliers 类。 此类包含使用生成器填充 Collection 的工具方法,泛化这些操作很有意义:

create() 可以创建新的 Collection 子类,而第一个版本的 fill() 会向已有的 Collection 子类放入元素。注意:这里同时还返回了传入的容器的具体类型,因此类型信息未丢失。

前两个方法一般仅限于 Collection 子类,第二个版本的 fill() 可用于任意类型的 holder。它需要额外的参数:未绑定的方法引用 adder。通过辅助潜在类型机制,只要 holder 内有用于添加元素的方法,fill() 就可以使用。由于该未绑定方法 adder 必须接收一个参数(要添加到 holder 中的元素),adder 必须是一个 BiConsumer<H, A>,其中 H 是要绑定的目标 holder 对象的类型,而 A 是要添加的元素类型。对 accept() 的调用会以 a 为参数,在对象 holder 上调用未绑定方法 adder。

下面对 Supplier 工具 测试,这里还使用了之前定义的 RandomList:

create() 生成了一个新的 Collection 对象,同时 fill() 则向已有集合中添加元素。第二个版本的 fill() 不仅可以用于无关联的新 Bank 类型,而且可以用于 List。因此第一个版本的 fill() 只是提供了更精简的语法。

总结

泛型的目的是为了实现更强大的表达能力,而不仅仅是为了创建类型安全的集合。类型安全的集合只是更通用代码创建能力的副产品。

正如在本章所见,编写真正泛型的 holder 类(Java 泛型便是)是相当简单的。要编写操作泛型参数的泛型代码,不论是类的创建者还是使用者,都需要付出额外的努力,两者都需要理解这种代码的概念和具体实现。这些额外的努力降低了该特性的易用性,并因此减少了它在某些本可以带来更多价值的场合下的适用性。

还要注意到,因为泛型是后来添加到 Java 中,所以某些容器无法达到它们应该具备的健壮性。例如,观察一下 Map ,在特定的方法 containsKey(Object key)get(Object key) 中就包含这类情况。如果这些类是使用在它们之前就存在的泛型设计的,那么这些方法将会使用参数化类型而不是 Object ,因此也就可以提供这些泛型假设会提供的编译期检查。例如,在 C++ 的 map 中,键的类型总是在编译期检查的。

进阶阅读

泛型的入门文档是 《Generics in the Java Programming Language》,作者是 Gilad Bracha。

Angelika Langer 的《Java Generics FAQs》是一份非常有帮助的资料。

可以从 《Adding Wildcards to the Java Programming Language》中学到更多关于通配符的知识,作者是 Torgerson、Ernst、Hansen、von der Ahe、Bracha 和 Gafter。

可以在 InfoQ 网站的文章 "A Discussion With Neal Gafter on the Future of Java" 中找到 Neal Gafter 对于 Java 各类问题(特指类型擦除)的看法。

最后更新于