假设我们现在需要作一些代码修改,并且需要编写特征测试(153页)来“固定”住已有的行为,那么应当为哪些地方编写测试呢?最简单的答案就是为我们所要修改的每个方法都编写测试。但这就够了吗?如果代码较为简单且易于理解的话的确是够了,但对于遗留代码来说,往往并非如此。一个地方的改动可能会影响到其他地方的行为;除非有测试“坐镇”,否则我们可能永远也不知道自己的修改造成了什么影响。
当需要在特别错综复杂的遗留代码中作改动时,我通常会先花点时间考虑一下应当在哪儿编写测试。这一过程包括考察将要进行的改动,看看它会带来哪些影响,看看被影响的东西又进而会对哪些东西造成影响,后者又会造成哪些影响……。这种推理方式并不新鲜,人们在计算机的启蒙时代就这么做了。
程序员们可能会因为各种各样的原因而坐下来对他们的程序进行推理。有意思的是,对此我们谈论得并不多。我们只是假设每个人都知道怎么做,以及假设这是程序员的“份内之事”。然而当我们面对的是错综复杂的、不易理清的代码时,光是嘴上说说是无济于事的。我们知道应该对代码作一点重构来让它更易于理解,但前面遇到过的测试困境又出现了:如果没有测试在手,我们又如何能知道正在进行的重构是正确的呢?
本章描述的技术填补了这个空白。看来,对于遗留代码,通常我们的确得花点功夫来推测一下代码修改会产生哪些影响,以便找到编写测试的最佳地点。
11.1 推测代码修改所产生的影响
虽说在业界我们就这个问题谈论得并不多,然而实际情况是,每对一个软件作一次功能上的改动,都会带来一连串互相关联的影响。例如,假设我们将下面这段C#代码中的3改为4,就会影响到该函数的返回值,并进而影响到调用该函数的函数的返回值……一路影响下去,直到遇到某种系统边界为止。话虽如此,仍有许多代码的行为还是跟以前一样。由于并没有调用getBalancePoint(),所以它们给出的仍是原来的结果。
int getBalancePoint() {
const int SCALE_FACTOR = 3;
int result = startingLoad + (LOAD_FACTOR * residual * SCALE_FACTOR);
foreach(Load load in loads) {
result += load.getPointWeight() * SCALE_FACTOR;
}
return result;
}
IDE对代码影响分析的支持
有时候真希望有个IDE能帮我在遗留代码中“看到”代码修改所产生的影响。想象这样一种情景:选中某块代码,敲下一个快捷键,于是IDE便给出了对该块代码作改动所可能影响到的所有变量和方法的列表。
或许有一天人们会开发出这样的工具。但在那一天到来之前我们还是得学习如何在没有工具的情况下仅凭大脑去推测代码修改的影响。这个技能学起来不难,但我们很难知道何时才算正确掌握了它。
要想了解影响推测是个什么概念,最佳途径就是从实例入手。下面就是一个Java类,该类所属应用程序的功能是操纵C++代码。这听起来似乎太专业,但其实在推测代码修改的影响时,有没有相关的领域知识并不重要。
让我们来做一个小练习。下面是一个名为CppClass的类,列出其中所有能够在CppClass对象创建之后被改变,从而对其方法的返回值产生影响的东西。
public class CppClass {
private String name;
private List declarations;
public CppClass(String name, List declarations) {
this.name = name;
this.declarations = declarations;
}
public int getDeclarationCount() {
return declarations.size();
}
public String getName() {
return name;
}
public Declaration getDeclaration(int index) {
return ((Declaration)declarations.get(index));
}
public String getInterface(String interfaceName, int [] indices) {
String result = "class " + interfaceName + " {\npublic:\n";
for (int n = 0; n < indices.length; n++) {
Declaration virtualFunction
= (Declaration)(declarations.get(indices[n]));
result += "\t" + virtualFunction.asAbstract() + "\n";
}
result += "};\n";
return result;
}
}
你的答案看起来应该像下面这样:
(1) 可以在declarations列表被传递给CppClass的构造函数之后再往它里面添加额外的元素。由于该列表是按引用传递给CppClass的构造函数并由CppClass的declarations成员变量按引用持有的,因此对它的改动会影响到getInterface、getDeclaration以及getDeclarationCount的结果。
(2) 可以改动或替换declarations列表内的元素,同样还是影响到那几个方法。
有些人看到getName()可能会想,如果有人改动了成员变量name的话,它的返回值便也被改变了,然而实际上,在Java中,String对象是常性的。也就是说它们一旦被创建,值就无法改变了。所以,在CppClass对象被创建出来之后,其getName()便总会返回同样的String值。
下面的一幅图展示了对declarations的改动是怎样影响到getdeclarationCount()的(图11-1)。

