内容:
整体和部分
Broken Dispatch 错误模式
症状
起因
治疗和预防措施
结论
参考资料
关于作者
对本文的评价
在象 Java 语言这样的面向对象的语言中,方法是可重载和可覆盖的,即便在中等复杂程度的程序中,方法调度也会给代码治理带来困难。在本周的诊断 Java 代码中,Eric Allen 讨论了由这些困难导致的错误模式,概述了由于参数不匹配导致的错误方法调用的征兆,同时给出了应对这个问题的一些办法。
整体和部分
还记得这条谚语吗,“整体大于部分之和”?假如把一个个独立的事件组合成一个相互作用的整体,产生的结果会比单个个体的作用之和要大得多。
程序也是一样的道理。随着一个个新方法被添加到程序中,整个程序可能的控制流程迅速增加。对于大型程序而言,很快局面就会无法控制了。就象是一个荒谬而又不可思议的戏法,有时您得到的最终结果并不是您所期望的方向 ? 这同您在重载方法或者覆盖方法时碰到的情况有些类似。
快速跟踪代码
清单 1. 实现不可变列表
我们讨论的起点
清单 2. 为链表定义强制参数
编写构造函数以强制类 LinkedList 的所有实例都是 String 列表。
清单 3. 新构造函数用唯一的 String 表达式列表作为参数。
介绍错误的行为
清单 4. 在方法调用中上溯造型参数
重写有关的 LinkedList 构造函数是一种简单的解决方案。
Broken Dispatch 错误模式
面向对象语言的最强大的特性之一就是继续多态性。这一特性答应我们根据参数类型重载和覆盖方法。但是,象其它功能强大的工具一样,这个特性也会引入新的危险。
虽然 Java 程序员们很快就能学会治理一次调用中将调用哪个方法的规则,但在大型程序中却很轻易出现这种情况:在一个类中重载了一个方法,结果却是以前在另一个类中可以运行的代码被中断了。这样的错误正符合我所说的 Broken Dispatch 模式。
该模式可以描述如下:
传递给某个重载方法,比如 foo 的参数,却被传给了另一个方法,比如 goo,它支持更广泛的参数类型。
goo 然后通过这些参数调用 foo。
但是由于 goo 内的这些参数的静态类型更为广泛,因此,可能会调用方法 foo 的错误版本。
象这样的错误很难诊断,因为可能只是添加了新的方法(而不是修改现有的方法)就引入了错误。而且,在发现问题之前,程序可能会继续执行相当长的一段时间。
症状
为了说明这种模式的本质,让我们来看看下面这段示例代码,它是为实现我前面的文章“ 空标志错误模式”中的不可变列表而编写的。
清单 1. 实现不可变列表
interface List {
public Object getFirst();
public List getRest();
}
class Empty implements List {
public Object getFirst() { throw new NoSUChElementException(); }
public List getRest() { throw new NoSuchElementException(); }
public boolean equals(Object that) {
return this.getClass() == that.getClass();
}
}
class Cons implements List {
Object first;
List rest;
Cons(Object _first) {
this.first = _first;
this.rest = new Empty();
}
Cons(Object _first, List _rest) {
this.first = _first;
this.rest = _rest;
}
public Object getFirst() { return this.first; }
public List getRest() { return this.rest; }
...
}
在那篇文章中,我们把链表实现作为这些不可变列表的容器。
假设我们在一个独立的包中实现链表,我们知道这个包中类 LinkedList 的所有实例都将是 String 列表。我们可以象下面这样编写构造函数来强制定义该不变量:
清单 2. 为链表定义强制参数
public class LinkedList {
private List value;
/**
* Constructs an empty LinkedList.
*/
public LinkedList() { this.value = new Empty(); }
/**
* Constructs a LinkedList containing only the given element.
*/
public LinkedList(String _first) { this.value = new Cons(_first); }
/**
* Constructs a LinkedList consisting of the given Object followed by
* all the elements in the given LinkedList.
*/
public LinkedList(String _first, LinkedList _rest) {
this.value = new Cons(_first, _rest.value);
}
public Object getFirst() { return this.value.getFirst(); }
public LinkedList getRest() {
return new LinkedList(this.value.getRest());
}
public void push(String s) { this.value = new Cons(s, this.value); }
public String pop() {
String result = (String)this.value.getFirst();
this.value = this.value.getRest();
return result;
}
public boolean isEmpty() { return this.value instanceof Empty; }
public String toString() {...}
...
}
假设我们写了这些代码,并且所有的测试案例都可以正常运行。(或者,更现实些,假设它起初并不能正常运行,可经过几个调试周期后,我们使它变得能够正常运行了。)
也许几个月后,您开发了类 Cons 的一个新构造函数,它使用列表的 String 表达作为其唯一的参数。这种构造函数非常有用 ? 它答应我们用下面这样的表达式构造新的列表:
清单 3. 新构造函数仅使用列表的 String 表示法作为参数
new Cons(′(this is a list)")
new Cons(′(so is this)")
这样,我们写了这个构造函数而且它的所有测试案例也正常运行了。太棒了!但是,接着,我们发现,太不可思议了,类 LinkedList 的方法测试中有一些忽然中断了。发生了什么事?
起因
问题在于类 LinkedList 的构造函数,它只有一个 String 作为参数。
这个构造函数以前曾调用底层的类 Cons 的构造函数。但是,既然我们用一个更加明确的方法 ? 该方法只有一个 String 作为参数 ? 重载了这个构造函数,那么被调用的就是这个更加非凡的方法了。
除非传递给 LinkedList 构造函数的 String 是一个有效的 Cons 表示法,否则试图对其进行语法分析时就会导致程序崩溃。更糟糕的是,假如 String 正好是一个有效的 Cons 表示法,程序就会使用这些毁坏的数据继续执行。假如那样的话,我们就在数据中引入了一个破坏者数据。关于破坏者数据的讨论,请参阅最后一部分,“ 破坏者数据错误模式”。
Broken Dispatch 错误,与所有的错误模式一样,在“布满测试”的代码(借用自极端编程术语)中最轻易诊断,在这种环境中连最微不足道的方法也有相应的单元测试。在这样的环境里,最为一般的症状是为您从未碰过的代码编写的测试案例忽然中断运行。
假如这种情况发生,有可能是 Broken Dispatch 模式的一种情况。假如测试案例中断是在您重载另一个方法后立即发生的,那就几乎可以肯定。
假如这段代码没有经过布满测试,情况就变得更加困难了。错误症状可能表现为,比如,返回速度比预期快得多(并且结果错误)的方法调用。换句话说,您可能会发现本来应该发生的某些事件从未发生(因为正确的方法未曾被执行)。
要记住,尽管类似的症状也可能是其它错误模式的缘故。但是,假如碰到这种的症状,最好是开始写更多的单元测试,从发现错误的方法开始,回退测试程序执行的过程。
治疗和预防措施
关于这个错误模式的好消息是有一些简单的解决方案。最直接的一种方法是把方法调用中的参数 上溯造型。在我们的示例中,这意味着重写相关的 LinkedList 构造函数,如下所示:
清单 4. 在方法调用中上溯造型参数
public LinkedList(String _first) {
this.value = new Cons((Object)_first);
}
当然,这种办法只解决了调用 Cons 构造函数这个问题。还有其它地方的调用,我们也得上溯造型,这种技术对于 Cons 类的客户来说是很讨厌的。在这样的情形下,您就得权衡一下,应用这个方便的构造函数给您带来的好处以及它引入错误的潜在危险,二者孰重孰轻了。
接口的表达性和健壮性之间的平衡也存在这种困境。一种两全其美的方法是用一个 static 方法来替代这个 Cons 的 String 构造函数,该 static 方法只有一个 String 作为参数并返回一个新的 Cons 对象。
总结
以下是对上面这个错误模式的总结:
模式: Broken Dispatch
症状:在重载了另一个方法之后,测试您从未碰过的代码的测试案例忽然发生中断。
起因: 重载使得未碰过的方法调用了一个方法,而该方法不是您希望调用的那个方法。
治疗和预防措施:插入显式上溯造型。或者,重新考虑您提供给不同的类的方法集。
记住,当您重载或者覆盖一个方法时,参数结构是确保按意图调用方法的一个要害部分。
下个月,我们将暂停错误模式的讨论转而处理其它一些重要的课题。诊断 Java 代码的下一部分将考查尾递归方法如何影响 Java 程序的性能。请不要担心,我们很快就会回到错误模式上来。
参考资料
参加本文的讨论论坛。
务必把 Java 语言规范加为书签,该书提供不错的关于方法调用规则的讨论。
经典的 Java VM 里有一个错误,导致方法覆盖不能正确的调度除它们的父类以外的包里定义的某些子类。请参阅 Sun Web 站点的讨论以获取具体信息。
Bill Venners 的 Under the Hood(JavaWorld,1997 年 6 月)讲述了 JVM 中方法调用的字节码实现。
下载 JUnit 并使您的代码“布满测试”。
要得到更多的关于极端编程方法的信息,请阅读“XP 精华”(developerWorks,2001 年 3 月),它提供了关于这种非常受欢迎而且灵活的进程的优秀文摘。
阅读 Eric 的诊断 Java 代码的完整系列。
关于作者
Eric Allen 毕业于 Cornell 大学,曾获得计算机科学和数学的学士学位。他目前是 Cycorp 公司的 Java 软件开发人员带头人,还是 Rice 大学的编程语言小组的兼职硕士生。他的研究涉及 Java 的正规语义模型和 Java 语言的扩展,都是在源代码和字节码的级别上的。目前,他正在为 NextGen 编程语言实现一种从源代码到字节码的编译器,这也是 Java 语言的泛型运行时类型的一种扩展。请通过 eallen@cyc.com 与 Eric 联系。