欢迎光临诊断 Java 代码,一个隔周更新的新专栏,着重讨论和您日常编程工作有关的 Java 解决方案。本文为第一篇,介绍了错误模式的概念,一个非常有用的概念,它将提高您检测和修正代码中错误的能力。您会了解到一种最普遍的错误模式,这将为您开始识别和避免更高级的错误模式奠定基础。
错误模式和它们为什么有用
正如好的编程技能涉及很多设计模式(您可以在不同的程序上下文中组合和应用这些模式)的知识一样,好的调试技能也涉及对错误模式的一定了解。错误模式就是已发出的错误和程序中潜在的错误之间的重复出现的相互关系。这种概念对编程来说并不新鲜。医生们在诊断疾病时依靠相似类型的相互关系。他们在实习期间通过和资格较老的医生共同工作来学习这些。他们的教育就是集中在做这种诊断上的。相反,我们软件工程师的教育是集中在过程设计和算法分析上的。这些技能固然重要,但是人们对调试过程的教育却很少关注。相反,我们得自己去“拾起”这种技能。随着极端编程的出现和它对单元测试的注重,这种做法已经开始改变了。但是频繁的单元测试只是解决了问题的一部分。一旦发现错误,就必须诊断和纠正它们。幸运的是,很多错误都遵循我们可以识别的几种错误模式的其中一种。一旦您可以识别出这些错误模式,您就可以诊断出错误的原因并且更快地纠正它了。
错误模式与反模式有关,反模式是一次又一次被证明是失败的公共软件设计的模式。虽然反模式是设计模式,错误模式却是与编程错误相关的错误的程序行为的模式。这与设计根本没有关系,而是与编程和调试过程有关。
通过示例学习
为了说明错误模式后面的思想,让我们来考虑一种基本错误模式,编程新手(经常还有更高级的程序员)常常会遇到这种错误模式。在后面的文章中,我们会谈到更高级的错误模式。对每一种模式,我会讨论将有助于把该模式的错误的发生控制到最少的编程原则(并非暗示所有的错误都是不遵循编程原则的结果;不管我们遵循多少原则,我们都会犯错误)。
为了分类起见,我会使用下面的形式(从医学上借用一些术语)来概括错误模式描述:
模式名称
症状
起因
治疗方法和预防措施
Rogue Tile 模式
也许它是编程新手中最普遍的错误模式,起因是复制和粘贴一段代码到程序的其它部分。有时,复制的一小部分因为功能上需求的略微不同而作了改动。不可避免地,错误在一个副本中被修正了,而在另一个副本中没有被修正,这样在错误症状复发时就会让您很头疼。尽管大多数程序员很快就熟悉了这种错误模式,但他们中很少人采取适当的措施来将这种错误的出现控制到最少。您很容易就会偷懒不去思考而简单地复制您认为已经可以运行的代码。但是工作效率由于修正代码而丧失,这是因为不加选择的复制—粘贴操作很快降低了复制代码带来的任何工作效率。
我称此为 Rogue Tile 模式是因为,一段代码的各个副本可以被看成是分布在程序中的“tile”。由于不同副本中的代码出现了差异,副本就变成了“rogue tile”。
症状
这种错误的模式的最普遍症状是,在您认为已经修正了问题以后,程序还继续表现出错误的行为。
起因
为了理解这种情况发生的原因,我们来看看下面的二元树类层次结构:
public abstract class Tree {
}
public class Leaf extends Tree {
public Object value;
...
}
public class Branch extends Tree {
public Object value;
public Tree left;
public Tree right;
...
}
对于这些类要注意的第一件事就是,两种具体类都包含 Object 类型的 value 字段。如果您决定稍后让树包含,比如说,Interger,您也许会忘记更新其中的一个字段声明。如果程序的其它部分需要这些字段是 Interger 的话,程序就很可能不会编译。您或许记得您改变了其中一个类的 value 字段的类型,却忽略了一个事实,就是您没有在其它类中作相应的改变。
一些预防措施
当然,这个示例所示的错误是编程新手可以很快学会通过分解出公共代码来避免的。在本例中,字段声明应该移到 Tree 类中。它的两个子类就会继承这个字段,而且对字段声明的任何改变都只需要在一个地方出现。
继续看这个示例,我们可能还会编写在一个 Tree 中相加和相乘所有节点的方法。为了简单起见,我将以递归的方式来编写这些方法。
// in class Tree:
public abstract int add();
public abstract int multiply();
// in class Branch:
public int add() {
return this.value.intValue() + left.add() + right.add();
}
public int multiply() {
return this.value.intValue() * left.multiply() + right.multiply();
}
// in class Leaf:
public int add() { return this.value.intValue(); }
public int multiply() { return this.value.intValue(); }
请注意我在 multiply 方法中为 Branch 类引入的错误:我没有用第三项去乘,而是加了它。错误发生了,因为我通过复制 add 方法中的代码并作轻微(但不完全)的改动创建了 multiply 方法。这种错误非常隐蔽,因为调用 multiply 方法永远不会发出错误信号。事实上,在很多情况下,它会返回一个看上去完全合理的结果。
就象以前一样,我们可以通过分解出公共代码来将这种错误控制到最少。在这种情况下,我们可以编写一个单独的方法,它在 Tree 上累计一个运算符(作为一个参数传送)。我们可以使用一种被称为公共模式的设计模式(不是错误模式!)在对象中封装这个运算符。
public abstract class Operator {
public abstract int apply(int l, int r);
}
public class Adder extends Operator {
public int apply(int l, int r) {
return l + r;
}
}
public class Multiplier extends Operator {
public int apply(int l, int r) {
return l * r;
}
}
然后我们就可以如下面的代码所示在我们的 Tree 类层次结构中改变这个方法:
// in class Tree:
public abstract int accumulate(Operator o);
public int add() {
return this.accumulate(new Adder());
}
public int multiply() {
return this.accumulate(new Multiplier());
}
// in class Leaf:
public int accumulate(Operator o) {
return value.intValue();
}
in class Branch:
public int accumulate(Operator o) {
return o.apply(this.value.intValue(),
o.apply(left.accumulate(o),
right.accumulate(o)));
}
通过分解出公共代码,我们消除了在 add 和 multiply 方法正文中出现复制—粘贴错误的可能性。另外,请注意我们不再需要为 Tree 的每一个子类编写单独的 add 和 multiply 方法了。
分解出公共代码是一个很好的习惯,但它并不适用于所有的情况。比如说,Java 类型系统的简单性经常迫使我们在精确类型检验和保持对程序的每个不同的功能性元素的单点控制(请参阅参考资料,阅读我写的关于 NextGen 的文章)之间作出选择。正因为这个,Rogue Tile 模式是所有开发人员必须一直努力以控制到最少的一种错误类型。