图11-1 declarations影响getDeclarationCount
从该图中我们可以看出,如果declarations发生了某些改变,例如其中的元素数目改变了,那么getDeclarationCount()的返回值也会随之改变。
同样,对于getDeclaration(),我们也可以画出一张图来(图11-2)。

图11-2 declarations以及它持有的元素对getDeclaration的影响
如果有人改动了declarations或者其中的元素,则getDeclaration(int index)的返回值也会改变。
图11-3展示了getInterface被影响的情况。

图11-3 影响getInterface的因素
现在,我们可以将上面这几幅图结合起来,成为一张大图(图11-4)。

图11-4 合并起来的影响图
这种图的规则并不复杂。我把它们称为影响草图。作图的关键是:为每个可能会被影响到的变量以及每个返回值可能改变的方法画一个单独的椭圆。这些变量可能来自同一个对象,也可能来自不同的对象。究竟属于何者并不重要,我们只需为每个会改变的东西画上一个椭圆,并从它们出发画一个箭头指向那些因它们的改变而在运行期改变的东西。
倘若你的代码结构良好,则其中的大多数方法的影响结构也会比较简单。实际上,衡量软件好坏的标准之一便是,看看该软件对外部世界的相当复杂的影响能否由代码内的一组相对简单得多的影响所构成。任何改动,只要能够使代码的影响结构图简单化,就能够使其更易理解和维护。
让我们把视野放远一点,看一下CppClass所处系统的影响结构图。CppClass对象是在一个名为ClassReader的类中被创建出来的。实际上,我们已经能够确定,它们仅在ClassReader中被创建。
public class ClassReader {
private boolean inPublicSection = false;
private CppClass parsedClass;
private List declarations = new ArrayList();
private Reader reader;
public ClassReader(Reader reader) {
this.reader = reader;
}
public void parse() throws Exception {
TokenReader source = new TokenReader(reader);
Token classToken = source.readToken();
Token className = source.readToken();
Token lbrace = source.readToken();
matchBody(source);
Token rbrace = source.readToken();
Token semicolon = source.readToken();
if (classToken.getType() == Token.CLASS
&& className.getType() == Token.IDENT
&& lbrace.getType() == Token.LBRACE
&& rbrace.getType() == Token.RBRACE
&& semicolon.getType() == Token.SEMIC) {
parsedClass = new CppClass(className.getText(),
declarations);
}
}
...
}
记得我们之前对CppClass有哪些了解吗?当时我们能否知道一个CppClass对象在被创建出来之后,它所持有的declarations列表会不会再改变呢?这个问题是没法在CppClass那儿找到答案的,我们需要弄清楚declarations列表是怎么被填充的。如果进一步考察上面这个类,便能看出,ClassReader中只有一处地方往declarations列表中添加了Declaration对象,那就是在matchVirtualDeclaration方法当中。具体过程为:parse()中调用了matchBody(),后者进而调用了下面这个matchVirtualDeclaration方法:
private void matchVirtualDeclaration(TokenReader source)
throws IOException {
if (!source.peekToken().getType() == Token.VIRTUAL)
return;
List declarationTokens = new ArrayList();
declarationTokens.add(source.readToken());
while(source.peekToken().getType() != Token.SEMIC) {
declarationTokens.add(source.readToken());
}
declarationTokens.add(source.readToken());
if (inPublicSection)
declarations.add(new Declaration(declarationTokens));
}
看上去,所有在这个列表上发生的事情都在CppClass对象被创建出来之前发生了。由于我们将一个元素存入declarations列表之后便不再保留其任何引用,所以该列表便没法再被更改了。
再来考察一下declarations列表里面的元素。TokenReader的readToken方法返回的是一个Token对象,后者仅持有一个字符串和一个永不改变的整型数。毫不夸张地说,只需扫一眼Declaration类的定义便可发现,一旦其对象被创建起来,就没有任何其他东西可以再改变它的状态了,于是我们可以很放心地断言,一个CppClass对象被创建后,其中的declarations列表以及列表里的元素便不再变动了。
以上这些分析对我们是有帮助的,如果我们从CppClass对象那儿得到了一些意外的结果,那么我们知道只需从几个地方下手来找原因就可以了。一般来说,可以查看CppClass的子对象都是在哪些地方创建的,从而去弄清楚发生了什么。另外我们还可以给CppClass持有的某些引用加上final修饰,从而使它们成为常量,这样代码便更加清晰了。
对于写得糟糕的程序,我们往往会发现很难弄明白眼下发生的事情因何而起。当面对一个意料之外的结果时,需要对付的其实是一个调试问题,我们得从问题的现象一路推到它的源头。然而倘若面对的是遗留代码,需要考虑的便是另外一个问题了,即一次特定的修改可能会对程序的其余结果产生何种影响。
这就要求我们从修改点一路向前推测影响。掌握了这种推理方式,也就初步掌握了寻找编写测试的合适地点的技术。







