必读网 - 人生必读的书

TXT下载此书 | 书籍信息


(双击鼠标开启屏幕滚动,鼠标上下控制速度) 返回首页
选择背景色:
浏览字体:[ ]  
字体颜色: 双击鼠标滚屏: (1最慢,10最快)

程序员修炼之道

_8 Andrew(美)
  预处理器没有内建设施那么好。把它们集成进你的项目可能会很杂乱,而且你使用的其他库没有合约。但它们仍然很有助益;当某个问题以这样的方式被发现时——特别是你本来决不会发现的问题——那几乎像是魔术。
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*Cs;
-- 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 ngth > 0
int i = 1;
// Loop invariant: m = max(arr[0:i-1])
while (i < ngth) {
m = Mx(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
evious = result -- Check the newly
xt = 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页)
22 死程序不说谎
  你是否注意到,有时别人在你自己意识到之前就能觉察到你的事情出了问题。别人的代码也是一样。如果我们的某个程序开始出错,有时库例程会最先抓住它。一个“迷途的”指针也许已经致使我们用无意义的内容覆写了某个文件句柄。对read的下一次调用将会抓住它。或许缓冲区越界已经把我们要用于检测分配多少内存的计数器变成了垃圾。也许我们对malloc的调用将会失败。数百万条之前的某个逻辑错误意味着某个case语句的选择开关不再是预期的1、2或3。我们将会命中default情况(这是为什么每个case/switch语句都需要有default子句的原因之一——我们想要知道何时发生了“不可能”的事情)。
  我们很容易掉进“它不可能发生”这样一种心理状态。我们中的大多数人编写的代码都不检查文件是否能成功关闭,或者某个跟踪语句是否已按照我们的预期写出。而如果所有的事情都能如我们所愿,我们很可能就不需要那么做——这些代码在任何正常的条件都不会失败。但我们是在防卫性地编程,我们在程序的其他部分中查找破坏堆栈的“淘气指针”,我们在检查确实加载了共享库的正确版本。
  所有的错误都能为你提供信息。你可以让自己相信错误不可能发生,并选择忽略它。但与此相反,注重实效的程序员告诉自己,如果有一个错误,就说明非常、非常糟糕的事情已经发生了。
提示32
Crash Early
早崩溃
要崩溃,不要破坏(trash)
  尽早检测问题的好处之一是你可以更早崩溃。而有许多时候,让你的程序崩溃是你的最佳选择。其他的办法可以是继续执行、把坏数据写到某个极其重要的数据库或是命令洗衣机进入其第二十次连续的转动周期。
  Java语言和库已经采用了这一哲学。当意料之外的某件事情在runtime系统中发生时,它会抛出RuntimeException。如果没有被捕捉,这个异常就会渗透到程序的顶部,致使其中止,并显示栈踪迹。
  你可以在别的语言中做相同的事情。如果没有异常机制,或是你的库不抛出异常,那么就确保你自己对错误进行了处理。在C语言中,对于这一目的,宏可能非常有用:
#define CHECK(LINE, EXPECTED) \
{ int rc = LINE; \
if (rc != EXPECTED) \
ut_abort(__FILE__, __LINE__, #LINE, rc, EXPECTED); }
void ut_abort(char *file, int ln, char *line, int rc, int exp) {
fprintf(stderr, "%s line %d
'%s': expected %d, got %d
",
file, ln, line, exp, rc);
exit(1);
}
  然后你可以这样包装决不应该失败的调用:
CHECK(stat("/tmp", &stat_buff), 0);
  如果它失败了,你就会得到写到stderr的消息:
source.c line 19
'stat("/tmp", &stat_buff)': expected 0, got -1
  显然,有时简单地退出运行中的程序并不合适。你申请的资源可能没有释放,或者你可能要写出日志消息,清理打开的事务,或与其他进程交互。我们在“何时使用异常”(125页)中讨论的技术在此能对你有帮助。但是,基本的原则是一样的——当你的代码发现,某件被认为不可能发生的事情已经发生时,你的程序就不再有存活能力。从此时开始,它所做的任何事情都会变得可疑,所以要尽快终止它。死程序带来的危害通常比有疾患的程序要小得多。
相关内容:
l 按合约设计,109页
l 何时使用异常,125页
23 断言式编程
在自责中有一种满足感。当我们责备自己时,会觉得再没人有权责备我们。
  ——奥斯卡?王尔德:《多里安?格雷的画像》
  每一个程序员似乎都必须在其职业生涯的早期记住一段曼特罗(mantra)。它是计算技术的基本原则,是我们学着应用于需求、设计、代码、注释——也就是我们所做的每一件事情——的核心信仰。那就是:
这决不会发生……
  “这些代码不会被用上30年,所以用两位数字表示日期没问题。”“这个应用决不会在国外使用,那么为什么要使其国际化?”“count不可能为负。”“这个printf不可能失败。”
  我们不要这样自我欺骗,特别是在编码时。
提示33
If It Can’t Happen, Use Assertions to Ensure That It Won’t
如果它不可能发生,用断言确保它不会发生
  无论何时你发现自己在思考“但那当然不可能发生”,增加代码检查它。最容易的办法是使用断言。在大多数C和C++实现中,你都能找到某种形式的检查布尔条件的assert或_assert宏。这些宏是无价的财富。如果传入你的过程的指针决不应该是NULL,那么就检查它:
void writeString(char *string) {
assert(string != NULL);
...
  对于算法的操作,断言也是有用的检查。也许你编写了一个聪明的排序算法。检查它是否能工作:
for (int i = 0; i < num_entries-1; i++) {
assert(sorted[i] <= sorted[i+1]);
}
  当然,传给断言的条件不应该有副作用(参见124页的方框)。还要记住断言可能会在编译时被关闭——决不要把必须执行的代码放在assert中。
  不要用断言代替真正的错误处理。断言检查的是决不应该发生的事情:你不会想编写这样的代码:
printf("Enter 'Y' or 'N': ");
ch = getchar();
assert((ch == 'Y') || (ch == 'N')); /* bad idea! */
  而且,提供给你的assert宏会在断言失败时调用exit,并不意味着你编写的版本就应该这么做。如果你需要释放资源,就让断言失败生成异常、longjump到某个退出点、或是调用错误处理器。要确保你在终止前的几毫秒内执行的代码不依赖最初触发断言失败的信息。
让断言开着
  有一个由编写编译器和语言环境的人传播的、关于断言的常见误解。就是像这样的说法:
  断言给代码增加了一些开销。因为它们检查的是决不应该发生的事情,所以只会由代码中的bug触发。一旦代码经过了测试并发布出去,它们就不再需要存在,应该被关闭,以使代码运行得更快。断言是一种调试设施。
  这里有两个明显错误的假定。首先,他们假定测试能找到所有的bug。现实的情况是,对于任何复杂的程序,你甚至不大可能测试你的代码执行路径的排列数的极小一部分(参见“无情的测试”,245页)。其次,乐观主义者们忘记了你的程序运行在一个危险的世界上。在测试过程中,老鼠可能不会噬咬通信电缆、某个玩游戏的人不会耗尽内存、日志文件不会塞满硬盘。这些事情可能会在你的程序运行在实际工作环境中时发生。你的第一条防线是检查任何可能的错误,第二条防线是使用断言设法检测你疏漏的错误。
  在你把程序交付使用时关闭断言就像是因为你曾经成功过,就不用保护网去走钢丝。那样做有极大的价值,但却难以获得人身保险。
  即使你确实有性能问题,也只关闭那些真的有很大影响的断言。上面的排序例子
断言与副作用
  如果我们增加的错误检测代码实际上却制造了新的错误,那是一件让人尴尬的事情。如果对条件的计算有副作用,这样的事情可能会在使用断言时发生。例如,在Java中,像下面这样编写代码,不是个好主意:
while (smoreElements () {
Test.ASSERT(xtElements() != null);
object obj = xtElement();
// ....
}
  ASSERT中的.nextElement()调用有副作用:它会让迭代器越过正在读取的元素,这样循环就会只处理集合中的一半元素。这样编写代码会更好:
while (smoreElements()) {
object obj = xtElement();
Test.ASSERT(obj != null);
//....
}
  这个问题是一种“海森堡虫子”(Heisenbug)——调试改变了被调试系统的行为(参见[URL 52])。
也许是你的应用的关键部分,也许需要很快才行。增加检查意味着又一次通过数据,这可能让人不能接受。让那个检查成为可选的,但让其余的留下来。
相关部分:
l 调试,90页
l 按合约设计,109页
l 怎样配平资源,129页
l 靠巧合编程,172页
练习
19. 一次快速的真实性检查。下面这些“不可能”的事情中,那些可能发生?  (解答在290页)
1. 一个月少于28天
2. stat(“.”, &sb) == -1 (也就是,无法访问当前目录)
3. 在C++里:a = 2; b = 3; if (a + b != 5) exit(1);
4. 内角和不等于180°的三角形。
5. 没有60秒的一分钟
6. 在Java中:(a + 1) <= a
20. 为Java开发一个简单的断言检查类。  (解答在291页)
24 何时使用异常
  在“死程序不说谎”(120页)中,我们提出,检查每一个可能的错误——特别是意料之外的错误——是一种良好的实践。但是,在实践中这可能会把我们引向相当丑陋的代码;你的程序的正常逻辑最后可能会被错误处理完全遮蔽,如果你赞成“例程必须有单个return语句”的编程学派(我们不赞成),情况就更是如此。我们见过看上去像这样的代码:
retcode = OK;
if (ad(name) != OK) {
retcode = BAD_READ;
返回书籍页