现在的位置: 首页 > 综合 > 正文

掌握 Java 泛型类型(三)

2017年11月30日 ⁄ 综合 ⁄ 共 4806字 ⁄ 字号 评论关闭

这一系列主要讨论在 Java 编程中添加泛型类型,本文是其中的一篇,将研究还未讨论过的有关使用泛型的两个限制之一,即添加对裸类型参数的 new 操作的支持(如类 C<T> 中的 new T())。

正如我上个月所提到的那样,Tiger 和 JSR-14 通过使用“类型消除(type erasure)”对 Java 语言实现泛型类型。使用类型消除(type erasure),泛型类型仅用于类型检查;然后,用它们的上界替换它们。由此定义可知:消除将与如 new T() 之类的表达式冲突。

如果假定 T 的界限是 Object,那么这一表达式将被消除为 new Object(),并且不管对 T 如何实例化(StringListURLClassLoader 等等),new 操作将产生一个新的 Object 实例。显然,这不是我们想要的。

要添加对表达式(如 new T())的支持,以及添加对我们上次讨论过的其它与类型相关的操作(如数据类型转换和 instanceof 表达式)的支持,我们必须采用某种实现策略而不是类型消除(如对于每个泛型实例化,使用独立的类)。但对于 new 操作,需要处理其它问题。

尤其是,为了实现对 Java 语言添加这种支持,必须对许多基本的语言设计问题作出决定。

有效的构造函数调用

首先,为了对类型参数构造合法的 new 表达式(如 new T()),必须确保我们调用的构造函数对于 T 的每个实例化都有效。但由于我们只知道 T 是其已声明界限的子类型,所以我们不知道 T 的某一实例化将有什么构造函数。要解决这一问题,可以用下述三种方法之一:

  1. 要求类型参数的所有实例化都包括不带参数的(zeroary)构造函数。
  2. 只要泛型类的运行时实例化没有包括所需的构造函数,就抛出异常。
  3. 修改语言的语法以包括更详尽的类型参数界限。

第 1 种方法:需要不带参数的构造函数
只要求类型参数的所有实例化都包括不带参数的构造函数。该解决方案的优点是非常简单。使用这种方法也有先例。

处理类似问题的现有 Java 技术(象 JavaBean 技术)就是通过要求一个不带参数的构造函数来解决问题的。然而,该方法的一个主要缺点是:对于许多类,没有合理的不带参数的构造函数。

例如,表示非空容器的任何类在构造函数中必然使用表示其元素的参数。包括不带参数的构造函数将迫使我们先创建实例,然后再进行本来可以在构造函数调用中完成的初始化。但该实践会导致问题的产生(您可能想要阅读 2002 年 4 月发表的本专栏文章“The Run-on Initializer bug pattern”,以获取详细信息;请参阅参考资料。)

第 2 种方法:当缺少所需构造函数时,抛出异常
处理该问题的另一种方法是:只要泛型类的运行时实例化没有包括所需构造函数,就抛出异常。请注意:必须在运行时抛出异常。因为 Java 语言的递增式编译模型,所以我们无法静态地确定所有将在运行时发生的泛型类的实例化。例如,假设我们有如下一组泛型类:

清单 1.“裸”类型参数的 New 操作


class C<T> {
  T makeT() {
    return new T();
  }
}

class D<S> {
  C<S> makeC() {
    return new C<S>();
  }
}

现在,在类 D<S> 中,构造了类 C<S> 的实例。然后,在类 C 的主体中,将调用 S 的不带参数的构造函数。这种不带参数的构造函数存在吗?答案当然取决于 S 的实例化!

比方说,如果 S 被实例化为 String,那么答案是“存在”。如果它被实例化为 Integer,那么答案是“不存在”。但是,当编译类 DC 时,我们不知道其它类会构造什么样的 D<S> 实例化。即使我们有可用于分析的整个程序(我们几乎从来没有这样的 Java 程序),我们还是必须进行代价相当高的流分析来确定潜在的构造函数问题可能会出现在哪里。

