轻松掌握 Java 泛型---2

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

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

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

有效的构造函数调用

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

要求类型参数的所有实例化都包括不带参数的(zeroary)构造函数。
只要泛型类的运行时实例化没有包括所需的构造函数,就抛出异常。
修改语言的语法以包括更详尽的类型参数界限。
第 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 ,那么答案是“不存在”。但是,当编译类 D 和 C 时,我们不知道其它类会构造什么样的 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 种:对实例化数设置上限

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

该方法类似于在 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);
  }
}

在类 C 或 D 中显然没有多态递归,但象 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 的优缺点。

Java 开发人员和研究人员 Eric Allen 讨论了通过泛型类型添加对 mixin 支持所带来的影响,并以此文来结束他对 JSR-14 和 Tiger 中泛型类型的由四部分组成的讨论。(您也可以单击本文顶部或底部的“讨论”来访问该论坛。)
至此,在这个讨论 JSR-14 和 Tiger 中泛型类型的微型系列中,我们已经探讨了:

泛型类型及被设计成支持它们的即将发布的功能
基本类型、受约束的泛型以及多态方法上的限制
几个强加给这些 Java 扩展的限制
这些扩展语言的编译器所用的实现策略如何使这些限制成为必需
在泛型类型中添加对“裸”类型参数的 new 操作的支持所带来的影响

本月,我们将探讨在可以处理 mixin(可能被期望是泛型类型中最强大的功能)之前需要先解决的问题,以此来结束对 Java 语言中泛型类型的讨论。

mixin vs 包装

mixin 是由其父类参数化的类。例如,请考虑以下这个泛型类,它继承了它本身的类型参数:

class Scrollable<T> extends T {...}

类 Scrollable 的目的是要向 GUI 窗口小部件嵌入添加可滚动性所必需的功能性。这个泛型类的每个应用都会继承一个不同的父类。例如, Scrollable<JTextPane> 是 JTextPane 的子类,而 Scrollable<JEditorPane> 是 JEditorPane 的子类。对比这种嵌入功能的方法和 Java Swing 库中现有的功能性,在这个库中,如果我们想使 JComponent 是可滚动的,必须将其“包装”在 JScrollPane 中。

包装不仅需要添加访问被包装类的功能的转发方法,而且它还阻止我们在需要被包装对象的实例的上下文中使用由此产生的可滚动对象(例如,我们不能将 JScrollPane 传递到需要 JTextPane 的实例的方法中)。通过 Scrollable 的父类将其参数化,在继承多个超类时,我们就能保持对涉及滚动的功能的单点控制。这样能够使用 mixin 让我们重新获得多重继承性的某些强大功能,而又没有附带异常。

在上面的示例中,我们甚至可以对类型参数施加约束以阻止它用于不适当的上下文中。例如,我们可能想使该类型参数强制为 JComponent 的子类:

class Scrollable<T extends JComponent> extends T {...}

那么我们的 mixin 只能继承 GUI 组件。

mixin 和泛型类:完美组合

通常,mixin 作为独立语言功能部件添加到某种语言中,就象 Jam 中的那样。但是合并 mixin 以作为泛型类型系统的一部分很吸引人,几乎可以说魅力无穷。原因是:mixin 和泛型类都能被认为是将现有类映射到新类的 函数。

泛型类可被视为将它们的参数映射成新实例化的函数。mixin 可被视为将现有类映射成新子类的函数。通过使用泛型类型合并 mixin,我们能解决其它 mixin 公式的许多关键限制。

在 Java 语言的 Jam 扩展中,mixin 的超类类型没有名称;我们就不能在 mixin 主体中引用它。这一限制会迅速引起一连串各种其它问题。例如,在 Jam 中,禁止程序员将 this 作为参数传递给方法;无法对这样的调用进行类型检查。这一限制的影响极大,因为许多最常见的设计模式都要依赖于能够将 this 作为参数传递。

请考虑访问者模式,其中用 for 方法为复合层次结构中的每个类都定义了访问者类。通常被访问的类包含 accept 方法,它采用访问者并传递 this 来调用该访问者的方法。因此,在 Jam 中,访问者模式不能和 mixin 一起使用。

将 mixin 明确表述为泛型类,我们就始终有父类的句柄,它是该类继承的类型参数。例如,我们可以将 Scrollable 的父类引用为类型 T 。其结果是,在允许将 this 作为类型参数传递时没有任何根本性的困难。

但是,将 mixin 明确表述为泛型类型时有其它一些明显的困难。为了让您初步体会可能产生的某些困难,我们将讨论几个突出的困难以及一些可能的解决方案。

mixin 与类型消除

在讨论任何其它问题之前,我们应该先指出,与上月讨论的泛型类型的功能扩展一样,通过使用由 JSR-14 和 Tiger 使用的简单 类型消除(type erasure)策略,不能将对 mixin 的支持添加到 Java 语言中。

要了解其原因,请考虑在继承类型参数的类被消除时会出现什么情况。该类会最终继承类型参数的 界限!例如,上一个示例中类 Scrollable 的每个实例化最终都继承类 JComponent 。那显然不是我们所希望的。

为了通过泛型类型支持 mixin,我们 需要获得泛型类型实例化的运行时表示。幸运的是,编码这一信息的方法有许多,它们实际上都向后与 Tiger 兼容。这样的向后兼容编码方案是泛型 Java(Generic Java)的 NextGen 公式的显著特点(在 参考资料一节中)。

