诊断Java代码:连续初始化器错误模式

您经常会看到代码不是仅仅通过调用构造函数对类进行初始化,它还通过一些紧接着的意在设置各个域的动作对类进行初始化。不幸的是,这样紧接着的动作是错误的高发地带,会带来连续初始化(run-on initialization)类型的错误。

连续初始化

由于各种原因(多数是糟糕的),您经常会看到这样的类定义,其中的类构造函数并不带有足够的参数来适当地初始化类的所有域。这样的构造函数要求客户机类用几个步骤来对实例进行初始化(设置未被初始化的域的值),而不是用一个构造函数调用就行了。以这样的方式初始化实例是一个易于出错的过程,我把它称为 连续初始化。这个过程产生的各种错误类型有相似的症状和治疗方法,所以我们可以将它们统统归入一种称为 连续初始化器错误模式的模式。

例如,考虑以下代码:

清单 1. 一个简单的连续初始化

class RestrictedInt {  public Integer value;  public boolean canTakeZero;  public RestrictedInt(boolean _canTakeZero) {   canTakeZero = _canTakeZero;  }  public void setValue(int _value) throws CantTakeZeroException {   if (_value == 0) {    if (canTakeZero) {     value = new Integer(_value);    }    else {     throw new CantTakeZeroException(this);    }   }   else {    value = new Integer(_value);   }  }}class CantTakeZeroException extends Exception {  public RestrictedInt ri;  public CantTakeZeroException(RestrictedInt _ri) {   super("RestrictedInt can't take zero");   ri = _ri;  }}class Client {  public static void initialize() throws CantTakeZeroException {   RestrictedInt ri = new RestrictedInt(false);   ri.setValue(0);  }}

不幸的是,对这个类的实例的初始化序列很容易出错。您可能已经注意到,在上面的代码中,在第二个初始化步骤处抛出了一个异常。结果是,在执行该步骤后应该已经被设置了的域未被设置。

但是,所抛出的异常的处理程序可能并不知道该域未被设置。如果在从异常恢复的过程中,处理程序访问 RestrictedInt 的有问题的 value 域,那么连它自己都有可能在 NullPointerException 处受阻。

如果真发生了那样的事情,倒不如处理程序根本不存在来得好些。至少被检查的异常包含有一些关于它的起因的线索。但 NullPointerException 是臭名昭著的难以诊断的异常,因为它们(必然地)几乎不包含关于某个值为什么一开始被设置为空的信息。而且,这些异常仅在未被初始化的域被访问时才发生。那个访问很可能是在离该错误的起因(即一开始未能初始化该域)很远的地方发生的。

当然,连续初始化错误还会引起其它错误。

更多因连续而产生的错误

可能引起的其它错误是:

编写初始化代码的程序员可能会忘记把某个初始化步骤包括进去。

初始化步骤中可能存在程序员并不知道的基于次序的依赖关系,程序员因而会不按次序执行这些语句。

正在被初始化的类可能会被更改。新的域被添加进来,或者旧的域被删除。结果每个客户机中的所有初始化代码都必须修改,以适当地设置这些域。多数修改后的代码都很相似,但就算只漏了一个副本,也会带来错误。因此,连续初始化器很容易就会变成 rogue tile(请参阅我关于 Rogue Tile 错误模式的文章了解一些背景)。

由于所有问题都与连续初始化有关,所以,定义初始化所有域的构造函数会好得多。在上面的示例中, RestrictedInt 的构造函数应带有一个 int ,以初始化它的 value 域。包含一个留有任何未被初始化的域的类构造函数,这种做法永远不会有好的理由。当从头编写类时,这并不是难以遵循的原则。

但是,如果您 必须处理一大堆代码库,而其中某个类并未在它的构造函数中初始化其所有域,并且代码库中到处是连续初始化器,那又该怎么办呢?我已经不止一次陷入到了这种境地。

当您束手无策时

不幸的是,这样一种情形,即处理其中某个类没有在它的构造函数中初始化所有域的旧代码库,比多数程序员所愿意处理的情形更常见。如果旧代码库很大,而且有损坏了的类的很多客户机,那您可能不会想修改构造函数说明,特别是如果代码的单元测试并不充足的话。不可避免地,您将在破坏了一些未编制文档的不变量之后作罢。

通常,在这种情形中,最好是抛开那些旧代码,从头做起!这听起来像疯言疯语,但您修补像那样的代码中的错误花去的时间很容易就能让重新编写代码所用的时间相形见绌。有很多次,我都煞费苦心地处理带有那种问题的庞大的旧代码库,但最终,我都只好放弃,但愿自己要是从头做起就好了。