此外,这一技术所产生的错误种类对于程序员来说很难诊断和修复。例如,假设程序员只熟悉类 D 的头。他知道 D 的类型参数的界限是缺省界限(Object)。如果得到那样的信息,他没有理由相信满足声明类型界限(如 D<Integer>)的 D 的实例化将会导致错误。事实上,它在相当长的时间里都不会引起错误,直到最后有人调用方法 makeC 以及(最终)对 C 的实例化调用方法 makeT。然后,我们将得到一个报告的错误,但这将在实际问题发生很久以后 — 类 D 的糟糕实例化。

还有,对所报告错误的堆栈跟踪甚至可能不包括任何对这个糟糕的 D 实例的方法调用!现在,让我们假设程序员无权访问类 C 的源代码。他对问题是什么或如何修正代码将毫无头绪,除非他设法联系类 C 的维护者并获得线索。

第 3 种方法:修改语法以获得更详尽的界限
另一种可能性是修改语言语法以包括更详尽的类型参数界限。这些界限可以指定一组可用的构造函数,它们必须出现在参数的每一个实例化中。因而,在泛型类定义内部,唯一可调用的构造函数是那些在界限中声明的构造函数。

同样,实例化泛型类的客户机类必须使用满足对构造函数存在所声明的约束的类来这样做。参数声明将充当类与其客户机之间的契约,这样我们可以静态地检查这两者是否遵守契约。

与另外两种方法相比,该方法有许多优点,它允许我们保持第二种方法的可表达性以及与第一种方法中相同的静态检查程度。但它也有需要克服的问题。

首先,类型参数声明很容易变得冗长。我们或许需要某种形式的语法上的甜头,使这些扩充的参数声明还过得去。另外,如果在 Tiger 以后的版本中添加扩充的参数声明,那么我们必须确保这些扩充的声明将与现有的已编译泛型类兼容。

如果将对泛型类型的与类型相关的操作的支持添加到 Java 编程中,那么它采用何种形式还不清楚。但是,从哪种方法将使 Java 代码尽可能地保持健壮(以及使在它遭到破坏时尽可能容易地修正)的观点看,第三个选项无疑是最适合的。

然而,new 表达式有另一个更严重的问题。

多态递归

更严重的问题是类定义中可能存在多态递归。当泛型类在其自己的主体中实例化其本身时,发生多态递归。例如,考虑下面的错误示例:

清单 2. 自引用的泛型类


class C<T> {
  public Object nest(int n) {
    if (n == 0) return this;
    else return new C<C<T>>().nest(n - 1);
  }
}

假设客户机类创建新的 C<Object> 实例,并调用(比方说)nest(1000)。然后,在执行方法 nest() 的过程中,将构造新的实例化 C<C<Object>>,并且对它调用 nest(999)。然后,将构造实例化 C<C<C<Object>>>,以此类推,直到构造 1000 个独立的类 C 的实例化。当然,我随便选择数字 1000;通常,我们无法知道在运行时哪些整数将被传递到方法 nest。事实上,可以将它们作为用户输入传入。

为什么这成为问题呢?因为如果我们通过为每个实例化构造独立类来支持泛型类型的与类型相关的操作,那么,在程序运行以前,我们无法知道我们需要构造哪些类。但是,如果类装入器为它所装入的每个类查找现有类文件,那么它会如何工作呢?

同样,这里有几种可能的解决办法:

  1. 对程序可以产生的泛型类的实例化数目设置上限。
  2. 静态禁止多态递归。
  3. 在程序运行时随需构造新的实例化类。

第 1 种:对实例化数设置上限
我们对程序可以产生的泛型类的实例化数目设置上限。然后,在编译期间,我们可以对一组合法的实例化确定有限界限,并且仅为该界限中的所有实例化生成类文件。

该方法类似于在 C++ 标准模板库中完成的事情(这使我们有理由担心它不是一个好方法)。该方法的问题是,和为错误的构造函数调用报告错误一样,程序员将无法预知其程序的某一次运行将崩溃。例如,假设实例化数的界限为 42,并且使用用户提供的参数调用先前提到的 nest() 方法。那么,只要用户输入小于 42 的数,一切都正常。当用户输入 43 时,这一计划不周的设计就会失败。现在,设想一下可怜的代码维护者,他所面对的任务是重新组合代码并试图弄清楚幻数 42 有什么特殊之处。

