【IT168 技术文章】在 java 编程中,最常见的重复(被抱怨最多的)错误之一是空指针异常。跟踪这些错误中的某一个的产生原因,真的会让您对您当初的择业决定产生怀疑。在诊断 java 代码的这一部分中,我们通过把和空指针异常联系在一起的最常见的一个类型编成目录,来继续我们的错误类型检查,并一步步分析一个含有空指针异常的类的示例。然后我们将回顾几个编程技巧,帮您减少这种类型错误的出现。
空指针到处都有!
在一个 Java 程序员所能遇到的所有异常中,空指针异常属于最恐怖的,这是因为:它是程序能给出的信息最少的异常。例如,不像一个类转型异常,空指针异常不给出它所需要的内容的任何信息,只有一个空指针。此外,它并不指出在代码的何处这个空指针被赋值。在许多空指针异常中,真正的错误出现在变量被赋为空值的地方。为了发现错误,我们必须通过控制流跟踪,以发现变量在哪里被赋值,并确定是否这么做是不正确的。当赋值出现在包中,而不是出现在发生报错的地方时,进程会被明显地破坏。
许多 Java 开发人员告诉我,他们所遇到的绝大多数程序崩溃是空指针异常,并且他们渴望有一种工具,能在程序第一次运行前静态地识别出这些错误。不幸的是,自动控制理论告诉我们,没有工具可以静态地决定哪些程序将抛出空指针异常。但是在一个程序中,用一个工具排除许多空指针异常是有可能的,留给我们仅仅一小部分需要我们必须人工检查的潜在的问题所在。实际上,为了为 Java 程序提供这样一个工具,现在正做着一些研究。但是一个好的工具也只能为我们做这些。空指针异常将决不会被完全根除。当它们真的发生时,工具能帮我们弄清和它们相联系的错误类型,这样我们能快速诊断它们。另外,我们可以应用某些编程和设计技巧来显著减少这些类型错误的出现。
悬挂复合类型
我们将探讨的第一个关于空指针异常的错误类型,是一个我称之为悬挂复合类型的错误类型。这种类型的错误是这样产生的:定义的某些基本例没有被给出它们自己的类,然后以这种方法定义了一个递归的数据类型。相反,空指针被插入到不同的复合数据类型中。数据类型实例的使用就好像空指针被正确填充了一样。我称之为悬挂复合类型是因为冲突代码是复合设计类型的一个有缺点的应用程序,其中,复合数据类型包含悬挂的引用(也就是空指针)。
原因
考虑下面 LinkedList 类的单连接执行,它有一个悬挂复合类型。为了示例的简单起见,我只执行在 java.util.LinkedList 中定义的一些方法。为了显示这种类型的错误是多么隐蔽,我已经在下面代码中引入一个错误。看看你是否能发现它。
清单 1. 单连接链表 这段代码相当的糟糕。它在两个域中都放置一个空指针来表示空链表,而不是为空链表定义一个单独的类。一开始看来,用这种方法表示一个空链表使代码简单。毕竟,我们不必仅仅为了空链表而去定义一个额外的类。但是,正如我将证明的,这样的简单操作只是一个幻想。让我们为这个类定义一些读取器 (getter) 和设置器 (setter) 方法:
2 public class LinkedList {
3 private Object first;
4 private LinkedList rest;
5 /**
6 * Constructs an empty LinkedList.
7 */
8 public LinkedList() {
9 this.first = null;
10 this.rest = null;
11 }
12 /**
13 * Constructs a LinkedList containing only the given element.
14 */
15 public LinkedList(Object _first) {
16 this.first = _first;
17 this.rest = null;
18 }
19 /**
20 * Constructs a LinkedList consisting of the given Object followed by
21 * all the elements in the given LinkedList.
22 */
23 public LinkedList(Object _first, LinkedList _rest) {
24 this.first = _first;
25 this.rest = _rest;
26 }
27 }
28
清单 2. 为 LinkedList 定义方法
2 if (! (this.isEmpty())) {
3 return this.first;
4 }
5 else {
6 throw new NoSuchElementException();
7 }
8 }
9 public LinkedList getRest() {
10
11 if (! (this.isEmpty())) {
12 return this.rest;
13 }
14 else {
15 throw new NoSuchElementException();
16 }
17 }
18 public void addFirst(Object o) {
19 LinkedList oldThis = (LinkedList)this.clone();
20 this.first = o;
21 this.rest = oldThis;
22 }
23 public boolean isEmpty() {
24 return this.first == null && this.rest == null;
25 }
26 private Object clone() {
27 return new LinkedList(this.first, this.rest);
28 }
29
注意,两个读取器采取的行动依赖于是否链表为空。这正好是那种一个正确构建的类层次所要防止的 if-then-else 链。由于这些链,我们不用在一个单一类型的链表上孤立地考虑这些读取器。此外,如果在将来的某一天,我们需要第三种类型的链表(例如一个不可变的链表),我们将不得不重新编写每一个方法的代码。
但是真正简单的方法是我们怎样才能轻易地避免将错误引入到程序中。按照这种方法,清单 2 中的 LinkedList 的执行只能是可怜的失败。实际上,就象我前面提到的,我们的 LinkedList 类已经包含一个微小的但有破坏性的错误(你发现了吗?)。空链表的表示到底是什么呢?我前面说过,空链表就是两个域都包含一个空指针的 LinkedList 。实际上,零参数构造器就是建立一个这样的空链表。但是注意单参数构造器 不是把空链表放入到 rest 域,这是构建一个只有一个值的链表所必须的。相反,它是用空指针替代。由于悬挂复合类型错误将空指针和基本例的位置标记符相混淆,象这样的错误是很容易犯的。为了了解这错误怎样表明自己是一个空指针异常,让我们为清单写一个 equals 方法:
清单 3. 哪里错了
2 // If the objects are not of the same class, then they are not equal.
3 // Reflection is used in case this method is called from an instance of a
4 // subclass.
5 if (this.getClass() == that.getClass()) {
6 LinkedList _that = (LinkedList)that;
7 if (this.isEmpty() || _that.isEmpty()) {
8 return this.isEmpty() && _that.isEmpty();
9 }
10 else {
11 boolean firstEltsMatch = this.getFirst().equals(_that.getFirst());
12 boolean restEltsMatch = this.getRest().equals(_that.getRest());
13
14 return firstEltsMatch && restEltsMatch;
15 }
16 }
17 else {
18 return false;
19 }
20 }
21
如果 this 和 that 都是非空,那么 equals 方法可以正确地预计它能调用它们的 getFirst 和 getRest 而不出现错误信息。但是如果链表中的任意一个包含用单参数构造器建立的任何部分,那么,在一个空链应该等待的地方,这个递归调用将最终表示为一个空指针。当它调用 getFirst 或 getRest 时,一个空指针异常就出现了。
一种观点可能是简单地直接把空链表表示成空指针,但是这个想法完全不可行的,因为在那时,不可能去掉链表的最后一个元素和在空链表中插入一个元素。
另一方面,可以照下面的方法重写单参数构造器来修复错误:
清单 4. 修复错误
2 this.first = _first;
3 this.rest = new LinkedList();
4 }
5
但是,象大多数的错误类型一样,阻止它们的出现总比修补它们要好的多。修补错误使得代码很容易被打断,即使简单的读取器、设置器和 equals 方法都会变得庞大,这样一个事实建议我们要采取一种更好的设计方法。