但如果不能选择抛开那些代码的话,我们仍然可以通过结合以下简单的做法来尝试控制出错的可能性:

把域初始化成(非空)缺省值。

包含额外的构造函数供使用。

在类中包含一个 isInitialized 方法。

构造特殊的类来代表缺省值。

让我们来看看为什么应该采用这些做法。

把域初始化成(非空)缺省值

通过把缺省值填充到域,您确保了类的实例在任何时候都处于定义良好的状态。这一做法对于除非另行指定,否则就取空值的引用类型尤其重要。

为什么?因为滥用空值不可避免地会导致 NullPointerException 。而 NullPointerException 是很糟糕的。一个原因是,这些异常几乎不提供关于一个错误的真正起因的信息。另一个原因是,它们常常在离错误的实际起因很远的地方被抛出。

要不惜一切代价避免它们。如果您决定要使用空值,以便您可以发出某个类尚未被完全初始化的信号,那请您参阅我的关于 Null Flag 错误模式的文章获取帮助。

包含额外的构造函数

当您包含额外的构造函数时,您可以在新的上下文中使用它们,在那里您不必包含新的连续初始化。仅仅是因为有些上下文被限制成必须使用连续初始化,其它上下文则不必为此付出代价。

在类中包含一个 isInitialized 方法

可以在类中包含一个 isInitialized 方法,以允许迅速判断某个实例是否已经被初始化了。在编写需要连续初始化的类时,这样一个方法基本上总是一个好主意。

对于您不是自己维护这些类的情况,您甚至可以把这样的 isInitialized 方法放到您自己的实用程序类中。毕竟,如果一个实例未被初始化,并且其结果可以从外部观察到,那么您就可以写一个方法来检查这个结果(即使它要求采用一般认为是不明智的实践 — 捕获 RuntimeException )。

构造特殊的类来代表缺省值

不是允许用空值来填充域,而是允许构造特殊的类(很可能是用 Singletons)来代表缺省值。然后把这些类的实例填充到缺省构造函数的域。您不但将降低抛出 NullPointerException 的可能性,而且,如果这些域被不恰当地访问了,您还将能够精确地掌握真正发生了什么错误。

例如,我们可以修改 RestrictedInt 类如下:

清单 2. 带 NonValue 的 RestrictedInt

class RestrictedInt implements SimpleInteger {  public SimpleInteger value;  public boolean canTakeZero;  public RestrictedInt(boolean _canTakeZero) {   canTakeZero = _canTakeZero;   value = NonValue.ONLY;  }  public void setValue(int _value) throws CantTakeZeroException {   if (_value == 0) {    if (canTakeZero) {     value = new DefaultSimpleInteger(_value);    }    else {     throw new CantTakeZeroException(this);    }   }   else {    value = new DefaultSimpleInteger(_value);   }  }  public int intValue() {   return ((DefaultSimpleInteger)value).intValue();  }}interface SimpleInteger {}class NonValue implements SimpleInteger {  public static NonValue NLY = new NonValue();  private NonValue() {}}class DefaultSimpleInteger implements SimpleInteger {  private int value;  public DefaultSimpleInteger(int _value) {   value = _value;  }  public int intValue() {   return value;  }}

现在,如果您的任何访问这个域的客户机类要在结果元素上执行一个 intValue 操作,则由于 NonValues 不支持该操作,所以这些客户机类必须首先强制转型成 DefaultSimpleInteger 。

上述办法的优点是您将不断地在代码中您忘记了强制转型的各个地方得到提示(通过编译器错误),指出这个方法调用无法在该缺省值上工作。而且,在运行时,如果您碰巧访问了这个域,而它包含缺省值,那您就将得到一个 ClassCastException ,它将包含比您原来将会得到的 NullPointerException 多得多的信息 — ClassCastException 将不仅告诉您那里实际发生了什么,而且还将告诉您程序在那里应该是什么样子。

缺点是性能将有所损失。每当域被访问时,程序都还要执行一个强制转型。

如果您觉得不需要编译错误消息也行,那另一种解决方案是在接口 SimpleInteger 中包含 intValue 方法。然后,您就可以用一个抛出任何您想抛出的错误在缺省类中实现这个方法,(而且您可以包含您想包含的任何信息)。为了说明这一点,请考察如下示例:

清单 3. 抛出异常的 NonValue

