级别: 初级 Eric E. Allen (eallen@cs.rice.edu), 博士研究生
2001 年 8 月 09 日 在诊断 Java 代码的最后一部分,我们看到实现一个 Java 接口, 而实际上未满足其预期语义是有可能的。这个分两部分的序列的第二篇文章演示了两个解决这种虚假的实现错误的方便的工具。Eric Allen 向您显示了如何使用断言和单元测试作为可执行文档,使您的代码更安全,可移植性更强。
臆想实现重温
回想一下
上次接口的
臆想实现是一个合法的实现,但不满足接口规范的某些未经检查的方面。我们考虑一下下面的堆栈接口,以及许多未被其单独的类型签名捕获的不变量:
清单 1. 一个堆栈接
public interface Stack {
public Object pop();
public void push(Object top);
public boolean isEmpty();
}
|
例如,请考虑我们希望任意堆栈实现都遵守的下列规则:
- 如果一个对象
o 被压进堆栈
s ,且在堆栈上执行的下一个操作是
pop ,则该操作的返回值将为
o 。
- 对于一个给定的堆栈
s ,如果
s.isEmpty() 的返回值为
true ,且在堆栈上执行的下一个操作是
pop ,那么对
pop 的调用将抛出一个
RuntimeException 异常。
尽管 Java 语言在接口不变量的规范方面有限制,但指定象这样的添加的接口不变量还是可能的。就象我们将看到的那样,您可以用这种可以自动检查以查看接口实现是否满足它们的方法来指定这些不变量。
断言
向程序中添加
断言是一个很老但未被充分利用的好主意。这种思想是在程序执行的不同阶段置入某些条件的布尔检查。根据
design by contract思想,断言应该被包含在接口实现与外部客户达成的协议中。通常情况下,断言使用下面 3 种变化形式之一:
-
前提条件检查在进入代码块之前某些条件是否成立。
-
后置条件检查在退出代码块时某些条件是否成立。
-
不变量检查在代码块执行
期间是否具备某些条件。由于它们的代价问题,这类断言极少以其最常规的形式受支持。相反,允许程序员检查代码块执行的某个
点是否具备各种条件。
对于不给定实现代码的接口规范,前两种是最有用的。
引入了基于 Java 的预处理器,如 iContract 之后,就有可能将断言放入源代码中并使它们自动转换为进行检查以确保断言永远有效的 Java 代码。由于经这些工具处理过的断言在原始文件中被指定为 Javadoc 注释,我们就可以很轻易地编译该文件,而无须运行预处理器,为未检查任何断言的代码制作一个“产品”副本。但用这种方法除去断言太过频繁。除对性能影响至关重要的部分之外,在程序的其它所有部分,断言检查的系统开销都不会很大。将断言留在程序中,可使得诊断来自最终用户的错误报告更加容易(肯定
将会有错误报告)。
在我们的堆栈示例中,我们可以向 pop
pop 添加一个断言,以确保 pop 永远不会在空堆栈上被调用:
清单 2. 测试堆栈接口的一个断言
public interface Stack {
/**
*@pre ! this.isEmpty()
*/
public Object pop();
public void push(Object top);
public boolean isEmpty();
}
|
向接口代码中添加类似这样的断言有助于确保当调用实现方法时,这种附加的不变量有效。因为它们可被编译成代码,所以它们是快速诊断臆想实现发生的高效方法。而且,它们还可作为接口的附加文档。但是,由于它们是严格的函数布尔表达式,它们被限制在自己的表达中 ― 例如,我们将如何把堆栈的第一条规则编写到断言中?与类型声明相同,断言自身表达能力不够,无法捕捉我们可能希望在接口上指定的全部规则。由于这个原因,最好是将它们与单元测试协力使用。
单元测试
程序员可为接口提供的另一个规范是一套单元测试。使用单元测试框架,如 JUnit(请参阅
参考资料),可以很容易地检查这套单元测试对于接口的所有实现是否是支持的。不可过分强调能够消除臆想实现发生的单元测试的范围。实际上,单元测试是一种提供这些额外的不变量的限制规范的出色方法。一个带有一套附随单元测试的接口为实现人员提供了一种检查是否满足了接口的额外不变量的方法。笔者强烈推荐为将由外部客户使用的所有接口提供这种测试,他们会为此感谢您的。即使是内部接口,有了这种附随的测试套件实现起来也要容易得多。
当然,与类型声明不同,有限的几套测试在检查实现时无法遍及所有可能的输入。但单元测试的检查已经足够彻底,使我们能够适度地期望它们捕捉到不变量的大多数不合法错误。当然,它们比类型签名更富于表现力。
接口的一套单元测试还可以被视为该接口的一种文档形式。在单元测试中描述不变量时要比在文字描述中精确得多。例如,考虑下面检查堆栈的不变量的测试:
清单 3. 堆栈的单元测试
public void testPushAndPop() {
Stack s = new MyStack();
Object o = new Object();
s.push(o);
assertTrue(o == s.pop());
}
public void testPopOnEmpty() {
Stack s = new MyStack();
assertTrue(s.isEmpty());
try {
s.pop();
}
catch (RuntimeException e) {
return;
}
throw new RuntimeException("pop on empty stack does not fail");
}
|
将这些测试与我们在开头
用英语定义的堆栈的不变量进行比较。与单元测试不同,这些英语描述使得许多东西的解释都是开放的。例如,当第一条规则声明“该操作的返回值将为
o ”时,是否意味着该返回值与压入对象满足
equals 测试,或实际上将满足
== ?单元测试把这一点搞得很清楚。
关于这些测试要注意的其它几点:
- 它们很小,又很直接了当。因为接口的单元测试还可作为文档,主要是要尽可能容易阅读。
- 因为它们可以是任意 Java 代码,所以允许我们测试实现的复杂行为。例如,注意第二种方法实际上测试应该抛出异常时有没有抛出,如果未抛出异常,则测试失败!
单元测试如此富于表现力当然会有优势。它使我们能够捕获我们希望指定的接口的任意规则的本质。这种表现力也有缺点:我们可以指定规则的示例,但我们也注意到无法使用单元测试检查规则是否适合程序所有可能的输入。
现在我们可以考虑用接口规范的三种语言(它们是,单元测试语言、断言语言和类型系统)来形成表达的层次。层次每上一级都要以语言的易测性下降为代价。由于经常都是这种情况,在表达性和易测性之间就存在基本矛盾。通过为接口加入几种这样的规范语言,就有可能达到两全其美的效果。
结论
如这些示例所示,断言和单元测试为接口提供了可检查规范,是避免臆想实现的高效方法。而且,它们检查的不变量的种类是互补的。理想情况下,一个接口是两者都包含的。
注意,这个规范的内涵并不只是捕捉完整的实现中的错误,它实际上还帮助自诩为实现人员的人确保
在他编程时正在正确地实现接口。这不仅可以促进生产率,还可以使程序员更加高兴。发送代码,使其通过一个自动检查工具 ― 并看着它通过总是很不错的。
参考资料
关于作者  | |  | Eric Allen 曾获 Cornell 大学的计算机科学和数学的学士学位,目前是 Rice 大学的 Java 编程语言小组的博士研究生,他的研究涉及为 Java 语言开发语义模型和静态分析工具,都是在源代码和字节代码级别上的。目前,他正在为下一代编程语言实现一种从源代码到字节代码的编译器,这也是 Java 语言的泛型运行时类型的一种扩展。可通过
eallen@cs.rice.edu与 Eric 联系。
|
对本文的评价
|