可用的超类构造函数

在我们希望允许类继承类型参数时立即出现的紧迫问题是要决定我们能调用什么样的超级构造函数?请回忆:每个 Java 类构造函数都必须调用超类的构造函数。通常,通过查找超类并确保存在匹配的超级构造函数,类型检查器确保这些超级构造函数调用会成功。

但是在我们对超类所知的一切只限于它是类型参数的实例化时,对于什么样的构造函数可用于给定的实例化,我们没有任何概念。而且请注意,类型检查器甚至不能检查是否每个 mixin 实例化都会产生有效的超级构造函数调用。其原因是:在某些其它上下文中,mixin 的参数可能用类型参数界限实例化了。

例如,泛型类 JSplitPane<T> 可以创建 Scrollable<T> 的实例。除非我们知道将类型参数 T 实例化为 JSplitPanes 的一切方法,否则我们不能知道在 Scrollable<T> 中调用的超级构造函数是否有效。但是因为 Java 编码允许单独的类编译,所以在类型检查期间,我们不能知道 JSplitPane 的所有实例。

解决这一问题的各种方案与我们上月 第 3 部分中讨论的针对检查 new 表达式的类型参数所提出的解决方案完全一致,因为超级构造函数调用和 new 表达式都引用了给定类的同一个类构造函数。让我们回顾一下这些解决方案:

需要一个不带参数的(zeroary)构造函数,用于所有类型参数的实例化。
当没有匹配的构造函数时,抛出运行时异常。
包含额外的类型参数注释,告知我们这些实例化必须包含哪些构造函数。
就如 new 表达式的情况,前两个解决方案有严重缺陷。通常在类定义中包含不带参数的构造函数没有任何意义。而且,当不存在任何匹配的构造函数时就抛出异常也不太理想。毕竟静态类型检查主要是严格防止那种异常。

第三种解决方案可能有点繁琐,但是它有许多优点。注释类型参数,其中包括所有实例化都必须拥有的构造函数集。这些注释确切地告知我们针对类型参数,我们可以可靠地调用什么样的构造函数。因此,当类型参数 T 用作泛型类的超类时, T 的注释确切地告知我们可以调用哪些超级构造函数。如果 T 不包含注释,那么类型检查器会禁止它用作超类。

意外的方法覆盖

任何 mixin 公式都会产生一个非常严重的问题:特定 mixin 的方法名可能与其超类的潜在实例化的方法名冲突。例如,假设类 Scrollable 包含不带任何参数的方法 getSize 并返回一个 Size 对象,编码了其水平和垂直尺寸。现在,我们假设类 MyTextPane ( JComponent 的子类)也包含不带任何参数的方法 getSize ,但返回一个 int ,表示调用它的对象的屏幕面积。

产生的类显示如下:

清单 1. 意外方法覆盖的示例

class Scrollable<T extends JComponent> extends T {
  ...
  Size getSize() {...}
}

class MyTextPane extends JComponent {
  ...
  int getSize() {...}
}

new Scrollable<MyTextPane>() 

随后 mixin 实例化 Scrollable<MyTextPane> 会包含两个带有同样(空)参数类型的方法 getSize ,但返回类型不一致!因为我们不能指望类 Scrollable 的程序员或 MyTextPane 的程序员预见这个有问题的 getSize 覆盖(毕竟,他们甚至不可能在同一个开发团队),因此我们称之为 意外覆盖。

当 mixin 被明确表述为泛型类时,意外覆盖的问题特别讨厌。因为 mixin 的父类可能用类型参数被实例化,因此类型检查器就不能确定意外方法覆盖的所有情况。而且,在意外覆盖出现时抛出运行时异常是无法接受的,因为客户机程序员无法预测何时将抛出这样的异常。如果我们想编写可靠的程序,那么我们必须禁止在运行时出现无法预料的错误。

另一个解决方案是只隐藏这些相互冲突的方法中的一个,并解析所有匹配的方法调用以引用未隐藏的方法。这个解决方案的问题是我们希望诸如 Scrollable<MyTextPane> 这样的 mixin 实例化可用于调用 Scrollable 对象的上下文以及调用 MyTextPane 对象的上下文中。隐藏 getSize 方法中的任一个都会在这两个上下文中禁止使用 Scrollable<MyTextPane> 。

在 1998 年召开的有关编程语言原理的 ACM SIGPLAN-SIGACT 研讨会(请参阅参考资料)上,Felleisen、Flatt 和 Krishnamurthi 提出了在 mixin 不属于泛型类型的上下文中针对该问题的一个好的解决方案:基于使用 mixin 实例化的上下文来解决对相互冲突的方法的引用。在这个解决方案中,mixin 包含有这样的观点:确定在名称不一致的情况中要调用哪个方法。

在 mixin 作为泛型类型的情况中,我们可以应用同样的解决方案。我们只要设计一些观点,这些观点在泛型类型的上下文中有效,并且还允许向后兼容 JVM。在 Rice JavaPLT 实验室中,我们已经在“A First-Class Approach to Genericity”(请参阅 参考资料)一文中提出了这样一种解决方案。

有得必有失

正如示例、问题和可能的解决方案所演示的,在 Java 编程中继承泛型类型以包含对 mixin 的支持会产生一种功能强大的语言,但同时也引入了一些有待克服的问题。这是典型的编程语言设计:只能通过使许多现有功能变复杂才能添加所希望的功能。在编程语言领域中,没有任何免费的午餐。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值