程序员修炼之道-8

预处理器没有内建设施那么好。把它们集成进你的项目可能会很杂乱,而且你使用的其他库没有合约。但它们仍然很有助益;当某个问题以这样的方式被发现时——特别是你本来决不会发现的问题——那几乎像是魔术。DBC与早崩溃  DBC相当符合我们关于早崩溃的概念(参见“死程序不说谎”,120页)。假定你有一个计算平方根的方法(比如在Eiffel的DOUBLE类中)。它需要一个前条件,把参数域限制为正数。Eiffel的前条件通过关键字require声明,后条件通过ensure声明,所以你可以编写:sqrt: DOUBLE is-- Square root routinerequiresqrt_arg_must_be_positive: Current >= 0;--- ...--- calculate square root here--- ...ensure((Result*Result) - Current).abs 0int 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 isrequireitem /= Void -- '/=' is 'not equal'.deferred -- Abstract base class.ensureevious = result -- Check the newlyxt = result -- added node's links.find_item(item) = result -- Should find it.End15. 下面,让我们试一试一个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子句的原因之一——我们想要知道何时发生了“不可能”的事情)。  我们很容易掉进“它不可能发生”这样一种心理状态。我们中的大多数人编写的代码都不检查文件是否能成功关闭,或者某个跟踪语句是否已按照我们的预期写出。而如果所有的事情都能如我们所愿,我们很可能就不需要那么做——这些代码在任何正常的条件都不会失败。但我们是在防卫性地编程,我们在程序的其他部分中查找破坏堆栈的“淘气指针”,我们在检查确实加载了共享库的正确版本。  所有的错误都能为你提供信息。你可以让自己相信错误不可能发生,并选择忽略它。但与此相反,注重实效的程序员告诉自己,如果有一个错误,就说明非常、非常糟糕的事情已经发生了。提示32Crash 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不可能失败。”  我们不要这样自我欺骗,特别是在编码时。提示33If 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) <= a20. 为Java开发一个简单的断言检查类。  (解答在291页)24 何时使用异常  在“死程序不说谎”(120页)中,我们提出,检查每一个可能的错误——特别是意料之外的错误——是一种良好的实践。但是,在实践中这可能会把我们引向相当丑陋的代码;你的程序的正常逻辑最后可能会被错误处理完全遮蔽,如果你赞成“例程必须有单个return语句”的编程学派(我们不赞成),情况就更是如此。我们见过看上去像这样的代码:retcode = OK;if (ad(name) != OK) {retcode = BAD_READ;

上一章 下一章
目录
打赏
夜间
日间
设置
10
正序
倒序
程序员修炼之道
程序员修炼之道-2
程序员修炼之道-3
程序员修炼之道-4
程序员修炼之道-5
程序员修炼之道-6
程序员修炼之道-7
程序员修炼之道-8
程序员修炼之道-9
程序员修炼之道-10
需支付:0 金币
开通VIP小说免费看
金币购买
您的金币 0

分享给朋友

程序员修炼之道
程序员修炼之道
获月票 0
  • x 1
  • x 2
  • x 3
  • x 4
  • x 5
  • x 6
  • 爱心猫粮
    1金币
  • 南瓜喵
    10金币
  • 喵喵玩具
    50金币
  • 喵喵毛线
    88金币
  • 喵喵项圈
    100金币
  • 喵喵手纸
    200金币
  • 喵喵跑车
    520金币
  • 喵喵别墅
    1314金币
网站统计