空指针到处都有!
在一个 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 ldThis = (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 的实例都是同样的。我省去了这个优化,因为它不能相应地消除空指针异常,并且使得代码更复杂了一些。
总结
下面是这个星期的错误类型的分析:
类型:悬挂复合
症状:使用递规定义的数据类型的代码报告一个空指针异常。
原因:定义的某些基本例没有给出自己的类,然后以这种方法定义了递归数据类型。相反,空指针被插入到不同的复合数据类型。客户端代码对基本例处理不一致。
解决方法和预防措施:确保基本例的表示和检查的一致性。为每个基本例给出一个自己的类。
我们这时还不能结束对空指针问题的讨论。在下一个部分,我们将还要注视另外一个非常普遍的,也被证明是一个空指针异常的错误类型,以及怎样识别它和避免它。
在你成功地把自己推销给别人之前,你必须百