实现DBC
使用DBC的最大好处也许是它迫使需求与保证的问题走到前台来。在设计时简单地列举输入域的范围是什么、边界条件是什么、例程允诺交付什么——或者,更重要的,它不允诺交付什么——是向着编写更好的软件的一次飞跃。不对这些事项作出陈述,你就回到了靠巧合编程(参见172页),那是许多项目开始、结束、失败的地方。
如果语言不在代码中支持DBC,你也许就只能走这么远了——这并不太坏。毕竟,DBC是一种设计技术。即使没有自动检查,你也可以把合约作为注释放在代码中,并仍然能够得到非常实际的好处。至少,在遇到麻烦时,用注释表示的合约给了你一个着手的地方。
断言
尽管用文档记载这些假定是一个了不起的开始,让编译器为你检查你的合约,你能够获得大得多的好处。在有些语言中,你可以通过断言(参见断言式编程,122页)对此进行部分的模拟。为何只是部分的?你不能用断言做DBC能做的每一件事情吗?
遗憾的是,答案是“不能”。首先,断言不能沿着继承层次向下遗传。这就意味着,如果你重新定义了某个具有合约的基类方法,实现该合约的断言不会被正确调用(除非你在新代码中手工复制它们)。在退出每个方法之前,你必须记得手工调用类不变项(以及所有的基类不变项)。根本的问题是合约不会自动实施。
还有,不存在内建的“老”值概念。也就是,与存在于方法入口处的值相同的值。如果你使用断言实施合约,你必须给前条件增加代码,保存你想要在后条件中使用的任何信息。把它与iContract比较一下,其后条件可以引用“variable@pre”;或者与Eiffel比较一下,它支持“老表达式”。
最后,runtime系统和库的设计不支持合约,所以它们的调用不会被检查。这是一个很大的损失,因为大多数问题常常是在你的代码和它使用的库之间的边界上检测到的(更详细的讨论,参见死程序不说谎,120页)。
语言支持
有内建的DBC支持的语言(比如Eiffel和Sather[URL 12])自动在编译器和runtime系统中检查前条件和后条件。在这样的情况下,你能获得最大的好处,因为所有的代码库(还有库函数)必须遵守它们的合约。
但像C、C++和Java这样的更流行的语言呢?对于这些语言,有一些预处理器能够处理作为特殊注释嵌入在原始源码中的合约。预处理器会把这些注释展开成检验断言的代码。
对于C和C++,你可以研究一下Nana[URL 18]。Nana不处理继承,但它却能以一种新颖的方式、使用调试器在运行时监控断言。
对于Java,可以使用iContract[URL 17]。它读取(JavaDoc形式的)注释,生成新的包含了断言逻辑的源文件。
预处理器没有内建设施那么好。把它们集成进你的项目可能会很杂乱,而且你使用的其他库没有合约。但它们仍然很有助益;当某个问题以这样的方式被发现时——特别是你本来决不会发现的问题——那几乎像是魔术。
DBC与早崩溃
DBC相当符合我们关于早崩溃的概念(参见“死程序不说谎”,120页)。假定你有一个计算平方根的方法(比如在Eiffel的DOUBLE类中)。它需要一个前条件,把参数域限制为正数。Eiffel的前条件通过关键字require声明,后条件通过ensure声明,所以你可以编写:
sqrt: DOUBLE is
-- Square root routine
require
sqrt_arg_must_be_positive: Current >= 0;
--- ...
--- calculate square root here
--- ...
ensure
((Result*Result) - Current).abs <= epsilon*Current.abs;
-- Result should be within error tolerance
end;
|
谁负责? 谁负责检查前条件,是调用者,还是被调用的例程?如果作为语言的一部分实现,答案是两者都不是:前条件是在调用者调用例程之后,但在进入例程自身之前,在幕后测试的。因而如果要对参数进行任何显式的检查,就必须由调用者来完成,因为例程自身永远也不会看到违反了其前条件的参数。(对于没有内建支持的语言,你需要用检查这些断言的“前言”(preamble)和/或“后文”(postamble)把被调用的例程括起来) 考虑一个程序,它从控制台读取数字,(通过调用sqrt)计算其平方根,并打印结果。sqrt函数有一个前条件——其参数不能为负。如果用户在控制台上输入负数,要由调用代码确保它不会被传给sqrt。该调用代码有许多选择:它可以终止,可以发出警告并读取另外的数,也可以把这个数变成正数,并在sqrt返回的结果后面附加一个“i”。无论其选择是什么,这都肯定不是sqrt的问题。 通过在sqrt例程的前条件中表示平方根函数的参数域,你把保证正确性的负担转交给了调用者——本应如此。随后你可以在知道了其输入会落在有效范围内的前提下,安全地设计sqrt例程。 |
如果你用于计算平方根的算法失败了(或不在规定的错误容忍程度之内),你会得到一条错误消息,以及用于告诉你调用链的栈踪迹(stack trace)。
如果你传给sqrt一个负参数,Eiffel runtime会打印错误“sqrt_arg_must_be_positive”,还有栈踪迹。这比像Java、C和C++等语言中的情况要好,在这些语言那里,把负数传给sqrt,返回的是特殊值NaN(Not a Number)。要等到你随后在程序中试图对NaN进行某种运算时,你才会得到让你吃惊的结果。
通过早崩溃、在问题现场找到和诊断问题要容易得多。
不变项的其他用法
到目前为止,我们已经讨论了适用于单个方法的前条件和后条件,以及应用于类中所有方法的不变项,但使用不变项还有其他一些有用的方式。
循环不变项
在复杂的循环上正确设定边界条件可能会很成问题。循环常有香蕉问题(我知道怎样拼写“banana”,但不知道何时停下来——“bananana…”)、篱笆桩错误(不知道该数桩还是该数空)、以及无处不在的“差一个”错误[URL 52]。
在这些情况下,不变项可以有帮助:循环不变项是对循环的最终目标的陈述,但又进行了一般化,这样在循环执行之前和每次循环迭代时,它都是有效的。你可以把它视为一种微型合约。经典的例子是找出数组中的最大值的例程:
int m = arr[0]; // example assumes arr.length > 0
int i = 1;
// Loop invariant: m = max(arr[0:i-1])
while (i < arr.length) {
m = Math.max(m, arr[i]);
i = i + 1;
}
(arr[m:n]是便捷表示法,意为数组从下标m到n的部分。)不变项在循环运行之前必须为真,循环的主体必须确保它在循环执行时保持为真。这样我们就知道不变项在循环终止时也保持不变,因而我们的结果是有效的。循环不变项可被显式地编写成断言,但作为设计和文档工具,它们也很有用。
语义不变项
你可以使用语义不变项(semantic invariant)表达不可违反的需求,一种“哲学合约”。
我们曾经编写过一个借记卡交易交换程序。一个主要的需求是借记卡用户的同一笔交易不能被两次记录到账户中。换句话说,不管发生何种方式的失败,结果都应该是:不处理交易,而不是处理重复的交易。
这个简单的法则,直接由需求驱动,被证明非常有助于处理复杂的错误恢复情况,并且可以在许多领域中指导详细的设计和实现。
一定不要把固定的需求、不可违反的法则与那些仅仅是政策(policiy)的东西混为一谈,后者可能会随着新的管理制度的出台而改变。这就是我们为什么要使用术语“语义不变项”的原因——它必须是事物的确切含义的中心,而不受反复无常的政策的支配(后者是更为动态的商业规则的用途所在)。
当你发现合格的需求时,确保让它成为你制作的无论什么文档的一个众所周知的部分——无论它是一式三份签署的需求文档中的圆点列表,还是只是每个人都能看到的公共白板上的重要通知。设法清晰、无歧义地陈述它。例如,在借记卡的例子中,我们可以写:
出错时要偏向消费者
这是清楚、简洁、无歧义的陈述,适用于系统的许多不同的区域。它是我们与系统的所有用户之间的合约,是我们对行为的保证。
动态合约与代理
直到现在为止,我们一直把合约作为固定的、不可改变的规范加以谈论。但在自治代理(autonomous agent)的领域中,情况并不一定是这样。按照“自治”的定义,代理有拒绝它们不想接受的请求的自由——“我无法提供那个,但如果你给我这个,那么我可以提供另外的某样东西。”
无疑,任何依赖于代理技术的系统对合约协商的依赖都是至关紧要的——即使它们是动态生成的。
设想一下,通过足够的“能够互相磋商合约、以实现某个目标”的组件和代理,我们也许就能解决软件生产率危机:让软件为我们解决它。
但如果我们不能手工使用合约,我们也无法自动使用它们。所以下次你设计软件时,也要设计它的合约。
相关内容:
l 正交性,34页
l 死程序不说谎,120页
l 断言式编程,122页
l 怎样配平资源,129页
l 解耦与得墨忒耳法则,138页
l 时间耦合,150页
l 靠巧合编程,172页
l 易于测试的代码,189页
l 注重实效的团队,224页
挑战
l 思考这样的问题:如果DBC如此强大,它为何没有得到更广泛的使用?制定合约困难吗?它是否会让你思考你本来想先放在一边的问题?它迫使你思考吗?显然,这是一个危险的工具!
练习
14. 好合约有什么特征?任何人都可以增加前条件和后条件,但那是否会给你带来任何好处?更糟糕的是,它们实际上带来的坏处是否会大过好处?对于下面的以及练习15和16中的例子,确定所规定的合约是好、是坏、还是很糟糕,并解释为什么。
首先,让我们看一个Eiffel例子。我们有一个用于把STRING添加到双向链接的循环链表中的例程(别忘了前条件用require标注,后条件用ensure标注)。 (解答在288页)
-- Add an item to a doubly linked list,
-- and return the newly created NODE.
add_item (item : STRING) : NODE is
require
item /= Void -- '/=' is 'not equal'.
deferred -- Abstract base class.
ensure
result.next.previous = result -- Check the newly
result.previous.next = result -- added node's links.
find_item(item) = result -- Should find it.
End
15. 下面,让我们试一试一个Java的例子——与练习14中的例子有点类似。insertNumber把整数插入有序列表中。前条件和后条件的标注方式与iContract(参见[URL 17])一样。 (解答在288页)
private int data[];
/**
* @post data[index-1] < data[index] &&
* data[index] == aValue
*/
public Node insertNumber (final int aValue)
{
int index = findPlaceToInsert(aValue);
...
16. 下面的代码段来自Java的栈类。这是好合约吗? (解答在289页)
/**
* @pre anItem != null // Require real data
* @post pop() == anItem // Verify that it's
* // on the stack
*/
public void push(final String anItem)
17. DBC的经典例子(如练习14-16中的例子)给出的是某种ADT(Abstract Data Type)的实现——栈或队列就是典型的例子。但并没有多少人真的会编写这种低级的类。
所以,这个练习的题目是,设计一个厨用搅拌机接口。它最终将是一个基于Web、适用于Internet、CORBA化的搅拌机,但现在我们只需要一个接口来控制它。它有十挡速率设置(0表示关机)。你不能在它空的时候进行操作,而且你只能一挡一挡地改变速率(也就是说,可以从0到1,从1到2,但不能从0到2)。
下面是各个方法。增加适当的前条件、后条件和不变项。 (解答在289页)
int getSpeed()
void setSpeed(int x)
boolean isFull()
void fill()
void empty()
18. 在0, 5, 10, 15, …,100序列中有多少个数? (解答在290页)