第 2 种:静态禁止多态递归
为什么我们不向编译器发出类似“静态禁止多态递归”这样的命令呢?(唉!要是那么简单就好了。)当然,包括我在内的许多程序员都会反对这种策略,它抑制了许多重要设计模式的使用。

例如,在泛型类 List<T> 中,您真的想要防止 List<List<T>> 的构造吗?从方法返回这种列表对于构建许多很常用的数据结构很有用。事实证明我们无法防止多态递归,即使我们想要那样,也是如此。就象静态检测糟糕的泛型构造函数调用一样,禁止多态递归会与递增式类编译发生冲突。我们先前的简单示例(其中,多态递归作为一个简单直接的自引用发生)会使这一事实变得模糊。但是,自引用对于在不同时间编译的大多数类常常采用任意的间接级别。再提一次,那是因为一个泛型类可以用其自己的类型参数来实例化另一个泛型类。

下面的示例涉及两个类之间的多态递归:

清单 3. 相互递归的多态递归


class C<T> {
  public Object potentialNest(int n) {
    if (n == 0) return this;
    else return new D<T>().nest(n - 1);
  }
}

class D<S> {
  public Object nest(int n) {
    return new C<C<S>>().nest(n);
  }
}

在类 CD 中显然没有多态递归,但象 new D<C<Object>>().nest(1000) 之类的表达式将引起类 C 的 1000 次实例化。

或许,我们可以将新属性添加到类文件中,以表明类中所有不同泛型类型实例化,然后在编译其它类时分析这些实例化,以进行递归。但是,我们还是必须向程序员提供奇怪的和不直观的错误消息。

在上面的代码中,我们在哪里报告错误呢?在类 D 的编译过程中还是在包含不相干表达式 new D<C<Object>>().nest(1000) 的客户机类的编译过程中呢?无论是哪一种,除非程序员有权访问类 C 的源代码,否则他无法预知何时会发生编译错误。

第 3 种:实时构造新的实例化类
另一种方法是在程序运行时按需构造新的实例化类。起先,这种方法似乎与 Java 运行时完全不兼容。但实际上,实现该策略所需的全部就是使用一个修改的类装入器,它根据“模板(template)”类文件构造新的实例化类。

JVM 规范已经允许程序员使用修改的类装入器;事实上,许多流行的 Java 应用程序(如 Ant、JUnit 和 DrJava)都使用它们。该方法的缺点是:修改的类装入器必须与其应用程序一起分布,以在较旧的 JVM 上运行。因为类装入器往往比较小,所以这个开销不会大。

让我们研究一下该方法的工作示例。

NextGen 示例:修改的类装入器
前一种方法 — 用按需构造泛型类型实例化的修改的类装入器解决多态递归问题 — 被 Java 语言的 NextGen 扩展所采用。修改的类装入器使用看上去几乎与普通类文件完全一样的模板文件,不同的是这个模板文件在常量池中有一些“洞”,在装入时为每个实例化类填充这些“洞”。非泛型类不受影响。

在 Rice 大学 JavaPLT 编程语言实验室,我们最近发布了 NextGen 编译器的原型,它是 GJ 泛型 Java 编译器的一种扩展,这种扩展支持类型参数的与类型相关的操作(数据类型转换、instanceof 测试和 new 表达式)。在该原型实现中,我们使用了一个修改的类装入器来支持多态递归。可以免费下载该原型(请参阅参考资料)。

结束语

正如上述考虑事项所演示的那样,将成熟的运行时支持添加到泛型 Java 要解决许多微妙的设计问题。如果这些问题处理得不当,那么可表达性和健壮性的降低会轻易地抵消泛型类型的好处。但愿 Java 编程会继续朝着维持这些属性的高度表达性和健壮性的方向发展。

下一次,我们将通过讨论或许是功能最强大的应用泛型类型的方法 — 将 mixin(具有参数父类型的类)添加到语言中 — 来结束对泛型类型的讨论。我们会将这种 mixin 的表现方式与先前讨论的这种功能强大的语言特性相关联,讨论通过泛型类型添加 mixin 的优缺点。

抱歉!评论已关闭.