消灭空指针异常的一个最常见的产生原因
在 Java 编程中,最常见的重复(被抱怨最多的)错误之一是空指针异常。跟踪这些错误中的某一个的产生原因,真的会让您对您当初的择业决定产生怀疑。在诊断 Java 代码的这一部分中,我们通过把和空指针异常联系在一起的最常见的一个类型编成目录,来继续我们的错误类型检查,并一步步分析一个含有空指针异常的类的示例。然后我们将回顾几个编程技巧,帮您减少这种类型错误的出现。
空指针到处都有!
在一个 Java 程序员所能碰到的所有异常中,空指针异常属于最恐怖的,这是因为:它是程序能给出的信息最少的异常。例如,不像一个类转型异常,空指针异常不给出它所需要的内容的任何信息,只有一个空指针。此外,它并不指出在代码的何处这个空指针被赋值。在许多空指针异常中,真正的错误出现在变量被赋为空值的地方。为了发现错误,我们必须通过控制流跟踪,以发现变量在哪里被赋值,并确定是否这么做是不正确的。当赋值出现在包中,而不是出现在发生报错的地方时,进程会被明显地破坏。
许多 Java 开发人员告诉我,他们所碰到的绝大多数程序崩溃是空指针异常,并且他们渴望有一种工具,能在程序第一次运行前静态地识别出这些错误。不幸的是,自动控制理论告诉我们,没有工具可以静态地决定哪些程序将抛出空指针异常。但是在一个程序中,用一个工具排除许多空指针异常是有可能的,留给我们仅仅一小部分需要我们必须人工检查的潜在的问题所在。实际上,为了为 Java 程序(请参阅参考资料)提供这样一个工具,现在正做着一些研究。但是一个好的工具也只能为我们做这些。空指针异常将决不会被完全根除。当它们真的发生时,工具能帮我们弄清和它们相联系的错误类型,这样我们能快速诊断它们。另外,我们可以应用某些编程和设计技巧来显著减少这些类型错误的出现。
悬挂复合类型
我们将探讨的第一个关于空指针异常的错误类型,是一个我称之为悬挂复合类型的错误类型。这种类型的错误是这样产生的:定义的某些基本例没有被给出它们自己的类,然后以这种方法定义了一个递归的数据类型。相反,空指针被插入到不同的复合数据类型中。数据类型实例的使用就似乎空指针被正确填充了一样。我称之为悬挂复合类型是因为冲突代码是复合设计类型的一个有缺点的应用程序,其中,复合数据类型包含悬挂的引用(也就是空指针)。
原因
考虑下面 LinkedList 类的单连接执行,它有一个悬挂复合类型。为了示例的简单起见,我只执行在 java.util.LinkedList 中定义的一些方法。为了显示这种类型的错误是多么隐蔽,我已经在下面代码中引入一个错误。看看你是否能发现它。
清单 1. 单连接链表
import java.util.NoSUChElementException;
public class LinkedList {
private Object first;
private LinkedList rest;
/**
* Constructs an empty LinkedList.
*/
public LinkedList() {
this.first = null;
this.rest = null;
}
/**
* Constructs a LinkedList containing only the given element.
*/
public LinkedList(Object _first) {
this.first = _first;
this.rest = null;
}
/**
* Constructs a LinkedList consisting of the given Object followed by
* all the elements in the given LinkedList.
*/
public LinkedList(Object _first, LinkedList _rest) {
this.first = _first;
this.rest = _rest;
}
}
这段代码相当的糟糕。它在两个域中都放置一个空指针来表示空链表,而不是为空链表定义一个单独的类。一开始看来,用这种方法表示一个空链表使代码简单。究竟,我们不必仅仅为了空链表而去定义一个额外的类。但是,正如我将证实的,这样的简单操作只是一个幻想。让我们为这个类定义一些读取器 (getter) 和设置器 (setter) 方法:
清单 2. 为 LinkedList 定义方法
public Object getFirst() {
if (! (this.isEmpty())) {
return this.first;
}
else {
throw new NoSuchElementException();
}
}
public LinkedList getRest() {
if (! (this.isEmpty())) {
return this.rest;
}
else {
throw new NoSuchElementException();
}
}
public void addFirst(Object o) {
LinkedList oldThis = (LinkedList)this.clone();
this.first = o;
this.rest = oldThis;
}
public boolean isEmpty() {
return this.first == null && this.rest == null;
}
private Object clone() {
return new LinkedList(this.first, this.rest);
}
注重,两个读取器采取的行动依靠于是否链表为空。这正好是那种一个正确构建的类层次所要防止的 if-then-else 链。由于这些链,我们不用在一个单一类型的链表上孤立地考虑这些读取器。此外,假如在将来的某一天,我们需要第三种类型的链表(例如一个不可变的链表),我们将不得不重新编写每一个方法的代码。
但是真正简单的方法是我们怎样才能轻易地避免将错误引入到程序中。按照这种方法,清单 2 中的 LinkedList 的执行只能是可怜的失败。实际上,就象我前面提到的,我们的 LinkedList 类已经包含一个微小的但有破坏性的错误(你发现了吗?)。空链表的表示到底是什么呢?我前面说过,空链表就是两个域都包含一个空指针的 LinkedList。实际上,零参数构造器就是建立一个这样的空链表。但是注重单参数构造器 不是把空链表放入到 rest 域,这是构建一个只有一个值的链表所必须的。相反,它是用空指针替代。由于悬挂复合类型错误将空指针和基本例的位置标记符相混淆,象这样的错误是很轻易犯的。为了了解这错误怎样表明自己是一个空指针异常,让我们为清单写一个 equals 方法:
清单 3. 哪里错了
public boolean equals(Object that) {
// If the objects are not of the same class, then they are not equal.
// Reflection is used in case this method is called from an instance of a
// subclass.
if (this.getClass() == that.getClass()) {
LinkedList _that = (LinkedList)that;
if (this.isEmpty() _that.isEmpty()) {
return this.isEmpty() && _that.isEmpty();
}
else {
boolean firstEltsMatch = this.getFirst().equals(_that.getFirst());
boolean restEltsMatch = this.getRest().equals(_that.getRest());
return firstEltsMatch && restEltsMatch;
}
}
else {
return false;
}
}
假如 this 和 that 都是非空,那么 equals 方法可以正确地预计它能调用它们的 getFirst 和 getRest 而不出现错误信息。但是假如链表中的任意一个包含用单参数构造器建立的任何部分,那么,在一个空链应该等待的地方,这个递归调用将最终表示为一个空指针。当它调用 getFirst 或 getRest 时,一个空指针异常就出现了。
一种观点可能是简单地直接把空链表表示成空指针,但是这个想法完全不可行的,因为在那时,不可能去掉链表的最后一个元素和在空链表中插入一个元素。
另一方面,可以照下面的方法重写单参数构造器来修复错误:
清单 4. 修复错误
public LinkedList(Object _first) {
this.first = _first;
this.rest = new LinkedList();
}
但是,象大多数的错误类型一样,阻止它们的出现总比修补它们要好的多。修补错误使得代码很轻易被打断,即使简单的读取器、设置器和 equals 方法都会变得庞大,这样一个事实建议我们要采取一种更好的设计方法。
解决方法和预防措施
事实上有一个简单的办法来避免悬挂复合错误:给每个数据类型的基本例定义一个自己的类。我建议执行有着 LinkedList 类的链表,该类包含一个有 Empty 类或 Cons 类的域,而不是象我们前面做的那样,单个执行链接链表。这些类执行一个公共接口,如图 1 所示。
图 1. Empty 和 Cons UML 示意图
为了执行可变的方法,新的 LinkedList 类作为一个内部不可变的链表的容器,如清单 5 所示。这个步骤是必须的,因为真正的空链表没有域可变,所以它们是不可变的。
清单 5. 每个基本例获得自己的类
import java.util.NoSuchElementException;
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(Object _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(Object _first, LinkedList _rest) {
this.value = new Cons(_first, _rest.value);
}
private LinkedList(List _value) { this.value = _value; }
public Object getFirst() { return this.value.getFirst(); }
public LinkedList getRest() { return new LinkedList(this.value.getRest()); }
public void addFirst(Object o) { this.value = new Cons(o, this.value); }
public boolean isEmpty() { return this.value instanceof Empty; }
public boolean equals(Object that) {
if (this.getClass() == that.getClass()) {
// The above test guarantees that the cast to LinkedList will always
// succeed.
return this.value.equals(((LinkedList)that).value);
}
else {
return false;
}
}
}
那时,执行一个不可变的链表是直截了当的,如清单 6 所示。
清单 6. 对节点作加法和乘法的方法
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; }
public boolean equals(Object that) {
if (this.getClass() == that.getClass()) {
// The above test guarantees that the cast to Cons will always succeed.
Cons _that = (Cons)that;
boolean firstEltsMatch = this.getFirst().equals(_that.getFirst());
boolean restEltsMatch = this.getRest().equals(_that.getRest());
return firstEltsMatch && restEltsMatch;
}
else {
return false;
}
}
}
每一个方法的逻辑现在相当简单了。也请注重,虽然就如以前在单参数 Cons 构造器中一样,它仍然可能引入同样的错误,但我们已经构造了一个显式 Empty 类的事实使这种可能性大大减少。另外,任何阻断我们的链表以及忽略检查空例的代码将返回一个 NoSuchElementException,而不是那些没什么用的空指针异常。
这段代码的一个简单优化是对 Empty 类应用同一个设计类型,因为每一个 Empty 的实例都是同样的。我省去了这个优化,因为它不能相应地消除空指针异常,并且使得代码更复杂了一些。
总结
下面是这个星期的错误类型的分析:
类型:悬挂复合
症状:使用递规定义的数据类型的代码报告一个空指针异常。
原因:定义的某些基本例没有给出自己的类,然后以这种方法定义了递归数据类型。相反,空指针被插入到不同的复合数据类型。客户端代码对基本例处理不一致。
解决方法和预防措施:确保基本例的表示和检查的一致性。为每个基本例给出一个自己的类。
我们这时还不能结束对空指针问题的讨论。在下一个部分,我们将还要注视另外一个非常普遍的,也被证实是一个空指针异常的错误类型,以及怎样识别它和避免它。
参考资料
静态确定可能出现的空指针异常的方法是一种称为基层设定分析的技术。Carnegie Mellon 学院的计算机科学网站提供了一种关于这种方法的简短介绍,同时还有几个相关主题的技术出版物的链接。
Depaul 大学的 Division of Software Engineering 已经在自动化定理方面做了一些工作,在 Java 代码中侦测出空指针异常。
请访问 Patterns 主页获取一个关于设计类型和怎样使用它们的介绍。
请查看 JUnit,通过使您的代码 "test-infested" 来捕捉更多的错误。
请一定要阅读一下 Eric Allen 关于 错误类型的第一篇文章(developerWorks,2001 年 2 月)。
对一般的调试感爱好吗?请查看这个免费的、dW 专有的教程、 Java 调试,来学习更多的从理论到应用的调试技巧。
关于作者
Eric Allen 毕业于 Cornell 大学,获得计算机科学和数学的学士学位。他目前是 Cycorp,Inc. Java 软件开发的负责人,并是 Rice 大学的编程语言小组的一个兼职研究生。他的研究涉及在源程序和字节码层次方面的正式语义模型和 Java 语言扩展的开发。目前,他正在为 NextGen 编程语言(类属运行类型的 Java 语言扩展)实现一个源程序到字节码的编译器。可通过 eallen@cyc.com 联系 Eric。