class RestrictedInt implements SimpleInteger {  public SimpleInteger value;  public boolean canTakeZero;  public RestrictedInt(boolean _canTakeZero) {   canTakeZero = _canTakeZero;   value = NonValue.ONLY;  }  public void setValue(int _value) throws CantTakeZeroException {   if (_value == 0) {    if (canTakeZero) {     value = new DefaultSimpleInteger(_value);    }    else {     throw new CantTakeZeroException(this);    }   }   else {    value = new DefaultSimpleInteger(_value);   }  }  public int intValue() {   return value.intValue();  }}interface SimpleInteger {  public int intValue();}class NonValue implements SimpleInteger {  public static NonValue NLY = new NonValue();  private NonValue() {}  public int intValue() {   throw new    RuntimeException("Attempt to access an int from a NonValue");  }}class DefaultSimpleInteger implements SimpleInteger {  private int value;  public DefaultSimpleInteger(int _value) {   value = _value;  }  public int intValue() {   return value;  }}

这个解决方案能够比 ClassCastException 提供更好的错误诊断。而且它还更高效,因为在运行时不需要强制转型。但是这个解决方案将不会要求您在每一个访问点考虑域的可能值。

选择使用哪一个解决方案,一部分取决于您的偏好,一部分取决于您的项目的性能和健壮性约束。

现在,让我们来研究一种乍一看好像完全错误的技术。

包含只抛出异常的方法

一开始,您可能会觉得这种做法天生就是错误的,而且有悖常理 — 类应该仅包含实际对数据进行有意义操作的方法。特别是当您在给程序员教授面向对象编程时,包含诸如这样的类可能会令人大惑不解。

例如,考虑两种可能的定义 List 的类层次结构的方式,如以下的清单 4 和清单 5 所示:

清单 4. 不带通用 getter 的 List

abstract class List {}class Empty extends List {}class Cons extends List {  Object first;  List rest;  Cons(Object _first, List _rest) {   first = _first;   rest = _rest;  }  public Object getFirst() {   return first;  }  public List getRest() {   return rest;  }}

清单 5. 在接口中带有 getter 的 List

abstract class List {  public abstract Object getFirst();  public abstract Object getRest();}class Empty extends List {  public Object getFirst() {  throw new RuntimeException("Attempt to take first of an empty list");  }  public List getRest() {  throw new RuntimeException("Attempt to take rest of an empty list");  }}class Cons extends List {  Object first;  List rest;  Cons(Object _first, List _rest) {   first = _first;   rest = _rest;  }  public Object getFirst() {   return first;  }  public List getRest() {   return rest;  }}

对于初学面向对象语言的程序员, List 的第一个版本(不带有通用 getter 的那个)背后的动机不会很令人费解。就直觉而言,除非一个方法做实际工作,否则类就不应该包含这个方法。不过以上关于处理缺省类的考虑事项也同样适用于这个示例。

不断地往代码插入强制转型,效率是很低的,而且会使代码变得拖泥带水。此外,类强制转型会给性能带来严重的后患,尤其是对于像 List 这样经常被调用的实用程序类。

就所有设计实践来说,当要考虑实践的深层动机时,这种做法是最适用的。这种动机并非总是适用的,所以,当它不适用时,就不应采用这种做法。

修正错误,情况会更好

您可能已经注意到(如果您阅读过我的论述错误模式的其它文章的话)连续初始化器错误有一点点不同。这一次我提供了不少如何解决这些错误的根本起因的想法,而不是仅仅将它修正。这是因为,在很多场合,我必须解决它们。那些可不是好差使。

而且,正如我们提到过的考虑事项所表明的,完全避免连续初始化要好得多。但当您必须处理它们时,至少能够保护您自己了。这里是这个错误模式的总结:

模式:连续初始化器。

症状:在未被初始化的域被访问的地方抛出了一个 NullPointerException 。

起因:有某个类,其构造函数并未直接初始化所有域。

处方及预防措施:在一个构造函数中初始化所有域。当没有更佳的值可使用时,使用代表缺省值的特殊类。对于有更佳的值可以使用的情况,包含多个构造函数。包含一个 isInitialized 方法。

在接下来的几个月里,我们将回到错误模式这个主题。下个月,我们将讨论一些 Java 语言中出现的与平台相关的错误。与普遍的看法相反,Java 语言并不是不受那类错误的影响。

今天的长相厮守,只是尽力而为而已。

诊断Java代码:连续初始化器错误模式

相关文章:

你感兴趣的文章:

标签云: