赞
踩
程序员修炼之道
1 我的源码让猫给吃了
2 软件的熵
3 石头汤与煮青蛙
4 足够好的软件
5 你的知识资产
6 交流!
7 重复的危害
8 正交性
9 可撤消性
10 曳光弹
11 原型与便笺
12 领域语言
13 估算
14 纯文本的威力
15 shell游戏
16 强力编辑
17 源码控制
18 调试
19 文本操纵
20 代码生成器
21 按合约设计
22 死程序不说谎
23 断言式编程
24 何时使用异常
25怎样配平资源
1 我的源码让猫给吃了
注重实效的程序员的特征是什么?
我们觉得是他们处理问题、寻求解决方案时的态度、风格、哲学。他们能够越出直接的问题去思考,总是设法把问题放在更大的语境中,总是设法注意更大的图景。毕竟,没有这样的更大的语境,你又怎能注重实效?你又怎能做出明智的妥协和有见识的决策?
他们成功的另一关键是他们对他们所做的每件事情负责,关于这一点,我们将在“我的源码让猫给吃了”中加以讨论。因为负责,注重实效的程序员不会坐视他们的项目土崩瓦解。在“软件的熵”中,我们将告诉你怎样使你的项目保持整洁。
大多数人发现自己很难接受变化,有时是出于好的理由,有时只是因为固有的惰性。在“石头汤与煮青蛙”中,我们将考察一种促成变化的策略,并(出于对平衡的兴趣)讲述一个忽视渐变危险的两栖动物的警世传说。
理解你的工作的语境的好处之一是,了解你的软件必须有多好变得更容易了。有时接近完美是惟一的选择,但常常会涉及各种权衡。我们将在“足够好的软件”中探究这一问题。
当然,你需要拥有广泛的知识和经验基础才能赢得这一切。学习是一个持续不断的过程。在“你的知识资产”中,我们将讨论一些策略,让你“开足马力”。
最后,我们没有人生活在真空中。我们都要花大量时间与他人打交道。在“交流!”中列出了能让我们更好地做到这一点的几种途径。 注重实效的编程源于注重实效的思考的哲学。本章将为这种哲学设立基础。
1 我的源码让猫给吃了 在所有弱点中,最大的弱点就是害怕暴露弱点。 ——J. B. Bossuet, Politics from Holy Writ, 1709
依据你的职业发展、你的项目和你每天的工作,为你自己和你的行为负责这样一种观念,是注重实效的哲学的一块基石。注重实效的程序员对他或她自己的职业生涯负责,并且不害怕承认无知或错误。这肯定并非是编程最令人愉悦的方面,但它肯定会发生——即使是在最好的项目中。尽管有彻底的测试、良好的文档以及足够的自动化,事情还是会出错。交付晚了,出现了未曾预见到的技术问题。
发生这样的事情,我们要设法尽可能职业地处理它们。这意味着诚实和坦率。我们可以为我们的能力自豪,但对于我们的缺点——还有我们的无知和我们的错误——我们必须诚实。
负责
责任是你主动担负的东西。你承诺确保某件事情正确完成,但你不一定能直接控制事情的每一个方面。除了尽你所能以外,你必须分析风险是否超出了你的控制。对于不可能做到的事情或是风险太大的事情,你有权不去为之负责。你必须基于你自己的道德准则和判断来做出决定。
如果你确实同意要为某个结果负责,你就应切实负起责任。当你犯错误(就如同我们所有人都会犯错误一样)、或是判断失误时,诚实地承认它,并设法给出各种选择。不要责备别人或别的东西,或是拼凑借口。不要把所有问题都归咎于供应商、编程语言、管理部门、或是你的同事。也许他(它)们全体或是某几方在其中扮演了某种角色,但你可以选择提供解决方案,而非寻找借口。
如果存在供应商不能按时供货的风险,你应该预先制定一份应急计划。如果磁盘垮了——带走了你的所有源码——而你没有做备份,那是你的错。告诉你的老板“我的源码让猫给吃了”也无法改变这一点。
提示3 Provide Options, Don’t Make Lame Excuses 提供各种选择,不要找蹩脚的借口
在你走向任何人、告诉他们为何某事做不到、为何耽搁、为何出问题之前,先停下来,听一听你心里的声音。与你的显示器上的橡皮鸭交谈,或是与猫交谈。你的辩解听起来合理,还是愚蠢?在你老板听来又是怎样?
在你的头脑里把谈话预演一遍。其他人可能会说什么?他们是否会问:“你试了这个吗……”,或是“你没有考虑那个吗?”你将怎样回答?在你去告诉他们坏消息之前,是否还有其他你可以再试一试的办法?有时,你其实知道他们会说什么,所以还是不要给他们添麻烦吧。 要提供各种选择,而不是找借口。不要说事情做不到;要说明能够做什么来挽回局面。必须把代码扔掉?给他们讲授重构的价值(参见重构,184页)。你要花时间建立原型(prototyping),以确定最好的继续前进的方式(参见原型与便笺,53页)?你要引入更好的测试(参见易于测试的代码,189页;以及无情的测试,237页)或自动化(参见无处不在的自动化,230页),以防止问题再度发生?又或许你需要额外的资源。不要害怕提出要求,也不要害怕承认你需要帮助。
在你大声说出它们之前,先设法把蹩脚的借口清除出去。如果你必须说,就先对你的猫说。反正,如果小蒂德尔丝(Tiddles,BBC在1969~1974年播出的喜剧节目“Monty Python's Flying Circus”中的著名小母猫——译注)要承受指责……
2 软件的熵
尽管软件开发几乎不受任何物理定律的约束,熵(entropy)对我们的影响却很大。熵是一个来自物理学的概念,指的是某个系统中的“无序”的总量。遗憾的是,热力学定律保证了宇宙中的熵倾向于最大化。当软件中的无序增长时,程序员们称之为“软件腐烂”(software rot)。
有许多因素可以促生软件腐烂。其中最重要的一个似乎是开发项目时的心理(或文化)。即使你的团队只有你一个人,你开发项目时的心理也可能是非常微妙的事情。尽管制定了最好的计划,拥有最好的开发者,项目在其生命期中仍可能遭遇毁灭和衰败。而另外有一些项目,尽管遇到巨大的困难和接连而来的挫折,却成功地击败自然的无序倾向,设法取得了相当好的结果。
是什么造成了这样的差异?
在市区,有些建筑漂亮而整洁,而另一些却是破败不堪的“废弃船只”。为什么?犯罪和城市衰退领域的研究者发现了一种迷人的触发机制,一种能够很快将整洁、完整和有人居住的建筑变为破败的废弃物的机制[WK82]。
破窗户。 一扇破窗户,只要有那么一段时间不修理,就会渐渐给建筑的居民带来一种废弃感——一种职权部门不关心这座建筑的感觉。于是又一扇窗户破了。人们开始乱扔垃圾。出现了乱涂乱画。严重的结构损坏开始了。在相对较短的一段时间里,建筑就被损毁得超出了业主愿意修理的程度,而废弃感变成了现实。 “破窗户理论”启发了纽约和其他大城市的警察部门,他们对一些轻微的案件严加处理,以防止大案的发生。这起了作用:管束破窗户、乱涂乱画和其他轻微违法事件减少了严重罪案的发生。
提示4 Don’t Live with Broken Windows 不要容忍破窗户 不要留着“破窗户”(低劣的设计、错误决策、或是糟糕的代码)不修。
发现一个就修一个。如果没有足够的时间进行适当的修理,就用木板把它钉起来。或许你可以把出问题的代码放入注释(comment out),或是显示“未实现”消息,或是用虚设的数据(dummy data)加以替代。采取某种行动防止进一步的损坏,并说明情势处在你的控制之下。
我们看到过整洁、运行良好的系统,一旦窗户开始破裂,就相当迅速地恶化。还有其他一些因素能够促生软件腐烂,我们将在别处探讨它们,但与其他任何因素相比,置之不理都会更快地加速腐烂的进程。
你也许在想,没有人有时间到处清理项目的所有碎玻璃。如果你继续这么想,你就最好计划找一个大型垃圾罐,或是搬到别处去。不要让熵赢得胜利。
灭火
作为对照,让我们讲述Andy的一个熟人的故事。他是一个富得让人讨厌的富翁,拥有一所完美、漂亮的房子,里面满是无价的古董、艺术品,以及诸如此类的东西。有一天,一幅挂毯挂得离他的卧室壁炉太近了一点,着了火。消防人员冲进来救火——和他的房子。但他们拖着粗大、肮脏的消防水管冲到房间门口却停住了——火在咆哮——他们要在前门和着火处之间铺上垫子。
他们不想弄脏地毯。
这的确是一个极端的事例,但我们必须以这样的方式对待软件。一扇破窗户——一段设计低劣的代码、团队必须在整个项目开发过程中加以忍受的一项糟糕的管理决策——就足以使项目开始衰败。如果你发现自己在有好些破窗户的项目里工作,会很容易产生这样的想法:“这些代码的其余部分也是垃圾,我只要照着做就行了。”项目在这之前是否一直很好,并没有什么关系。在最初得出“破窗户理论”的一项实验中,一辆废弃的轿车放了一个星期,无人理睬。而一旦有一扇窗户被打破,数小时之内车上的设备就被抢夺一空,车也被翻了个底朝天。
按照同样的道理,如果你发现你所在团队和项目的代码十分漂亮——编写整洁、设计良好,并且很优雅——你就很可能会格外注意不去把它弄脏,就和那些消防员一样。即使有火在咆哮(最后期限、发布日期、会展演示,等等),你也不会想成为第一个弄脏东西的人。 相关内容: l 石头汤与煮青蛙,7页 l 重构,184页 l 注重实效的团队,224页 挑战 l 通过调查你周边的计算“环境”,帮助增强你的团队的能力。选择两或三扇“破窗户”,并与你的同事讨论问题何在,以及怎样修理它们。 l 你能否说出某扇窗户是何时破的?你的反应是什么?如果它是他人的决策所致,或者是管理部门的指示,你能做些什么?
3 石头汤与煮青蛙
三个士兵从战场返回家乡,在路上饿了。他们看见前面有村庄,就来了精神——他们相信村民会给他们一顿饭吃。但当他们到达那里,却发现门锁着,窗户也关着。经历了多年战乱,村民们粮食匮乏,并把他们有的一点粮食藏了起来。
士兵们并未气馁,他们煮开一锅水,小心地把三块石头放进去。吃惊的村民们走出来望着他们。
“这是石头汤。”士兵们解释说。“就放石头吗?”村民们问。“一点没错——但有人说加一些胡萝卜味道更好……”一个村民跑开了,又很快带着他储藏的一篮胡萝卜跑回来。
几分钟之后,村民们又问:“就是这些了吗?”
“哦,”士兵们说:“几个土豆会让汤更实在。”又一个村民跑开了。 接下来的一小时,士兵们列举了更多让汤更鲜美的配料:牛肉、韭菜、盐,还有香菜。每次都会有一个不同的村民跑回去搜寻自己的私人储藏品。
最后他们煮出了一大锅热气腾腾的汤。士兵们拿掉石头,和所有村民一起享用了一顿美餐,这是几个月以来他们所有人第一次吃饱饭。
在石头汤的故事里有两层寓意。士兵戏弄了村民,他们利用村民的好奇,从他们那里弄到了食物。但更重要的是,士兵充当催化剂,把村民团结起来,和他们一起做到了他们自己本来做不到的事情——一项协作的成果。最后每个人都是赢家。
你常常也可以效仿这些士兵。
在有些情况下,你也许确切地知道需要做什么,以及怎样去做。整个系统就在你的眼前——你知道它是对的。但请求许可去处理整个事情,你会遇到拖延和漠然。大家要设立委员会,预算需要批准,事情会变得复杂化。每个人都会护卫他们自己的资源。有时候,这叫做“启动杂役”(start-up fatigue)。
这正是拿出石头的时候。设计出你可以合理要求的东西,好好开发它。一旦完成,就拿给大家看,让他们大吃一惊。然后说:“要是我们增加……可能就会更好。”假装那并不重要。坐回椅子上,等着他们开始要你增加你本来就想要的功能。人们发现,参与正在发生的成功要更容易。让他们瞥见未来,你就能让他们聚集在你周围[1]。
提示5 Be a Catalyst for Change 做变化的催化剂 村民的角度
另一方面,石头汤的故事也是关于温和而渐进的欺骗的故事。它讲述的是过于集中的注意力。村民想着石头,忘了世界的其余部分。我们都是这样,每一天。事情会悄悄爬到我们身上。
我们都看见过这样的症状。项目慢慢地、不可改变地完全失去控制。大多数软件灾难都是从微不足道的小事情开始的,大多数项目的拖延都是一天一天发生的。系统一个特性一个特性地偏离其规范,一个又一个的补丁被打到某段代码上,直到最初的代码一点没有留下。常常是小事情的累积破坏了士气和团队。
提示6 Remember the Big Picture 记住大图景
我们没有做过这个——真的,但有人说,如果你抓一只青蛙放进沸水里,它会一下子跳出来。但是,如果你把青蛙放进冷水里,然后慢慢加热,青蛙不会注意到温度的缓慢变化,会呆在锅里,直到被煮熟。
注意,青蛙的问题与第2节讨论的破窗户问题不同。在破窗户理论中,人们失去与熵战斗的意愿,是因为他们觉察到没有人会在意。而青蛙只是没有注意到变化。
不要像青蛙一样。留心大图景。要持续不断地观察周围发生的事情,而不只是你自己在做的事情。 相关内容: l 软件的熵,4页 l 靠巧合编程,172页 l 重构,184页 l 需求之坑,202页 l 注重实效的团队,224页 挑战 l 在评阅本书的草稿时,John Lakos提出这样一个问题:士兵渐进地欺骗村民,但他们所催生的变化对村民完全有利。但是,渐进地欺骗青蛙,你是在加害于它。当你设法催生变化时,你能否确定你是在做石头汤还是青蛙汤?决策是主观的还是客观的?
4 足够好的软件 欲求更好,常把好事变糟。 ——李尔王 1.4 有一个(有点)老的笑话,说一家美国公司向一家日本制造商订购100 000片集成电路。规格说明中有次品率:10 000片中只能有1片。几周过后订货到了:一个大盒子,里面装有数千片IC,还有一个小盒子,里面只装有10片IC。在小盒子上有一个标签,上面写着:“这些是次品”。 要是我们真的能这样控制质量就好了。但现实世界不会让我们制作出十分完美的产品,特别是不会有无错的软件。时间、技术和急躁都在合谋反对我们。 但是,这并不一定就让人气馁。如Ed Yourdon发表在IEEE Software上的一篇文章[You95]所描述的,你可以训练你自己,编写出足够好的软件——对你的用户、对未来的维护者、对你自己内心的安宁来说足够好。你会发现,你变得更多产,而你的用户也会更加高兴。你也许还会发现,因为“孵化期”更短,你的程序实际上更好了。 在继续前进之前,我们需要对我们将要说的话进行限定。短语“足够好”并非意味着不整洁或制作糟糕的代码。所有系统都必须满足其用户的需求,才能取得成功。我们只是在宣扬,应该给用户以机会,让他们参与决定你所制作的东西何时已足够好。 让你的用户参与权衡 通常你是为别人编写软件。你常常需要记得从他们那里获取需求[2]。但你是否常问他们,他们想要他们的软件有多好?有时候选择并不存在。如果你的工作对象是心脏起搏器、航天飞机、或是将被广泛传播的底层库,需求就会更苛刻,你的选择就更有限。但是,如果你的工作对象是全新的产品,你就会有不同的约束。市场人员有需要信守的承诺,最终用户也许已基于交付时间表制定了各种计划,而你的公司肯定有现金流方面的约束。无视这些用户的需求,一味地给程序增加新特性,或是一次又一次润饰代码,这不是有职业素养的做法。我们不是在提倡慌张:许诺不可能兑现的时间标度(time scale),为赶上最后期限而削减基本的工程内容,这些同样不是有职业素养的做法。 你所制作的系统的范围和质量应该作为系统需求的一部分规定下来。 提示7 Make Quality a Requirements Issue 使质量成为需求问题 你常常会处在须要进行权衡的情形中。让人惊奇的是,许多用户宁愿在今天用上有一些“毛边”的软件,也不愿等待一年后的多媒体版本。许多预算吃紧的IT部门都会同意这样的说法。今天的了不起的软件常常比明天的完美软件更可取。如果你给用户某样东西,让他们及早使用,他们的反馈常常会把你引向更好的最终解决方案(参见曳光弹,48页)。 知道何时止步 在某些方面,编程就像是绘画。你从空白的画布和某些基本原材料开始,通过知识、艺术和技艺的结合去确定用前者做些什么。你勾画出全景,绘制背景,然后填入各种细节。你不时后退一步,用批判的眼光观察你的作品。常常,你会扔掉画布,重新再来。 但艺术家们会告诉你,如果你不懂得应何时止步,所有的辛苦劳作就会遭到毁坏。如果你一层又一层、细节复细节地叠加,绘画就会迷失在绘制之中。 不要因为过度修饰和过于求精而毁损完好的程序。继续前进,让你的代码凭着自己的质量站立一会儿。它也许不完美,但不用担心:它不可能完美(在第6章,171页,我们将讨论在不完美的世界上开发代码的哲学)。 相关内容: l 曳光弹,48页 l 需求之坑,202页 l 注重实效的团队,224页 l 极大的期待,255页 挑战 l 考察你使用的软件工具和操作系统的制造商。你能否发现证据,表明这些公司安于发布他们知道不完美的软件吗?作为用户,你是会(1)等着他们清除所有bug,(2)拥有复杂的软件,并接受某些bug,还是会(3)选择缺陷较少的更简单的软件? l 考虑模块化对软件交付的影响。与以模块化方式设计的系统相比,整体式(monolithic)软件要达到所需质量,花费的时间更多还是更少?你能找到一个商业案例吗? 5 你的知识资产 知识上的投资总能得到最好的回报。 ——本杰明•富兰克林 噢,好样的老富兰克林——从不会想不出精练的说教。为什么,如果我们能够早睡早起,我们就是了不起的程序员——对吗?早起的鸟儿有虫吃,但早起的虫子呢? 然而在这种情况下,Ben确实命中了要害。你的知识和经验是你最重要的职业财富。 遗憾的是,它们是有时效的资产(expiring asset)。随着新技术、语言及环境的出现,你的知识会变得过时。不断变化的市场驱动力也许会使你的经验变得陈旧或无关紧要。考虑到“网年”飞逝的速度,这样的事情可能会非常快地发生。 随着你的知识的价值降低,对你的公司或客户来说,你的价值也在降低。我们想要阻止这样的事情,决不让它发生。 你的知识资产 我们喜欢把程序员所知道的关于计算技术和他们所工作的应用领域的全部事实、以及他们的所有经验视为他们的知识资产(Knowledge Portfolios)。管理知识资产与管理金融资产非常相似: 1. 严肃的投资者定期投资——作为习惯。 2. 多元化是长期成功的关键。 3. 聪明的投资者在保守的投资和高风险、高回报的投资之间平衡他们的资产。 4. 投资者设法低买高卖,以获取最大回报。 5. 应周期性地重新评估和平衡资产。 要在职业生涯中获得成功,你必须运用同样的指导方针管理你的知识资产。 经营你的资产 l 定期投资。就像金融投资一样,你必须定期为你的知识资产投资。即使投资量很小,习惯自身也和总量一样重要。在下一节中将列出一些示范目标。 l 多元化。你知道的不同的事情越多,你就越有价值。作为底线,你需要知道你目前所用的特定技术的各种特性。但不要就此止步。计算技术的面貌变化很快——今天的热门技术明天就可能变得近乎无用(或至少是不再抢手)。你掌握的技术越多,你就越能更好地进行调整,赶上变化。 l 管理风险。从高风险、可能有高回报,到低风险、低回报,技术存在于这样一条谱带上。把你所有的金钱都投入可能突然崩盘的高风险股票并不是一个好主意;你也不应太保守,错过可能的机会。不要把你所有的技术鸡蛋放在一个篮子里。 l 低买高卖。在新兴的技术流行之前学习它可能就和找到被低估的股票一样困难,但所得到的就和那样的股票带来的收益一样。在Java刚出现时学习它可能有风险,但对于现在已步入该领域的顶尖行列的早期采用者,这样做得到了非常大的回报。 l 重新评估和平衡。这是一个非常动荡的行业。你上个月开始研究的热门技术现在也许已像石头一样冰冷。也许你需要重温你有一阵子没有使用的数据库技术。又或许,如果你之前试用过另一种语言,你就会更有可能获得那个新职位…… 在所有这些指导方针中,最重要的也是最简单的: 提示8 Invest Regularly in Your Knowledge Portfolio 定期为你的知识资产投资 目标 关于何时以及增加什么到你的知识资产中,现在你已经拥有了一些指导方针,那么什么是获得智力资本、从而为你的资产提供资金的最佳方式呢?这里有一些建议。 l 每年至少学习一种新语言。不同语言以不同方式解决相同的问题。通过学习若干不同的方法,可以帮助你拓宽你的思维,并避免墨守成规。此外,现在学习许多语言已容易了许多,感谢可从网上自由获取的软件财富(参见267页)。 l 每季度阅读一本技术书籍。书店里摆满了许多书籍,讨论与你当前的项目有关的有趣话题。一旦你养成习惯,就一个月读一本书。在你掌握了你正在使用的技术之后,扩宽范围,阅读一些与你的项目无关的书籍。 l 也要阅读非技术书籍。记住计算机是由人——你在设法满足其需要的人——使用的,这十分重要。不要忘了等式中人这一边。 l 上课。在本地的学院或大学、或是将要来临的下一次会展上寻找有趣的课程。 l 参加本地用户组织。不要只是去听讲,而要主动参与。与世隔绝对你的职业生涯来说可能是致命的;打听一下你们公司以外的人都在做什么。 l 试验不同的环境。如果你只在Windows上工作,就在家玩一玩Unix(可自由获取的Linux就正好)。如果你只用过makefile和编辑器,就试一试IDE,反之亦然。 l 跟上潮流。订阅商务杂志和其他期刊(参见262页的推荐刊物)。选择所涵盖的技术与你当前的项目不同的刊物。 l 上网。想要了解某种新语言或其他技术的各种特性?要了解其他人的相关经验,了解他们使用的特定行话,等等,新闻组是一种很好的方式。上网冲浪,查找论文、商业站点,以及其他任何你可以找到的信息来源。 持续投入十分重要。一旦你熟悉了某种新语言或新技术,继续前进。学习另一种。 是否在某个项目中使用这些技术,或者是否把它们放入你的简历,这并不重要。学习的过程将扩展你的思维,使你向着新的可能性和新的做事方式拓展。思想的“异花授粉”(cross-pollination)十分重要;设法把你学到的东西应用到你当前的项目中。即使你的项目没有使用该技术,你或许也能借鉴一些想法。例如,熟悉了面向对象,你就会用不同的方式编写纯C程序。 学习的机会 于是你狼吞虎咽地阅读,在你的领域,你站在了所有突破性进展的前沿(这不是容易的事情)。有人向你请教一个问题,答案是什么?你连最起码的想法都没有。你坦白地承认了这一点。 不要就此止步,把找到答案视为对你个人的挑战。去请教古鲁(如果在你们的办公室里没有,你应该能在Internet上找到:参见下一页上的方框)。上网搜索。去图书馆。 如果你自己找不到答案,就去找出能找到答案的人。不要把问题搁在那里。与他人交谈可以帮助你建立人际网络,而因为在这个过程中找到了其他不相关问题的解决方案,你也许还会让自己大吃一惊。旧有的资产也在不断增长…… 所有阅读和研究都需要时间,而时间已经很短缺。所以你需要预先规划。让自己在空闲的片刻时间里总有东西可读。花在等医生上的时间是抓紧阅读的好机会——但一定要带上你自己的杂志,否则,你也许会发现自己在翻阅1973年的一篇卷角的关于巴布亚新几内亚的文章。 批判的思考 最后一个要点是,批判地思考你读到的和听到的。你需要确保你的资产中的知识是准确的,并且没有受到供应商或媒体炒作的影响。警惕声称他们的信条提供了惟一答案的狂热者——那或许适用、或许不适用于你和你的项目。 不要低估商业主义的力量。Web搜索引擎把某个页面列在最前面,并不意味着那就是最佳选择;内容供应商可以付钱让自己排在前面。书店在显著位置展示某一本书,也并不意味着那就是一本好书,甚至也不说明那是一本受欢迎的书;它们可能是付了钱才放在那里的。 提示9 Critically Analyze What You Read and Hear 批判地分析你读到的和听到的 遗憾的是,几乎再没有简单的答案了。但拥有大量知识资产,并把批判的分析应用于你将要阅读的技术出版物的洪流,你将能够理解复杂的答案。 与古鲁打交道的礼节与教养 随着Internet在全球普及,古鲁们突然变得像你的Enter键一样贴近。那么,你怎样才能找到一个古鲁,怎样才能找一个古鲁和你交谈呢? 我们找到了一些简单的诀窍。 l 确切地知道你想要问什么,并尽量明确具体。 l 小心而得体地组织你的问题。记住你是在请求帮助;不要显得好像是在要求对方回答。 l 组织好问题之后,停下来,再找找答案。选出一些关键字,搜索Web。查找适当的FAQ(常见问题的解答列表)。 l 决定你是想公开提问还是私下提问。Usenet新闻组是与专家会面的美妙场所,在那里可以讨论几乎任何问题,但有些人对这些新闻组的公共性质有顾虑。你总是可以用另外的方法:直接发电子邮件给古鲁。不管怎样,要使用有意义的主题(“需要帮助!!!”无益于事)。 l 坐回椅子上,耐心等候。人们很忙,也许需要几天才能得到明确的答案。 最后,请一定要感谢任何回应你的人。如果你看到有人提出你能够解答的问题,尽你的一份力,参与解答。 挑战 l 这周就开始学习一种新语言。总在用C++编程?试试Smalltalk[URL 13]或Squeak[URL 14]。在用Java?试试Eiffel[URL 10]或TOM[URL 15]。关于其他自由编译器和环境的来源,参见267页。 l 开始阅读一本新书(但要先读完这一本!)。如果你在进行非常详细的实现和编码,就阅读关于设计和架构的书。如果你在进行高级设计,就阅读关于编码技术的书。 l 出去和与你的当前项目无关的人、或是其他公司的人谈谈技术。在你们公司的自助餐厅里结识其他人,或是在本地用户组织聚会时寻找兴趣相投的人。 6 交流! 我相信,被打量比被忽略要好。 ——Mae West, Belle of the Nineties,1934 也许我们可以从West女士那里学到一点什么。问题不只是你有什么,还要看你怎样包装它。除非你能够与他人交流,否则就算你拥有最好的主意、最漂亮的代码、或是最注重实效的想法,最终也会毫无结果。没有有效的交流,一个好想法就只是一个无人关心的孤儿。 作为开发者,我们必须在许多层面上进行交流。我们把许多小时花在开会、倾听和交谈上。我们与最终用户一起工作,设法了解他们的需要。我们编写代码,与机器交流我们的意图;把我们的想法变成文档,留给以后的开发者。我们撰写提案和备忘录,用以申请资源并证明其正当性、报告我们的状态、以及提出各种新方法。我们每天在团队中工作,宣扬我们的主意、修正现有的做法、并提出新的做法。我们的时间有很大一部分都花在交流上,所以我们需要把它做好。 我们汇总了我们觉得有用的一些想法。 知道你想要说什么 在工作中使用的更为正式的交流方式中,最困难的部分也许是确切地弄清楚你想要说什么。小说家在开始写作之前,会详细地构思情节,而撰写技术文档的人却常常乐于坐到键盘前,键入“1. 介绍……”,并开始敲入接下来在他们的头脑里冒出来的任何东西。 规划你想要说的东西。写出大纲。然后问你自己:“这是否讲清了我要说的所有内容?”提炼它,直到确实如此为止。 这个方法不只适用于撰写文档。当你面临重要会议、或是要与重要客户通电话时,简略记下你想要交流的想法,并准备好几种把它们讲清楚的策略。 了解你的听众 只有当你是在传达信息时,你才是在交流。为此,你需要了解你的听众的需要、兴趣、能力。我们都曾出席过这样的会议:一个做开发的滑稽人物在发表长篇独白,讲述某种神秘技术的各种优点,把市场部副总裁弄得目光呆滞。这不是交流,而只是空谈,让人厌烦的(annoying)空谈。 要在脑海里形成一幅明确的关于你的听众的画面。下一页的图1.1中显示的WISDOM离合诗(acrostic)可能会对你有帮助。 假设你想提议开发一个基于Web的系统,用于让你们的最终用户提交bug报告。取决于听众的不同,你可以用不同的方式介绍这个系统。如果可以不用在电话上等候,每天24小时提交bug报告,最终用户将会很高兴。你们的市场部门可以利用这一事实促销。支持部门的经理会因为两个原因而高兴:所需员工更少,问题报告得以自动化。最后,开发者会因为能获得基于Web的客户-服务器技术和新数据库引擎方面的经验而感到享受。通过针对不同的人进行适当的修正,你将让他们都为你的项目感到兴奋。 选择时机 这是星期五的下午六点,审计人员进驻已有一周。你的老板最小的孩子在医院里,外面下着滂沱大雨,这时开车回家肯定是一场噩梦。这大概不是向她提出PC内存升级的好时候。 为了了解你的听众需要听到什么,你需要弄清楚他们的“轻重缓急”是什么。找到一个刚刚因为丢失源码而遭到老板批评的经理,向她介绍你关于源码仓库的构想,你将会拥有一个更容易接纳的倾听者。要让你所说的适得其时,在内容上切实相关。有时候,只要简单地问一句“现在我们可以谈谈……吗?”就可以了。 图1.1 WISDOM离合诗——了解听众 What do you want them to learn? What is their interest in what you’ve got to say? How sophisticated are they? How much detail do they want? Whom do you want to own the information? How can you motivate them to listen to you? 你想让他们学到什么? 他们对你讲的什么感兴趣? 他们有多富有经验? 他们想要多少细节? 你想要让谁拥有这些信息? 你如何促使他们听你说话? 选择风格 调整你的交流风格,让其适应你的听众。有人要的是正式的“事实”简报。另一些人喜欢在进入正题之前高谈阔论一番。如果是书面文档,则有人喜欢一大摞报告,而另一些人却喜欢简单的备忘录或电子邮件。如果有疑问,就询问对方。 但是,要记住,你也是交流事务的一方。如果有人说,他们需要你用一段话进行描述,而你觉得不用若干页纸就无法做到,如实告诉他们。记住,这样的反馈也是交流的一种形式。 让文档美观 你的主意很重要。它们应该以美观的方式传递给你的听众。 太多程序员(和他们的经理)在制作书面文档时只关心内容。我们认为这是一个错误。任何一个厨师都会告诉你,你可以在厨房里忙碌几个小时,最后却会因为饭菜糟糕的外观而毁掉你的努力。 在今天,已经没有任何借口制作出外观糟糕的打印文档。现代的字处理器(以及像LaTeX和troff这样的排版系统)能够生成非常好的输出。你只需要学习一些基本的命令。如果你的字处理器支持样式表,就加以利用(你的公司也许已经定义了你可以使用的样式表)。学习如何设置页眉和页脚。查看你的软件包中包含的样本文档,以对样式和版式有所了解。检查拼写,先自动,再手工。毕竟,有一些拼写错误是检查器找不出来的(After awl, their are spelling miss streaks that the chequer can knot ketch)。 让听众参与 我们常常发现,与制作文档的过程相比,我们制作出的文档最后并没有那么重要。如果可能,让你的读者参与文档的早期草稿的制作。获取他们的反馈,并汲取他们的智慧。你将建立良好的工作关系,并很可能在此过程中制作出更好的文档。 做倾听者 如果你想要大家听你说话,你必须使用一种方法:听他们说话。即使你掌握着全部信息,即使那是一个正式会议,你站在20个衣着正式的人面前——如果你不听他们说话,他们也不会听你说话。 鼓励大家通过提问来交谈,或是让他们总结你告诉他们的东西。把会议变成对话,你将能更有效地阐明你的观点。谁知道呢,你也许还能学到点什么。 回复他人 如果你向别人提问,他们不做出回应,你会觉得他们不礼貌。但当别人给你发送电子邮件或备忘录、请你提供信息、或是采取某种行动时,你是否经常忘记回复?在匆忙的日常生活中,很容易忘记事情。你应该总是对电子邮件和语音邮件做出回应,即使内容只是“我稍后回复你。”随时通知别人,会让他们更容易原谅你偶然的疏忽,并让他们觉得你没有忘记他们。 提示10 It’s Both What You Say and the Way You Say It 你说什么和你怎么说同样重要 除非你生活在真空中,你才不需要能交流。交流越有效,你就越有影响力。 电子邮件交流 我们所说的关于书面交流的所有东西都同样适用于电子邮件。现在的电子邮件已经发展成为公司内部和公司之间进行交流的主要手段。它被用于讨论合约、调解争端,以及用作法庭证据。但因为某种原因,许多从不会发出低劣的书面文档的人却乐于往全世界乱扔外观糟糕的电子邮件。 我们关于电子邮件的提示很简单: l 在你按下SEND之前进行校对。 l 检查拼写。 l 让格式保持简单。有人使用均衡字体(proportional font)阅读电子邮件,所以你辛苦制作的ASCII艺术图形在他们看来将像是母鸡的脚印一样乱七八糟。 l 只在你知道对方能够阅读rich-text或HTML格式的邮件的情况下使用这些格式。纯文本是通用的。 l 设法让引文减至最少。没有人喜欢收到一封回邮,其中有100行是他原来的电子邮件,只在最后新添了三个字:“我同意”。 l 如果你引用别人的电子邮件,一定要注明出处。并在正文中进行引用(而不是当做附件)。 l 不要用言语攻击别人(flame),除非你想让别人也攻击你,并老是纠缠你。 l 在发送之前检查你的收件人名单。最近《华尔街日报》上有一篇文章报道说,有一个雇员通过部门的电子邮件散布对老板的不满,却没有意识到老板也在收件人名单里。 l 将你的电子邮件——你收到的重要文件和你发送的邮件——加以组织并存档。 如Microsoft和Netscape的好些雇员在1999年司法部调查期间所发现的,e-mail是永久性的。要设法像对待任何书面备忘录或报告一样小心对待e-mail。 总结 l 知道你想要说什么。 l 了解你的听众。 l 选择时机。 l 选择风格。 l 让文档美观。 l 让听众参与。 l 做倾听者。 l 回复他人。 相关内容: l 原型与便笺,53页 l 注重实效的团队,224页 挑战 l 有几本好书讨论了开发团队内部的交流[Bro95, McC95, DL99]。下决心在接下来的18个月里读完所有这三本书。此外,Dinosaur Brains[Ber96]这本书讨论了我们所有人都会带到工作环境中的“情绪包袱”。 l 在你下一次进行展示、或是撰写备忘录支持某种立场时,先试着按第20页的WISDOM离合诗做一遍。看这样是否有助于你了解怎样定位你的讲话。如果合适,事后与你的听众谈一谈,看你对他们的需要的估计有多准确。 7 重复的危害 有些提示和诀窍可应用于软件开发的所有层面,有些想法几乎是公理,有些过程实际上普遍适用。但是,人们几乎没有为这些途径建立这样的文档,你很可能会发现,它们作为零散的段落写在关于设计、项目管理或编码的讨论中。 在这一章里,我们将要把这些想法和过程集中在一起。头两节,“重复的危害”与“正交性”,密切相关。前者提醒你,不要在系统各处对知识进行重复,后者提醒你,不要把任何一项知识分散在多个系统组件中。 随着变化的步伐加快,我们越来越难以让应用跟上变化。在“可撤消性”中,我们将考察有助于使你的项目与其不断变化的环境绝缘的一些技术。 接下来的两节也是相关的。在“曳光弹”中,我们将讨论一种开发方式,能让你同时搜集需求、测试设计、并实现代码。这听起来太好,不可能是真的?的确如此:曳光弹开发并非总是可以应用。“原型与便笺”将告诉你,在曳光弹开发不适用的情况下,怎样使用原型来测试架构、算法、接口以及各种想法。 随着计算机科学慢慢成熟,设计者正在制作越来越高级的语言。尽管能够接受“让它这样”(make it so)指令的编译器还没有发明出来,在“领域语言”中我们给出了一些适度的建议,你可以自行加以实施。 最后,我们都是在一个时间和资源有限的世界上工作。如果你善于估计出事情需要多长时间完成,你就能更好地在两者都很匮乏的情况下生存下去(并让你的老板更高兴)。我们将在“估算”中涵盖这一主题。 在开发过程中牢记这些基本原则,你就将能编写更快、更好、更强健的代码。你甚至可以让这看起来很容易。 7 重复的危害 给予计算机两项自相矛盾的知识,是James T. Kirk舰长(出自Star Trek,“星际迷航”——译注)喜欢用来使四处劫掠的人工智能生命失效的方法。遗憾的是,同样的原则也能有效地使你的代码失效。 作为程序员,我们收集、组织、维护和利用知识。我们在规范中记载知识、在运行的代码中使其活跃起来并将其用于提供测试过程中所需的检查。 遗憾的是,知识并不稳定。它变化——常常很快。你对需求的理解可能会随着与客户的会谈而发生变化。政府改变规章制度,有些商业逻辑过时了。测试也许表明所选择的算法无法工作。所有这些不稳定都意味着我们要把很大一部分时间花在维护上,重新组织和表达我们的系统中的知识。 大多数人都以为维护是在应用发布时开始的,维护就意味着修正bug和增强特性。我们认为这些人错了。程序员须持续不断地维护。我们的理解逐日变化。当我们设计或编码时,出现了新的需求。环境或许变了。不管原因是什么,维护都不是时有时无的活动,而是整个开发过程中的例行事务。 当我们进行维护时,我们必须找到并改变事物的表示——那些嵌在应用中的知识胶囊。问题是,在我们开发的规范、过程和程序中很容易重复表述知识,而当我们这样做时,我们是在向维护的噩梦发出邀请——在应用发布之前就会开始的噩梦。 我们觉得,可靠地开发软件、并让我们的开发更易于理解和维护的惟一途径,是遵循我们称之为DRY的原则: 系统中的每一项知识都必须具有单一、无歧义、权威的表示。 我们为何称其为DRY? 提示11 DRY – Don’t Repeat Yourself 不要重复你自己 与此不同的做法是在两个或更多地方表达同一事物。如果你改变其中一处,你必须记得改变其他各处。或者,就像那些异形计算机,你的程序将因为自相矛盾而被迫屈服。这不是你是否能记住的问题,而是你何时忘记的问题。 你会发现DRY原则在全书中一再出现,并且常常出现在与编码无关的语境中。我们觉得,这是注重实效的程序员的工具箱里最重要的工具之一。 在这一节我们将概述重复的问题,并提出对此加以处理的一般策略。 重复是怎样发生的 我们所见到的大多数重复都可归入下列范畴: l 强加的重复(imposed duplication)。开发者觉得他们无可选择——环境似乎要求重复。 l 无意的重复(inadvertent duplication)。开发者没有意识到他们在重复信息。 l 无耐性的重复(impatient duplication)。开发者偷懒,他们重复,因为那样似乎更容易。 l 开发者之间的重复(interdeveloper duplication)。同一团队(或不同团队)的几个人重复了同样的信息。 让我们更详细地看一看这四个以“i ”开头的重复。 强加的重复 有时,重复似乎是强加给我们的。项目标准可能要求建立含有重复信息的文档,或是重复代码中的信息的文档。多个目标平台各自需要自己的编程语言、库以及开发环境,这会使我们重复共有的定义和过程。编程语言自身要求某些重复信息的结构。我们都在我们觉得无力避免重复的情形下工作过。然而也有一些方法,可用于把一项知识存放在一处,以遵守DRY原则,同时也让我们的生活更容易一点。这里有一些这样的技术: 信息的多种表示。在编码一级,我们常常需要以不同的形式表示同一信息。我们也许在编写客户-服务器应用,在客户和服务器端使用了不同的语言,并且需要在两端都表示某种共有的结构。我们或许需要一个类,其属性是某个数据库表的schema(模型、方案)的镜像。你也许在撰写一本书,其中包括的程序片段,也正是你要编译并测试的程序。 发挥一点聪明才智,你通常能够消除重复的需要。答案常常是编写简单的过滤器或代码生成器。可以在每次构建(build)软件时,使用简单的代码生成器,根据公共的元数据表示构建多种语言下的结构(示例参见图3.4,106页)。可以根据在线数据库schema、或是最初用于构建schema的元数据,自动生成类定义。本书中摘录的代码,由预处理器在我们每次对文本进行格式化时插入。诀窍是让该过程成为主动的,这不能是一次性转换,否则我们就会退回到重复数据的情况。 代码中的文档。程序员被教导说,要给代码加上注释:好代码有许多注释。遗憾的是,没有人教给他们,代码为什么需要注释:糟糕的代码才需要许多注释。 DRY法则告诉我们,要把低级的知识放在代码中,它属于那里;把注释保留给其他的高级说明。否则,我们就是在重复知识,而每一次改变都意味着既要改变代码,也要改变注释。注释将不可避免地变得过时,而不可信任的注释比完全没有注释更糟(关于注释的更多信息,参见全都是写,248页)。 文档与代码。你撰写文档,然后编写代码。有些东西变了,你修订文档、更新代码。文档和代码都含有同一知识的表示。而我们都知道,在最紧张的时候——最后期限在逼近,重要的客户在喊叫——我们往往会推迟文档的更新。 Dave曾经参与过一个国际电报交换机项目的开发。很容易理解,客户要求提供详尽的测试规范,并要求软件在每次交付时都通过所有测试。为了确保测试准确地反映规范,开发团队用程序方式、根据文档本身生成这些测试。当客户修订他们的规范时,测试套件会自动改变。有一次团队向客户证明了,该过程很健全,生成验收测试在典型情况下只需要几秒种。 语言问题。许多语言会在源码中强加可观的重复。如果语言使模块的接口与其实现分离,就常常会出现这样的情况。C与C++有头文件,在其中重复了被导出变量、函数和(C++的)类的名称和类型信息。Object Pascal甚至会在同一文件里重复这些信息。如果你使用远地过程调用或CORBA[URL 29],你将会在接口规范与实现它的代码之间重复接口信息。 没有什么简单的技术可用于克服语言的这些需求。尽管有些开发环境通过自动生成头文件、隐藏了对头文件的需要,而Object Pascal允许你缩写重复的函数声明,你通常仍受制于给予你的东西。至少对于大多数与语言有关的问题,与实现不一致的头文件将会产生某种形式的编译或链接错误。你仍会弄错事情,但至少,你将在很早的时候就得到通知。 再思考一下头文件和实现文件中的注释。绝对没有理由在这两种文件之间重复函数或类头注释(header comment)。应该用头文件记载接口问题,用实现文件记载代码的使用者无须了解的实际细节。 无意的重复 有时,重复来自设计中的错误。 让我们看一个来自配送行业的例子。假定我们的分析揭示,一辆卡车有车型、牌照号、司机及其他一些属性。与此类似,发运路线的属性包括路线、卡车和司机。基于这一理解,我们编写了一些类。 但如果Sally打电话请病假、我们必须改换司机,事情又会怎样呢?Truck和DeliverRoute都包含有司机。我们改变哪一个?显然这样的重复很糟糕。根据底层的商业模型对其进行规范化(normalize)——卡车的底层属性集真的应包含司机?路线呢?又或许我们需要第三种对象,把司机、卡车及路线结合在一起。不管最终的解决方案是什么,我们都应避免这种不规范的数据。 当我们拥有多个互相依赖的数据元素时,会出现一种不那么显而易见的不规范数据。让我们看一个表示线段的类: class Line { public: Point start; Point end; double length; }; 第一眼看上去,这个类似乎是合理的。线段显然有起点和终点,并总是有长度(即使长度为零)。但这里有重复。长度是由起点和终点决定的:改变其中一个,长度就会变化。最好是让长度成为计算字段: class Line { public: Point start; Point end; double length() { return start.distanceTo(end); } }; 在以后的开发过程中,你可以因为性能原因而选择违反DRY原则。这经常会发生在你需要缓存数据,以避免重复昂贵的操作时。其诀窍是使影响局部化。对DRY原则的违反没有暴露给外界:只有类中的方法需要注意“保持行为良好”。 class Line { private: bool changed; double length; Point start; Point end; public: void setStart(Point p) { start = p; changed = true; } void setEnd(Point p) { end = p; changed = true; } Point getStart(void) { return start; } Point getEnd(void) { return end; } double getLength() { if (changed) { length = start.distanceTo(end); changed = false; } return length; } }; 这个例子还说明了像Java和C++这样的面向对象语言的一个重要问题。在可能的情况下,应该总是用访问器(accessor)函数读写对象的属性。这将使未来增加功能(比如缓存)变得更容易。 无耐性的重复 每个项目都有时间压力——这是能够驱使我们中间最优秀的人走捷径的力量。需要与你写过的一个例程相似的例程?你会受到诱惑,去拷贝原来的代码,并做出一些改动。需要一个表示最大点数的值?如果我改动头文件,整个项目就得重新构建。也许我应该在这里使用直接的数字(literal number),这里,还有这里,需要一个与Java runtime中的某个类相似的类?源码在那里(你有使用许可),那么为什么不拷贝它、并做出你所需的改动呢? 如果你觉得受到诱惑,想一想古老的格言:“欲速则不达”。你现在也许可以节省几秒钟,但以后却可能损失几小时。想一想围绕着Y2K惨败的种种问题。其中许多问题是由开发者的懒惰造成的:他们没有参数化日期字段的尺寸,或是实现集中的日期服务库。 无耐性的重复是一种容易检测和处理的重复形式,但那需要你接受训练,并愿意为避免以后的痛苦而预先花一些时间。 开发者之间的重复 另一方面,或许是最难检测和处理的重复发生在项目的不同开发者之间。整个功能集都可能在无意中被重复,而这些重复可能几年里都不会被发现,从而导致各种维护问题。我们亲耳听说过,美国某个州在对政府的计算机系统进行Y2K问题检查时,审计者发现有超出10,000个程序,每一个都有自己的社会保障号验证代码。 在高层,可以通过清晰的设计、强有力的技术项目领导(参见288页“注重实效的团队”一节中的内容)、以及在设计中进行得到了充分理解的责任划分,对这个问题加以处理。但是,在模块层,问题更加隐蔽。不能划入某个明显的责任区域的常用功能和数据可能会被实现许多次。 我们觉得,处理这个问题的最佳方式是鼓励开发者相互进行主动的交流。设置论坛,用以讨论常见问题(在过去的一些项目中,我们设置了私有的Usenet新闻组,用于让开发者交换意见,进行提问。这提供了一种不受打扰的交流方式——甚至跨越多个站点——同时又保留了所有言论的永久历史)。让某个团队成员担任项目资料管理员,其工作是促进知识的交流。在源码树中指定一个中央区域,用于存放实用例程和脚本。一定要阅读他人的源码与文档,不管是非正式的,还是进行代码复查。你不是在窥探——你是在向他们学习。而且要记住,访问是互惠的——不要因为别人钻研(乱钻?)你的代码而苦恼。 提示12 Make It Easy to Reuse 让复用变得容易 你所要做的是营造一种环境,在其中要找到并复用已有的东西,比自己编写更容易。如果不容易,大家就不会去复用。而如果不进行复用,你们就会有重复知识的风险。 相关内容: l 正交性,34页 l 文本操纵,99页 l 代码生成器,102页 l 重构,184页 l 注重实效的团队,224页 l 无处不在的自动化,230页 l 全都是写,248页 8 正交性 如果你想要制作易于设计、构建、测试及扩展的系统,正交性是一个十分关键的概念,但是,正交性的概念很少被直接讲授,而常常是你学习的各种其他方法和技术的隐含特性。这是一个错误。一旦你学会了直接应用正交性原则,你将发现,你制作的系统的质量立刻就得到了提高。 什么是正交性 “正交性”是从几何学中借来的术语。如果两条直线相交成直角,它们就是正交的,比如图中的坐标轴。用向量术语说,这两条直线互不依赖。沿着某一条直线移动,你投影到另一条直线上的位置不变。 在计算技术中,该术语用于表示某种不相依赖性或是解耦性。如果两个或更多事物中的一个发生变化,不会影响其他事物,这些事物就是正交的。在设计良好的系统中,数据库代码与用户界面是正交的:你可以改动界面,而不影响数据库;更换数据库,而不用改动界面。 在我们考察正交系统的好处之前,让我们先看一看非正交系统。 非正交系统 你正乘坐直升机游览科罗拉多大峡谷,驾驶员——他显然犯了一个错误,在吃鱼,他的午餐——突然呻吟起来,晕了过去。幸运的是,他把你留在了离地面100英尺的地方。你推断,升降杆控制总升力,所以轻轻将其压低可以让直升机平缓降向地面。然而,当你这样做时,却发现生活并非那么简单。直升机的鼻子向下,开始向左盘旋下降。突然间你发现,你驾驶的这个系统,所有的控制输入都有次级效应。压低左手的操作杆,你需要补偿性地向后移动右手柄,并踩右踏板。但这些改变中的每一项都会再次影响所有其他的控制。突然间,你在用一个让人难以置信的复杂系统玩杂耍,其中每一项改变都会影响所有其他的输入。你的工作负担异常巨大:你的手脚在不停地移动,试图平衡所有交互影响的力量。 直升机的各个控制器断然不是正交的。 正交的好处 如直升机的例子所阐明的,非正交系统的改变与控制更复杂是其固有的性质。当任何系统的各组件互相高度依赖时,就不再有局部修正(local fix)这样的事情。 提示13 Eliminate Effects Between Unrelated Things 消除无关事物之间的影响 我们想要设计自足(self-contained)的组件:独立,具有单一、良好定义的目的(Yourdon和Constantine称之为内聚(cohesion)[YC86])。如果组件是相互隔离的,你就知道你能够改变其中之一,而不用担心其余组件。只要你不改变组件的外部接口,你就可以放心:你不会造成波及整个系统的问题。 如果你编写正交的系统,你得到两个主要好处:提高生产率与降低风险。 提高生产率 l 改动得以局部化,所以开发时间和测试时间得以降低。与编写单个的大块代码相比,编写多个相对较小的、自足的组件更为容易。你可以设计、编写简单的组件,对其进行单元测试,然后把它们忘掉——当你增加新代码时,无须不断改动已有的代码。 l 正交的途径还能够促进复用。如果组件具有明确而具体的、良好定义的责任,就可以用其最初的实现者未曾想象过的方式,把它们与新组件组合在一起。 l 如果你对正交的组件进行组合,生产率会有相当微妙的提高。假定某个组件做M件事情,而另一个组件做N件事情。如果它们是正交的,而你把它们组合在一起,结果就能做M x N件事情。但是,如果这两个组件是非正交的,它们就会重叠,结果能做的事情就更少。通过组合正交的组件,你的每一份努力都能得到更多的功能。 降低风险 正交的途径能降低任何开发中固有的风险。 l 有问题的代码区域被隔离开来。如果某个模块有毛病,它不大可能把病症扩散到系统的其余部分。要把它切掉,换成健康的新模块也更容易。 l 所得系统更健壮。对特定区域做出小的改动与修正,你所导致的任何问题都将局限在该区域中。 l 正交系统很可能能得到更好的测试,因为设计测试、并针对其组件运行测试更容易。 l 你不会与特定的供应商、产品、或是平台紧绑在一起,因为与这些第三方组件的接口将被隔离在全部开发的较小部分中。 让我们看一看在工作中应用正交原则的几种方式。 项目团队 你是否注意到,有些项目团队很有效率,每个人都知道要做什么,并全力做出贡献,而另一些团队的成员却老是在争吵,而且好像无法避免互相妨碍? 这常常是一个正交性问题。如果团队的组织有许多重叠,各个成员就会对责任感到困惑。每一次改动都需要整个团队开一次会,因为他们中的任何一个人都可能受到影响。 怎样把团队划分为责任得到了良好定义的小组,并使重叠降至最低呢?没有简单的答案。这部分地取决于项目本身,以及你对可能变动的区域的分析。这还取决于你可以得到的人员。我们的偏好是从使基础设施与应用分离开始。每个主要的基础设施组件(数据库、通信接口、中间件层,等等)有自己的子团队。如果应用功能的划分显而易见,那就照此划分。然后我们考察我们现有的(或计划有的)人员,并对分组进行相应的调整。 你可以对项目团队的正交性进行非正式的衡量。只要看一看,在讨论每个所需改动时需要涉及多少人。人数越多,团队的正交性就越差。显然,正交的团队效率也更高(尽管如此,我们也鼓励子团队不断地相互交流)。 设计 大多数开发者都熟知需要设计正交的系统,尽管他们可能会使用像模块化、基于组件、或是分层这样的术语描述该过程。系统应该由一组相互协作的模块组成,每个模块都实现不依赖于其他模块的功能。有时,这些组件被组织为多个层次,每层提供一级抽象。这种分层的途径是设计正交系统的强大方式。因为每层都只使用在其下面的层次提供的抽象,在改动底层实现、而又不影响其他代码方面,你拥有极大的灵活性。分层也降低了模块间依赖关系失控的风险。你将常常看到像下一页的图2.1这样的图表示的层次关系。 对于正交设计,有一种简单的测试方法。一旦设计好组件,问问你自己:如果我显著地改变某个特定功能背后的需求,有多少模块会受影响?在正交系统中,答案应 图2.1 典型的层次图 该是“一个”。移动GUI面板上的按钮,不应该要求改动数据库schema。增加语境敏感的帮助,也不应该改动记账子系统。 让我们考虑一个用于监视和控制供暖设备的复杂系统。原来的需求要求提供图形用户界面,但后来需求被改为要增加语音应答系统,用按键电话控制设备。在正交地设计的系统中,你只需要改变那些与用户界面有关联的模块,让它们对此加以处理:控制设备的底层逻辑保持不变。事实上,如果你仔细设计你的系统结构,你应该能够用同一个底层代码库支持这两种界面。157页的“它只是视图”将讨论怎样使用模型-视图-控制器(MVC)范型编写解耦的代码,该范型在这里的情况下也能很好地工作。 还要问问你自己,你的设计在多大程度上解除了与现实世界中的的变化的耦合?你在把电话号码当作顾客标识符吗?如果电话公司重新分配了区号,会怎么样?不要依赖你无法控制的事物属性。 8 正交性(2) 工具箱与库 在你引入第三方工具箱和库时,要注意保持系统的正交性。要明智地选择技术。 我们曾经参加过一个项目,在其中需要一段Java代码,既运行在本地的服务器机器上,又运行在远地的客户机器上。要把类按这样的方式分布,可以选用RMI或CORBA。如果用RMI实现类的远地访问,对类中的远地方法的每一次调用都可能会抛出异常;这意味着,一个幼稚的实现可能会要求我们,无论何时使用远地类,都要对异常进行处理。在这里,使用RMI显然不是正交的:调用远地类的代码应该不用知道这些类的位置。另一种方法——使用CORBA——就没有施加这样的限制:我们可以编写不知道我们类的位置的代码。 在引入某个工具箱时(甚或是来自你们团队其他成员的库),问问你自己,它是否会迫使你对代码进行不必要的改动。如果对象持久模型(object persistence scheme)是透明的,那么它就是正交的。如果它要求你以一种特殊的方式创建或访问对象,那么它就不是正交的。让这样的细节与代码隔离具有额外的好处:它使得你在以后更容易更换供应商。 Enterprise Java Beans(EJB)系统是正交性的一个有趣例子。在大多数面向事务的系统中,应用代码必须描述每个事务的开始与结束。在EJB中,该信息是作为元数据,在任何代码之外,以声明的方式表示的。同一应用代码不用修改,就可以运行在不同的EJB事务环境中。这很可能是将来许多环境的模型。 正交性的另一个有趣的变体是面向方面编程(Aspect-Oriented Programming,AOP),这是Xerox Parc的一个研究项目([KLM+97]与[URL 49])。AOP让你在一个地方表达本来会分散在源码各处的某种行为。例如,日志消息通常是在源码各处、通过显式地调用某个日志函数生成的。通过AOP,你把日志功能正交地实现到要进行日志记录的代码中。使用AOP的Java版本,你可以通过编写aspect、在进入类Fred的任何方法时写日志消息: aspect Trace { advise * Fred.*(..) { static before { Log.write("-> Entering " + thisJoinPoint.methodName); } } } 如果你把这个方面编织(weave)进你的代码,就会生成追踪消息。否则,你就不会看到任何消息。不管怎样,你原来的源码都没有变化。 编码 每次你编写代码,都有降低应用正交性的风险。除非你不仅时刻监视你正在做的事情,也时刻监视应用的更大语境,否则,你就有可能无意中重复其他模块的功能,或是两次表示已有的知识。 你可以将若干技术用于维持正交性: l 让你的代码保持解耦。编写“羞怯”的代码——也就是不会没有必要地向其他模块暴露任何事情、也不依赖其他模块的实现的模块。试一试我们将在183页的“解耦与得墨忒耳法则”中讨论的得墨忒耳法则(Law of Demeter)[LH89]。如果你需要改变对象的状态,让这个对象替你去做。这样,你的代码就会保持与其他代码的实现的隔离,并增加你保持正交的机会。 l 避免使用全局数据。每当你的代码引用全局数据时,它都把自己与共享该数据的其他组件绑在了一起。即使你只想对全局数据进行读取,也可能会带来麻烦(例如,如果你突然需要把代码改为多线程的)。一般而言,如果你把所需的任何语境(context)显式地传入模块,你的代码就会更易于理解和维护。在面向对象应用中,语境常常作为参数传给对象的构造器。换句话说,你可以创建含有语境的结构,并传递指向这些结构的引用。 《设计模式》[GHJV95]一书中的Singleton(单体)模式是确保特定类的对象只有一个实例的一种途径。许多人把这些singleton对象用作某种全局变量(特别是在除此而外不支持全局概念的语言中,比如Java)。使用singleton要小心——它们可能造成不必要的关联。 l 避免编写相似的函数。你常常会遇到看起来全都很像的一组函数——它们也许在开始和结束处共享公共的代码,中间的算法却各有不同。重复的代码是结构问题的一种症状。要了解更好的实现,参见《设计模式》一书中的Strategy(策略)模式。 养成不断地批判对待自己的代码的习惯。寻找任何重新进行组织、以改善其结构和正交性的机会。这个过程叫做重构(refactoring),它非常重要,所以我们专门写了一节加以讨论(见“重构”,184页) 测试 正交地设计和实现的系统也更易于测试,因为系统的各组件间的交互是形式化的和有限的,更多的系统测试可以在单个的模块级进行。这是好消息,因为与集成测试(integration testing)相比,模块级(或单元)测试要更容易规定和进行得多。事实上,我们建议让每个模块都拥有自己的、内建在代码中的单元测试,并让这些测试作为常规构建过程的一部分自动运行(参见“易于测试的代码”,189页)。 构建单元测试本身是对正交性的一项有趣测试。要构建和链接某个单元测试,都需要什么?只是为了编译或链接某个测试,你是否就必须把系统其余的很大一部分拽进来?如果是这样,你已经发现了一个没有很好地解除与系统其余部分耦合的模块。 修正bug也是评估整个系统的正交性的好时候。当你遇到问题时,评估修正的局部化程度。 你是否只改动了一个模块,或者改动分散在整个系统的各个地方?当你做出改动时,它修正了所有问题,还是又神秘地出现了其他问题?这是开始运用自动化的好机会。如果你使用了源码控制系统(在阅读了86页的“源码控制”之后,你会使用的),当你在测试之后、把代码签回(check the code back)时,标记所做的bug修正。随后你可以运行月报,分析每个bug修正所影响的源文件数目的变化趋势。 文档 也许会让人惊讶,正交性也适用于文档。其坐标轴是内容和表现形式。对于真正正交的文档,你应该能显著地改变外观,而不用改变内容。现代的字处理器提供了样式表和宏,能够对你有帮助(参见“全都是写”,248页)。 认同正交性 正交性与27页介绍的DRY原则紧密相关。运用DRY原则,你是在寻求使系统中的重复降至最小;运用正交性原则,你可降低系统的各组件间的相互依赖。这样说也许有点笨拙,但如果你紧密结合DRY原则、运用正交性原则,你将会发现你开发的系统会变得更为灵活、更易于理解、并且更易于调试、测试和维护。 如果你参加了一个项目,大家都在不顾一切地做出改动,而每一处改动似乎都会造成别的东西出错,回想一下直升机的噩梦。项目很可能没有进行正交的设计和编码。是重构的时候了。 另外,如果你是直升机驾驶员,不要吃鱼…… 相关内容: l 重复的危害,26页 l 源码控制,86页 l 按合约设计,109页 l 解耦与得墨忒耳法则,138页 l 元程序设计,144页 l 它只是视图,157页 l 重构,184页 l 易于测试的代码,189页 l 邪恶的向导,198页 l 注重实效的团队,224页 l 全都是写,248页 挑战 l 考虑常在Windows系统上见到的面向GUI的大型工具和在shell提示下使用的短小、但却可以组合的命令行实用工具。哪一种更为正交,为什么?如果正好按其设计用途加以应用,哪一种更易于使用?哪一种更易于与其他工具组合、以满足新的要求? l C++支持多重继承,而Java允许类实现多重接口。使用这些设施对正交性有何影响?使用多重继承与使用多重接口的影响是否有不同?使用委托(delegation)与使用继承之间是否有不同? 练习 1. 你在编写一个叫做Split的类,其用途是把输入行拆分为字段。下面的两个Java类的型构(signature)中,哪一个是更为正交的设计? (解答在279页) class Split1 { public Split1(InputStreamReader rdr) { ... public void readNextLine() throws IOException { ... public int numFields() { ... public String getField(int fieldNo) { ... } class Split2 { public Split2(String line) { ... public int numFields() { ... public String getField(int fieldNo) { ... } 2. 非模态对话框或模态对话框,哪一个能带来更为正交的设计? (解答在279页) 3. 过程语言与对象技术的情况又如何?哪一种能产生更为正交的系统? (解答在280页) 9 可撤消性 如果某个想法是你惟一的想法,再没有什么比这更危险的事情了。 ——Emil-Auguste Chartier, Propos sur la religion, 1938 工程师们喜欢问题有简单、单一的解决方案。与论述法国大革命的无数起因的一篇模糊、热烈的文章相比,允许你怀着极大的自信宣称x = 2的数学测验要让人觉得舒服得多。管理人员往往与工程师趣味相投:单一、容易的答案正好可以放在电子表格和项目计划中。 现实世界能够合作就好了!遗憾的是,今天x是2,明天也许就需要是5,下周则是3。没有什么永远不变——而如果你严重依赖某一事实,你几乎可以确定它将会变化。 要实现某种东西,总有不止一种方式,而且通常有不止一家供应商可以提供第三方产品。如果你参与的项目被短视的、认为只有一种实现方式的观念所牵绊,你也许就会遇到让人不悦的意外之事。许多项目团队会被迫在未来展现之时睁开眼睛: “但你说过我们要使用XYZ数据库!我们的项目已经完成了85%的编码工作。我们现在不能改变了!”程序员抗议道。“对不起,但我们公司决定进行标准化,改用PDQ数据库——所有项目。这超出了我的职权范围。我们必须重新编码。周末所有人都要加班,直到另行通知为止。” 变动不一定会这么严苛,甚至也不会这么迫在眉睫。但随着时间的流逝,随着你的项目取得进展,你也许会发现自己陷在无法立足的处境里。随着每一项关键决策的做出,项目团队受到越来越小的目标的约束——现实的更窄小的版本,选择的余地越来越小。 在许多关键决策做出之后,目标会变得如此之小,以至于如果它动一下,或是风改变方向,或是东京的蝴蝶扇动翅膀,你都会错过目标。而且你可能会偏出很远。 问题在于,关键决策不容易撤消。 一旦你决定使用这家供应商的数据库、那种架构模式、或是特定的部署模型(例如,客户-服务器 vs. 单机),除非付出极大的代价,否则你就将受制于一个无法撤消的动作进程(course of action)。 可撤消性 我们让本书的许多话题相互配合,以制作灵活、有适应能力的软件。通过遵循它们的建议——特别是DRY原则(26页)、解耦(138页)以及元数据的使用(144页)——我们不必做出许多关键的、不可逆转的决策。这是一件好事情,因为我们并非总能在一开始就做出最好的决策。我们采用了某种技术,却发现我们雇不到足够的具有必需技能的人。我们刚刚选定某个第三方供应商,他们就被竞争者收购了。与我们开发软件的速度相比,需求、用户以及硬件变得更快。 假定在项目初期,你决定使用供应商A提供的关系数据库。过了很久,在性能测试过程中,你发现数据库简直太慢了,而供应商B提供的对象数据库更快。对于大多数传统项目,你不会有什么运气。大多数时候,对第三方产品的调用都缠绕在代码各处。但如果你真的已经把数据库的概念抽象出来——抽象到数据库只是把持久(persistence)作为服务提供出来的程度——你就会拥有“中流换马(change horses in midstream)”的灵活性。 与此类似,假定项目最初采用的是客户-服务器模型,但随即,在开发的后期,市场部门认为服务器对于某些客户过于昂贵,他们想要单机版。对你来说,那会有多困难?因为这只是一个部署问题,所以不应该要很多天。如果所需时间更长,那么你就没有考虑过可撤消性。另外一个方向甚至更有趣。如果需要以客户-服务器或n层方式部署你正在开发的单机产品,事情又会怎样?那也不应该很困难。 错误在于假定决策是浇铸在石头上的——同时还在于没有为可能出现的意外事件做准备。 要把决策视为是写在沙滩上的,而不要把它们刻在石头上。大浪随时可能到来,把它们抹去。 提示14 There Are No Final Decisions 不存在最终决策 灵活的架构 有许多人会设法保持代码的灵活性,而你还需要考虑维持架构、部署及供应商集成等领域的灵活性。 像CORBA这样的技术可以帮助把项目的某些部分与开发语言或平台的变化隔离开来。Java在该平台上的性能不能满足要求?重新用C++编写客户代码,其他没有什么需要改变。用C++编写的规则引擎不够灵活?换到Smalltalk版本。采用CORBA架构,你只须改动替换的组件:其他组件应该不会受影响。 你正在开发UNIX软件?哪一种?你是否处理了所有可移植性问题?你正在为某个特定版本的Windows做开发?哪一种——3.1、95、98、NT、CE、或是2000?支持其他版本有多难?如果你让决策保持软和与柔韧,事情就完全不困难。如果在代码中有着糟糕的封装、高度耦合以及硬编码的逻辑或参数,事情也许就是不可能的。 不确定市场部门想怎样部署系统?预先考虑这个问题,你可以支持单机、客户-服务器、或n层模型——只需要改变配置文件。我们就写过一些这么做的程序。 通常,你可以把第三方产品隐藏在定义良好的抽象接口后面。事实上,在我们做过的任何项目中,我们都总能够这么做。但假定你无法那么彻底地隔离它,如果你必须大量地把某些语句分散在整个代码中,该怎么办?把该需求放入元数据,并且使用某种自动机制——比如Aspect(参见39页)或Perl——把必需的语句插入代码自身中。无论你使用的是何种机制,让它可撤消。如果某样东西是自动添加的,它也可以被自动去掉。 没有人知道未来会怎样,尤其是我们!所以要让你的代码学会“摇滚”:可以“摇”就“摇”,必须“滚”就“滚”。 相关内容: l 解耦与得墨忒耳法则,138页 l 元程序设计,144页 l 它只是视图,157页 挑战 l 让我们通过“薛定谔的猫”学一点量子力学。假定在一个封闭的盒子里有一只猫,还有一个放射性粒子。这个粒子正好有50%的机会裂变成两个粒子。如果发生了裂变,猫就会被杀死;如果没有,猫就不会有事。那么,猫是死是活?根据薛定谔的理论,正确的答案是“都是”。每当有两种可能结果的亚核反应发生时,宇宙就会被克隆。在其中一个宇宙中,事件发生;在另一个宇宙中,事件不发生。猫在一个宇宙中是活的,在另一个宇宙中是死的。只有当你打开盒子,你才知道你在哪一个宇宙里。 怪不得为未来编码很困难。 但想一想,代码沿着与装满薛定谔的猫的盒子一样的路线演化:每一项决策都会导致不同版本的未来。你的代码能支持多少种可能的未来?哪一种未来更有可能发生?到时支持它们有多困难? 你敢打开盒子吗? 10 曳光弹 预备、开火、瞄准…… 在黑暗中用机枪射击有两种方式。你可以找出目标的确切位置(射程、仰角及方位)。你可以确定环境状况(温度、湿度、气压、风,等等)。你可以确定你使用的弹药筒和子弹的精确规格,以及它们与你使用的机枪的交互作用。然后你可以用计算表或射击计算机计算枪管的确切方向及仰角。如果每一样东西都严格按照规定的方式工作,你的计算表正确无误,而且环境没有发生变化,你的子弹应该能落在距目标不远的地方。 或者,你可以使用曳光弹。 曳光弹与常规弹药交错着装在弹药带上。发射时,曳光弹中的磷点燃,在枪与它们击中的地方之间留下一条烟火般的踪迹。如果曳光弹击中目标,那么常规子弹也会击中目标。 并不让人惊奇的是,曳光弹比费力计算更可取。反馈是即时的,而且因为它们工作在与真正的弹药相同的环境中,外部影响得以降至最低。 这个类比也许有点暴力,但它适用于新的项目,特别是当你构建从未构建过的东西时。与枪手一样,你也设法在黑暗中击中目标。因为你的用户从未见过这样的系统,他们的需求可能会含糊不清。因为你在使用不熟悉的算法、技术、语言或库,你面对着大量未知的事物。同时,因为完成项目需要时间,在很大程度上你能够确知,你的工作环境将在你完成之前发生变化。 经典的做法是把系统定死。制作大量文档,逐一列出每项需求、确定所有未知因素、并限定环境。根据死的计算射击。预先进行一次大量计算,然后射击并企望击中目标。 然而,注重实效的程序员往往更喜欢使用曳光弹。 在黑暗中发光的代码 曳光弹行之有效,是因为它们与真正的子弹在相同的环境、相同的约束下工作。它们快速飞向目标,所以枪手可以得到即时的反馈。同时,从实践的角度看,这样的解决方案也更便宜。 为了在代码中获得同样的效果,我们要找到某种东西,让我们能快速、直观和可重复地从需求出发,满足最终系统的某个方面要求。 提示15 Use Tracer Bullets to Find the Target 用曳光弹找到目标 有一次,我们接受了一个复杂的客户-服务器数据库营销项目。其部分需求是要能够指定并执行临时查询。服务器是一系列专用的关系数据库。用Object Pascal编写的客户GUI使用一组C库提供给服务器的接口。在转换为优化的SQL之前,用户的查询以类似Lisp的表示方式存储在服务器上;转换直到执行前才进行。有许多未知因素和许多不同的环境,没有人清楚地知道GUI应该怎样工作。 这是使用曳光代码的好机会。我们开发了前端框架、用于表示查询的库以及用于把所存储的查询转换为具体数据库的查询的结构。随后我们把它们集中在一起,并检查它们是否能工作。使用最初构建的系统,我们所能做的只是提交一个查询,列出某个表中的所有行,但它证明了UI能够与库交谈,库能够对查询进行序列化和解序列化,而服务器能够根据结果生成SQL。在接下来的几个月里,我们逐渐充实这个基本结构,通过并行地扩大曳光代码的各个组件增加新的功能。当UI增加了新的查询类型时,库随之成长,而我们也使SQL生成变得更为成熟。 曳光代码并非用过就扔的代码:你编写它,是为了保留它。它含有任何一段产品代码都拥有的完整的错误检查、结构、文档、以及自查。它只不过功能不全而已。但是,一旦你在系统的各组件间实现了端到端(end-to-end)的连接,你就可以检查你离目标还有多远,并在必要的情况下进行调整。一旦你完全瞄准,增加功能将是一件容易的事情。 曳光开发与项目永不会结束的理念是一致的:总有改动需要完成,总有功能需要增加。这是一个渐进的过程。 另一种传统做法是一种繁重的工程方法:把代码划分为模块,在真空中对模块进行编码。把模块组合成子配件(subassembly),再对子配件进行组合,直到有一天你拥有完整的应用为止。直到那时,才能把应用作为一个整体呈现给用户,并进行测试。 曳光代码方法有许多优点: l 用户能够及早看到能工作的东西。如果你成功地就你在做的事情与用户进行了交流(参见“极大的期望”,255页),用户就会知道他们看到的是还未完成的东西。他们不会因为缺少功能而失望;他们将因为看到了系统的某种可见的进展而欣喜陶醉。他们还会随着项目的进展做出贡献,增加他们的“买入”。同样是这些用户,他们很可能也会告诉你,每一轮“射击”距离目标有多接近。 l 开发者构建了一个他们能在其中工作的结构。最令人畏缩的纸是什么也没有写的白纸。如果你已经找出应用的所有端到端的交互,并把它们体现在代码里,你的团队就无须再无中生有。这让每个人都变得更有生产力,同时又促进了一致性。 l 你有了一个集成平台。随着系统端到端地连接起来,你拥有了一个环境,一旦新的代码段通过了单元测试,你就可以将其加入该环境中。你将每天进行集成(常常是一天进行多次),而不是尝试进行大爆炸式的集成。每一个新改动的影响都更为显而易见,而交互也更为有限,于是调试和测试将变得更快、更准确。 l 你有了可用于演示的东西。项目出资人与高级官员往往会在最不方便的时候来看演示。有了曳光代码,你总有东西可以拿给他们看。 l 你将更能够感觉到工作进展。在曳光代码开发中,开发者一个一个地处理用例(use case)。做完一个,再做下一个。评测性能、并向用户演示你的进展,变得容易了许多。因为每一项个别的开发都更小,你也避免了创建这样的整体式代码块:一周又一周,其完成度一直是95%。 曳光弹并非总能击中目标 曳光弹告诉你击中的是什么。那不一定总是目标。于是你调整准星,直到完全击中目标为止。这正是要点所在。 曳光代码也是如此。你在不能100%确定该去往何处的情形下使用这项技术。如果最初的几次尝试错过了目标——用户说:“那不是我的意思”,你需要的数据在你需要它时不可用,或是性能好像有问题——你不应感到惊奇。找出怎样改变已有的东西、让其更接近目标的办法,并且为你使用了一种简约的开发方法而感到高兴。小段代码的惯性也小——要改变它更容易、更迅速。你能够搜集关于你的应用的反馈,而且与其他任何方法相比,你能够花费较少代价、更为迅速地生成新的、更为准确的版本。同时,因为每个主要的应用组件都已表现在你的曳光代码中,用户可以确信,他们所看到的东西具有现实基础,不仅仅是纸上的规范。 曳光代码 vs. 原型制作 你也许会想,这种曳光代码的概念就是原型制作,只不过有一个更富“进攻性”的名字。它们有区别。使用原型,你是要探究最终系统的某些具体的方面。使用真正的原型,在对概念进行了试验之后,你会把你捆扎在一起的无论什么东西扔掉,并根据你学到的经验教训重新适当地进行编码。 例如,假定你在制作一个应用,其用途是帮助运货人确定怎样把不规则的箱子装入集装箱。 除了考虑其他一些问题,你还需要设计直观的用户界面,而你用于确定最优装箱方式的算法非常复杂。 你可以在GUI工具中为最终用户制作一个用户界面原型。你的代码只能让界面响应用户操作。一旦用户对界面布局表示同意,你可以把它扔掉,用目标语言重新对其进行编码,并在其后加上商业逻辑。与此类似,你可以为实际进行装箱的算法制作原型。你可以用像Perl这样的宽松的高级语言编写功能测试,并用更接近机器的某种语言编写低级的性能测试。无论如何,一旦你做出决策,你都会重新开始在其最终环境中为算法编写代码,与现实世界接合。这就是原型制作,它非常有用。 曳光代码方法处理的是不同的问题。你需要知道应用怎样结合成一个整体。你想要向用户演示,实际的交互是怎样工作的,同时你还想要给出一个架构骨架,开发者可以在其上增加代码。在这样的情况下,你可以构造一段曳光代码,其中含有一个极其简单的集装箱装箱算法实现(也许是像“先来先服务”这样的算法)和一个简单、但却能工作的用户界面。一旦你把应用中的所有组件都组合在一起,你就拥有了一个可以向你的用户和开发者演示的框架。接下来的时间里,你给这个框架增加新功能,完成预留了接口的例程。但框架仍保持完整,而你也知道,系统将会继续按照你第一次的曳光代码完成时的方式工作。 其间的区别很重要,足以让我们再重复一次。原型制作生成用过就扔的代码。曳光代码虽然简约,但却是完整的,并且构成了最终系统的骨架的一部分。你可以把原型制作视为在第一发曳光弹发射之前进行的侦察和情报搜集工作。 相关内容: l 足够好的软件,9页 l 原型与便笺,53页 l 规范陷阱,217页 l 极大的期望,255页 11 原型与便笺 许多不同的行业都使用原型试验具体的想法:与完全的制作相比,制作原型要便宜得多。例如,轿车制造商可以制造某种新车设计的许多不同的原型,每一种的设计目的都是要测试轿车的某个具体的方面——空气动力学、样式、结构特征,等等。也许会制造一个粘土模型,用于风洞测试,也许会为工艺部门制造一个轻木和胶带模型,等等。有些轿车公司更进一步,在计算机上进行大量的建模工作,从而进一步降低了开销。以这样的方式,可以试验危险或不确定的元件,而不用实际进行真实的制造。 我们以同样的方式构建软件原型,并且原因也一样——为了分析和揭示风险,并以大大降低的代价、为修正提供机会。与轿车制造商一样,我们可以把原型用于测试项目的一个或多个具体方面。 我们往往以为原型要以代码为基础,但它们并不总是非如此不可。与轿车制造商一样,我们可以用不同的材料构建原型。要为像工作流和应用逻辑这样的动态事物制作原型,便笺(post-it note)就非常好。用户界面的原型则可以是白板上的图形、或是用绘图程序或界面构建器绘制的无功能的模型。 原型的设计目的就是回答一些问题,所以与投入使用的产品应用相比,它们的开发要便宜得多、快捷得多。其代码可以忽略不重要的细节——在此刻对你不重要,但对后来的用户可能非常重要。例如,如果你在制作GUI原型,你不会因不正确的结果或数据而遭到指责。而另一方面,如果你只是在研究计算或性能方面的问题,你也不会因为相当糟糕的GUI而遭到指责;甚至也可以完全不要GUI。 但如果你发现自己处在不能放弃细节的环境中,就需要问自己,是否真的在构建原型。或许曳光弹开发方式更适合这种情况(参见“曳光弹”,48页)。 应制作原型的事物 你可以选择通过原型来研究什么样的事物呢?任何带有风险的事物。以前没有试过的事物,或是对于最终系统极端关键的事物。任何未被证明的、实验性的、或有疑问的事物。任何让你觉得不舒服的事物。你可以为下列事物制作原型: l 架构 l 已有系统中的新功能 l 外部数据的结构或内容 l 第三方工具或组件 l 性能问题 l 用户界面设计 原型制作是一种学习经验。其价值并不在于所产生的代码,而在于所学到的经验教训。那才是原型制作的要点所在。 提示16 Prototype to Learn 为了学习而制作原型 怎样使用原型 在构建原型时,你可以忽略哪些细节? l 正确性。你也许可以在适当的地方使用虚设的数据。 l 完整性。原型也许只能在非常有限的意义上工作,也许只有一项预先选择的输入数据和一个菜单项。 l 健壮性。错误检查很可能不完整,或是完全没有。如果你偏离预定路径,原型就可能崩溃,并在“烟火般的灿烂显示中焚毁”。这没有关系。 l 风格。在纸上承认这一点让人痛苦,但原型代码可能没有多少注释或文档。根据使用原型的经验,你也许会撰写出大量文档,但关于原型系统自身的内容相对而言却非常少。 因为原型应该遮盖细节,并聚焦于所考虑系统的某些具体方面,你可以用非常高级的语言实现原型——比项目的其余部分更高级(也许是像Perl、Python或Tcl这样的语言)。高级的脚本语言能让你推迟考虑许多细节(包括指定数据类型),并且仍然能制作出能工作的(即使不完整或速度慢)代码。如果你需要制作用户界面的原型,可研究像Tcl/Tk、Visual Basic、Powerbuilder或Delphi这样的工具。 作为能把低级的部分组合在一起的“胶合剂”,脚本语言工作良好。在Windows下,Visual Basic可以把COM控件胶合在一起。更一般地说,你可以使用像Perl和Python这样的语言,把低级的C库绑在一起——无论是手工进行,还是通过工具自动进行,比如可以自由获取的SWIG[URL 28]。采用这种方法,你可以快速地把现有组件装配进新的配置,从而了解它们的工作情况。 制作架构原型 许多原型被构造出来,是要为在考虑之下的整个系统建模。与曳光弹不同,在原型系统中,单个模块不需要能行使特定的功能。事实上,要制作架构原型,你甚至不一定需要进行编码——你可以用便笺或索引卡片、在白板上制作原型。你寻求的是了解系统怎样结合成为一个整体,并推迟考虑细节。下面是一些你可以在架构原型中寻求解答的具体问题: l 主要组件的责任是否得到了良好定义?是否适当? l 主要组件间的协作是否得到了良好定义? l 耦合是否得以最小化? l 你能否确定重复的潜在来源? l 接口定义和各项约束是否可接受? l 每个模块在执行过程中是否能访问到其所需的数据?是否能在需要时进行访问? 根据我们制作原型的经验,最后一项往往会产生最让人惊讶和最有价值的结果。 怎样“不”使用原型 在你着手制作任何基于代码的原型之前,先确定每个人都理解你正在编写用过就扔的代码。对于不知道那只是原型的人,原型可能会具有欺骗性的吸引力。你必须非常清楚地说明,这些代码是用过就扔的,它们不完整,也不可能完整。 别人很容易被演示原型外表的完整性误导,而如果你没有设定正确的期望值,项目出资人或管理部门可能会坚持要部署原型(或其后裔)。提醒他们,你可以用轻木和胶带制造一辆了不起的新车原型,但你却不会在高峰时间的车流中驾驶它。 如果你觉得在你所在的环境或文化中,原型代码的目的很有可能被误解,你也许最好还是采用曳光弹方法。你最后将得到一个坚实的框架,为将来的开发奠定基础。 适当地使用原型,可以帮助你在开发周期的早期确定和改正潜在的问题点——在此时改正错误既便宜、又容易——从而为你节省大量时间、金钱,并大大减轻你遭受的痛苦和折磨。 相关内容: l 我的源码让猫给吃了,2页 l 交流!,18页 l 曳光弹,48页 l 极大的期望,255页 练习 4. 市场部门想要坐下来和你一起讨论一些网页的设计问题。他们想用可点击的图像进行页面导航,但却不能确定该用什么图像模型——也许是轿车、电话或是房子。你有一些目标网页和内容;他们想要看到一些原型。哦,随便说一下,你只有15分钟。你可以使用什么样的工具? (解答在280页) 12 领域语言 语言的界限就是一个人的世界的界限。 ——维特根斯坦 计算机语言会影响你思考问题的方式,以及你看待交流的方式。每种语言都含有一系列特性——比如静态类型与动态类型、早期绑定与迟后绑定、继承模型(单、多或无)这样的时髦话语——所有这些特性都在提示或遮蔽特定的解决方案。头脑里想着Lisp设计的解决方案将会产生与基于C风格的思考方式而设计的解决方案不同的结果,反之亦然。与此相反——我们认为这更重要——问题领域的语言也可能会提示出编程方案。 我们总是设法使用应用领域的语汇来编写代码(参见210页的需求之坑,我们在那里提出要使用项目词汇表)。在某些情况下,我们可以更进一层,采用领域的语汇、语法、语义——语言——实际进行编程。 当你听取某个提议中的系统的用户说明情况时,他们也许能确切地告诉你,系统应怎样工作: 在一组X.25线路上侦听由ABC规程12.3定义的交易,把它们转译成XYZ公司的43B格式,在卫星上行链路上重新传输,并存储起来,供将来分析使用。 如果用户有一些这样的做了良好限定的陈述,你可以发明一种为应用领域进行了适当剪裁的小型语言,确切地表达他们的需要: From X25LINE1 (Format=ABC123) { Put TELSTAR1 (Format=XYZ43B); Store DB; } 该语言无须是可执行的。一开始,它可以只是用于捕捉用户需求的一种方式——一种规范。但是,你可能想要更进一步,实际实现该语言。你的规范变成了可执行代码。 在你编写完应用之后,用户给了你一项新需求:不应存储余额为负的交易,而应以原来的格式在X.25线路上发送回去: From X25LINE1 (Format=ABC123) { if (ABC123.balance < 0) { Put X25LINE1 (Format=ABC123); } else { Put TELSTAR1 (Format=XYZ43B); Store DB; } } 很容易,不是吗?有了适当的支持,你可以用大大接近应用领域的方式进行编程。我们并不是在建议让你的最终用户用这些语言实际编程。相反,你给了自己一个工具,能够让你更靠近他们的领域工作。 提示17 Program Close to the Problem domain 靠近问题领域编程 无论是用于配置和控制应用程序的简单语言,还是用于指定规则或过程的更为复杂的语言,我们认为,你都应该考虑让你的项目更靠近问题领域。通过在更高的抽象层面上编码,你获得了专心解决领域问题的自由,并且可以忽略琐碎的实现细节。 记住,应用有许多用户。有最终用户,他们了解商业规则和所需输出;也有次级用户:操作人员、配置与测试管理人员、支持与维护程序员,还有将来的开发者。他们都有各自的问题领域,而你可以为他们所有人生成小型环境和语言。 具体领域的错误 如果你是在问题领域中编写程序,你也可以通过用户可以理解的术语进行具体领域的验证,或是报告问题。以上一页我们的交换应用为例,假定用户拼错了格式名: From X25LINE1 (Format=AB123) 如果这发生在某种标准的、通用的编程语言中,你可能会收到一条标准的、通用的错误消息: Syntax error: undeclared identifier 但使用小型语言,你却能够使用该领域的语汇发出错误消息: "AB123" is not a format. known formats are ABC123, XYZ43B, PDQB, and 42. 实现小型语言 在最简单的情况下,小型语言可以采用面向行的、易于解析的格式。在实践中,与其他任何格式相比,我们很可能会更多地使用这样的格式。只要使用switch语句、或是使用像Perl这样的脚本语言中的正则表达式,就能够对其进行解析。281页上练习5的解答给出了一种用C编写的简单实现。 你还可以用更为正式的语法,实现更为复杂的语言。这里的诀窍是首先使用像BNF这样的表示法定义语法。一旦规定了文法,要将其转换为解析器生成器(parser generator)的输入语法通常就非常简单了。C和C++程序员多年来一直在使用yacc(或其可自由获取的实现,bison[URL 27])。在Lex and Yacc[LMB92]一书中详细地讲述了这些程序。Java程序员可以选用javaCC,可在[URL 26]处获取该程序。282页上练习7的解答给出了一个用bison编写的解析器。如其所示,一旦你了解了语法,编写简单的小型语言实在没有多少工作要做。 要实现小型语言还有另一种途径:扩展已有的语言。例如,你可以把应用级功能与Python[URL 9]集成在一起,编写像这样的代码: record = X25LINE1.get(format=ABC123) if (record.balance < 0): X25LINE1.put(record, format=ABC123) else: TELSTAR1.put(record, format=XYZ43B) DB.store(record) 数据语言与命令语言 可以通过两种不同的方式使用你实现的语言。 数据语言产生某种形式的数据结构给应用使用。这些语言常用于表示配置信息。 例如,sendmail程序在世界各地被用于在Internet上转发电子邮件。它具有许多杰出的特性和优点,由一个上千行的配置文件控制,用sendmail自己的配置语言编写: Mlocal, P=/usr/bin/procmail, F=lsDFMAw5 :/|@qSPfhn9, S=10/30, R=20/40, T=DNS/RFC822/X-Unix, A=procmail -Y -a $h -d $u 显然,可读性不是sendmail的强项。 多年以来,Microsoft一直在使用一种可以描述菜单、widget(窗口小部件)、对话框及其他Windows资源的数据语言。下一页上的图2.2摘录了一段典型的资源文件。这比sendmail的配置文件要易读得多,但其使用方式却完全一样——我们编译它,以生成数据结构。 命令语言更进了一步。在这种情况下,语言被实际执行,所以可以包含语句、控制结构、以及类似的东西(比如58页上的脚本)。 图2.2 Windows .rc文件 你也可以使用自己的命令语言来使程序易于维护。例如,也许用户要求你把来自某个遗留应用的信息集成进你的新GUI开发中。要完成这一任务,常用的方法是“刮屏”(screen scraping):你的应用连接到主机应用,就好像它是正常的使用人员;发出键击,并“阅读”取回的响应。你可以使用一种小型语言来把这样的交互编写成脚本: locate prompt "SSN:" type "%s" social_security_number type enter waitfor keyboardunlock if text_at(10,14) is "INVALID SSN" return bad_ssn if text_at(10,14) is "DUPLICATE SSN" return dup_ssn # etc... 当应用确定是时候输入社会保障号时,它调用解释器执行这个脚本,后者随即对事务进行控制。如果解释器是嵌入在应用中的,两者甚至可以直接共享数据(例如,通过回调机制)。 这里你是在维护程序员(maintenace programmer)的领域中编程。当主机应用发生变化、字段移往别处时,程序员只需更新你的高级描述,而不用钻入C代码的各种细节中。 独立语言与嵌入式语言 要发挥作用,小型语言无须由应用直接使用。许多时候,我们可以使用规范语言创建各种由程序自身编译、读入或用于其他用途的制品(包括元数据。参见元程序设计,144页)。 例如,在100页我们将描述一个系统,在其中我们使用Perl、根据原始的schema规范生成大量衍生物。我们发明了一种用于表示数据库schema的通用语言,然后生成我们所需的所有形式——SQL、C、网页、XML,等等。应用不直接使用规范,但它依赖于根据规范产生的输出。 把高级命令语言直接嵌入你的应用是一种常见做法,这样,它们就会在你的代码运行时执行。这显然是一种强大的能力;通过改变应用读取的脚本,你可以改变应用的行为,却完全不用编译。这可以显著地简化动态的应用领域中的维护工作。 易于开发还是易于维护 我们已经看到若干不同的文法,范围从简单的面向行的格式到更为复杂的、看起来像真正的语言的文法。既然实现更为复杂的文法需要额外的努力,你又为何要这样做呢? 权衡要素是可扩展性与维护。尽管解析“真正的”语言所需的代码可能更难编写,但它却容易被人理解得多,并且将来用新特性和新功能进行扩展也要容易得多。太简单的语言也许容易解析,但却可能晦涩难懂——很像是60页上的sendmail例子。 考虑到大多数应用都会超过预期的使用期限,你可能最好咬紧牙关,先就采用更复杂、可读性更好的语言。最初的努力将在降低支持与维护费用方面得到许多倍的回报。 相关内容: l 元程序设计,144页 挑战 l 你目前的项目的某些需求是否能以具体领域的语言表示?是否有可能编写编译器或转译器,生成大多数所需代码? l 如果你决定采用小型语言作为更接近问题领域的编程方式,你就是接受了,实现它们需要一些努力。你能否找到一些途径,通过它们把你为某个项目开发的框架复用于其他项目? 练习 5. 我们想实现一种小型语言,用于控制一种简单的绘图包(或许是一种“海龟图形”(turtle-graphics)系统)。这种语言由单字母命令组成。有些命令后跟单个数字。例如,下面的输入将会绘制出一个矩形: P 2 # select pen 2 D # pen down W 2 # draw west 2cm N 1 # then north 1 E 2 # then east 2 S 1 # then back south U # pen up 请实现解析这种语言的代码。它应该被设计成能简单地增加新命令。(解答在281页) 6. 设计一种解析时间规范的BNF文法。应能接受下面的所有例子:(解答在282页) 4pm, 7:38pm, 23:42, 3:16, 3:16am 7. 用yacc、bison或类似的解析器生成器为练习6中的BNF文法实现解析器。(解答在282页) 8. 用Perl实现时间解析器(提示:正则表达式可带来好的解析器)。(解答在283页) 13 估算 快!通过56k modem线发送《战争与和平》需要多少时间?存储一百万个姓名与地址需要多少磁盘空间?1 000字节的数据块通过路由器需要多少时间?交付你的项目需要多少个月? 在某种程度上,这些都是没有意义的问题——它们都缺少信息。然而它们仍然可以得到回答,只要你习惯于进行估算。同时,在进行估算的过程中,你将会加深对你的程序所处的世界的理解。 通过学习估算,并将此技能发展到你对事物的数量级有直觉的程度,你就能展现出一种魔法般的能力,确定它们的可行性。当有人说“我们将通过ISDN线路把备份发给中央站点”时,你将能够直觉地知道那是否实际。当你编码时,你将能够知道哪些子系统需要优化,哪些可以放在一边。 提示18 Estimate to Avoid Surprises 估算,以避免发生意外 作为奖励,在这一节的末尾我们将透露一个总是正确的答案——无论什么时候有人要你进行估算,你都可以给出答案。 多准确才足够准确 在某种程度上,所有的解答都是估算。只不过有一些要比其他的更准确。所以当有人要你进行估算时,你要问自己的第一个问题就是,你解答问题的语境是什么?他们是需要高度的准确性,还是在考虑棒球场的大小? l 如果你的奶奶问你何时抵达,她也许只是想知道该给你准备午餐还是晚餐。而一个困在水下、空气就快用光的潜水员很可能对精确到秒的答案更感兴趣。 l p的值是多少?如果你想知道的是要买多少饰边,才能把一个圆形花坛围起来,那么“3”很可能就足够好了。如果你在学校里,那么“22/7”也许就是一个好的近似值。如果你在NASA(美国国家航空航天管理局),那么也许要12个小数位。 关于估算,一件有趣的事情是,你使用的单位会对结果的解读造成影响。如果你说,某事需要130个工作日,那么大家会期望它在相当接近的时间里完成。但是,如果你说“哦,大概要六个月”,那么大家知道它会在从现在开始的五到七个月内完成。这两个数字表示相同的时长,但“130天”却可能暗含了比你的感觉更高的精确程度。我们建议你这样度量时间估算: 时长 报出估算的单位 1-15天 天 3-8周 周 8-30周 月 30+周 在给出估算前努力思考一下 于是,在完成了所有必要的工作之后,你确定项目将需要125个工作日(25周),你可以给出“大约六个月”的估算。 同样的概念适用于对任何数量的估算:要选择能反映你想要传达的精确度的单位。 13 估算(2) 估算来自哪里 所有的估算都以问题的模型为基础。但在我们过深地卷入建模技术之前,我们必须先提及一个基本的估算诀窍,它总能给出好的答案:去问已经做过这件事情的人。在你一头钻进建模之前,仔细在周围找找也曾处在类似情况下的人。 看看他们的问题是怎么解决的。你不大可能找到完全相符的案例,但你会惊奇有多少次,你能够成功地借鉴他人的经验。 理解提问内容 任何估算练习的第一步都是建立对提问内容的理解。除了上面讨论的精确度问题以外,你还需要把握问题域的范围。这常常隐含在问题中,但你需要养成在开始猜想之前先思考范围的习惯。常常,你选择的范围将形成你给出的解答的一部分:“假定没有交通意外,而且车里还有汽油,我会在20分钟内赶到那里。” 建立系统的模型 这是估算有趣的部分。根据你对所提问题的理解,建立粗略、就绪的思维模型骨架。如果你是在估算响应时间,你的模型也许要涉及服务器和某种到达流量(arriving traffic)。对于一个项目,模型可以是你的组织在开发过程中所用的步骤、以及系统的实现方式的非常粗略的图景。 建模既可以是创造性的,又可以是长期有用的。在建模的过程中,你常常会发现一些在表面上不明显的底层模式与过程。你甚至可能会想要重新检查原来的问题:“你要求对做X所需的时间进行估算。但好像X的变种Y只需一半时间就能完成,而你只会损失一个特性。” 建模把不精确性引入了估算过程中。这是不可避免的,而且也是有益的。你是在用模型的简单性与精确性做交易。使花在模型上的努力加倍也许只能带来精确性的轻微提高。你的经验将告诉你何时停止提炼。 把模型分解为组件 一旦拥有了模型,你可以把它分解为组件。你须要找出描述这些组件怎样交互的数学规则。有时某个组件会提供一个值,加入到结果中。有些组件有着成倍的影响,而另一些可能会更为复杂(比如那些模拟某个节点上的到达流量的组件)。 你将会发现,在典型情况下,每个组件都有一些参数,会对它给整个模型带来什么造成影响。在这一阶段,只要确定每个参数就行了。 给每个参数指定值 一旦你分解出各个参数,你就可以逐一给每个参数赋值。在这个步骤中你可能会引入一些错误。诀窍是找出哪些参数对结果的影响最大,并致力于让它们大致正确。在典型情况下,其值被直接加入结果的参数,没有被乘或除的那些参数重要。让线路速度加倍可以让1小时内接收的数据量加倍,而增加5毫秒的传输延迟不会有显著的效果。 你应该采用一种合理的方式计算这些关键参数。对于排队的例子,你可以测量现有系统的实际事务到达率,或是找一个类似的系统进行测量。与此类似,你可以测量现在服务1个请求所花的时间,或是使用这一节描述的技术进行估算。事实上,你常常会发现自己以其他子估算为基础进行估算。这是最大的错误伺机溜进来的地方。 计算答案 只有在最简单的情况下估算才有单一的答案。你也许会高兴地说:“我能在15分钟内走完五个街区。”但是,当系统变得更为复杂时,你就会避免做出正面回答。进行多次计算,改变关键参数的值,直到你找出真正主导模型的那些参数。电子表格可以有很大帮助。然后根据这些参数表述你的答案。“如果系统拥有SCSI总线和64MB内存,响应时间约为四分之三秒;如果内存是48MB,则响应时间约为一秒。”(注意“四分之三秒”怎样给人以一种与750毫秒不同的精确感。) 在计算阶段,你可能会得到看起来很奇怪的答案。不要太快放弃它们。如果你的运算是正确的,那你对问题或模型的理解就很可能是错的。这是非常宝贵的信息。 追踪你的估算能力 我们认为,记录你的估算,从而让你看到自己接近正确答案的程度,这是一个非常好的主意。如果总体估算涉及子估算的计算,那么也要追踪这些子估算。你常常会发现自己估算得非常好——事实上,一段时间之后,你就会开始期待这样的事情。 如果结果证明估算错了,不要只是耸耸肩走开。找出事情为何与你的猜想不同的原因。也许你选择了与问题的实际情况不符的一些参数。也许你的模型是错的。不管原因是什么,花一点时间揭开所发生的事情。如果你这样做了,你的下一次估算就会更好。 估算项目进度 在面对相当大的应用开发的各种复杂问题与反复无常的情况时,普通的估算规则可能会失效。我们发现,为项目确定进度表的惟一途径常常是在相同的项目上获取经验。如果你实行增量开发、重复下面的步骤,这不一定就是一个悖论: l 检查需求 l 分析风险 l 设计、实现、集成 l 向用户确认 一开始,你对需要多少次迭代、或是需要多少时间,也许只有模糊的概念。有些方法要求你把这个作为初始计划的一部分定下来,但除了最微不足道的项目,这是一个错误。除非你在开发与前一个应用类似的应用,拥有同样的团队和同样的技术,否则,你就只不过是在猜想。 于是你完成了初始功能的编码与测试,并将此标记为第一轮增量开发的结束。基于这样的经验,你可以提炼你原来对迭代次数、以及在每次迭代中可以包含的内容的猜想。提炼会变得一次比一次好,对进度表的信心也将随之增长。 提示19 Iterate the Schedule with the Code 通过代码对进度表进行迭代 这也许并不会受到管理部门的欢迎,在典型情况下,他们想要的是单一的、必须遵守的数字——甚至是在项目开始之前。你必须帮助他们了解团队、团队的生产率、还有环境将决定进度。通过使其形式化,并把改进进度表作为每次迭代的一部分,你将给予他们你所能给予的最精确的进度估算。 在被要求进行估算时说什么 你说:“我等会儿回答你。” 如果你放慢估算的速度,并花一点时间仔细检查我们在这一节描述的步骤,你几乎总能得到更好的结果。在咖啡机旁给出的估算将(像咖啡一样)回来纠缠你。 相关内容 l 算法速度,177页 挑战 l 开始写估算日志。追踪每一次估算的精确程度。如果你的错误率大于50%,设法找出你的估算误入歧途的地方。 练习 9. 有人问你:“1Mbps的通信线路和在口袋里装了4GB磁带、在两台计算机间步行的人,哪一个的带宽更高?”你要对你的答案附加什么约束,以确保你的答复的范围是正确的?(例如,你可以说,访问磁带所花时间忽略不计。) (解答在283页) 10. 那么,哪一个带宽更高? (解答在284页) 14 纯文本的威力 每个工匠在开始其职业生涯时,都会准备一套品质良好的基本工具。木匠可能需要尺、计量器、几把锯子、几把好刨子、精良的凿子、钻孔器和夹子、锤子还有钳子。这些工具将经过认真挑选、打造得坚固耐用、并用于完成很少与其他工具重合的特定工作,而且,也许最重要的是,刚刚出道的木匠把它们拿在手里会觉得很顺手。 随后学习与适应的过程就开始了。每样工具都有自身的特性和古怪之处,并且需要得到相应的特殊对待。每样工具都需要以独特的方式进行打磨,或者以独特的方式把持。随着时间的过去,每样工具都会因使用而磨损,直到手柄看上去就像是木匠双手的模子,而切割面与握持工具的角度完全吻合。到这时,工具变成了工匠的头脑与所完成的产品之间的通道——它们变成了工匠双手的延伸。木匠将不时增添新的工具,比如饼式切坯机、激光制导斜切锯、楔形模具——全都是奇妙的技术,但你可以肯定的是,当他把原来的某样工具拿在手里,当他听到刨子滑过木料发出的歌声时,那是他最高兴的时候。 工具放大你的才干。你的工具越好,你越是能更好地掌握它们的用法,你的生产力就越高。从一套基本的通用工具开始,随着经验的获得,随着你遇到一些特殊需求,你将会在其中增添新的工具。要与工匠一样,想着定期增添工具。要总是寻找更好的做事方式。如果你遇到某种情况,你觉得现有的工具不能解决问题,记得去寻找可能会有帮助的其他工具或更强大的工具。 让需要驱动你的采购。 许多新程序员都会犯下错误,采用单一的强力工具,比如特定的集成开发环境(IDE),而且再也不离开其舒适的界面。这实在是个错误。我们要乐于超越IDE所施加的各种限制。要做到这一点,惟一的途径是保持基本工具集的“锋利”与就绪。 在本章我们将讨论怎样为你自己的基本工具箱投资。与关于工具的任何好的讨论一样,我们将从考察你的原材料——你将要制作的东西——开始(在“纯文本的威力”中)。然后我们将从那里转向工作台(workbench),在我们的工作范围也就是计算机。要怎样使用计算机,你才能最大限度地利用你所用的工具?我们将在shell游戏中讨论这一问题。现在我们有了工作所需的材料及工作台,我们将转向一样你可能用得最频繁的工具:你的编辑器。在强力编辑中,我们将提出多种让你更有效率的途径。 为了确保不会丢失先前的任何工作成果,我们应该总是使用源码控制系统——即使是像我们的个人地址簿这样的东西!同时,因为Murphy先生实在是一个乐观主义者,如果你没有高超的调试技能,你就不可能成为了不起的程序员。 你需要一些“胶合剂”,把大量魔术“粘”在一起。我们将在文本操纵中讨论一些可能的方案,比如awk、Perl以及Python。 就如同木匠有时会制作模具,用以控制复杂工件的打造一样,程序员也可以编写自身能编写代码的代码。我们将在“代码生成器”中讨论这一问题。 花时间学习使用这些工具,有一天你将会惊奇地发现,你的手指在键盘上移动,操纵文本,却不用进行有意识的思考。工具将变成你的双手的延伸。 纯文本的威力 作为注重实效的程序员,我们的基本材料不是木头,不是铁,而是知识。我们搜集需求,将其变为知识,随后又在我们的设计、实现、测试、以及文档中表达这些知识。而且我们相信,持久地存储知识的最佳格式是纯文本。通过纯文本,我们给予了自己既能以手工方式、也能以程序方式操纵知识的能力——实际上可以随意使用每一样工具。 什么是纯文本 纯文本由可打印字符组成,人可以直接阅读和理解其形式。例如,尽管下面的片段由可打印字符组成,它却是无意义的: Fieldl9=467abe 阅读者不知道467abe的含义是什么。更好的选择是让其变得能让人理解: DrawingType=UMLActivityDrawing 纯文本并非意味着文本是无结构的;XML、SGML和HTML都是有良好定义的结构的纯文本的好例子。通过纯文本,你可以做你通过某种二进制格式所能做的每件事情,其中包括版本管理。 与直接的二进制编码相比,纯文本所处的层面往往更高;前者通常直接源自实现。假定你想要存储叫做uses_menus的属性,其值既可为TRUE,也可为FALSE。使用纯文本,你可以将其写为: myprop.uses_menus=FALSE 把它与0010010101110101对比一下。 大多数二进制格式的问题在于,理解数据所必需的语境与数据本身是分离的。你人为地使数据与其含义脱离开来。数据也可能加了密;没有应用逻辑对其进行解析,这些数据绝对没有意义。但是,通过纯文本,你可以获得自描述(self-describing)的、不依赖于创建它的应用的数据流。 提示20 Keep Knowledge in Plain Text 用纯文本保存知识 缺点 使用纯文本有两个主要缺点:(1)与压缩的二进制格式相比,存储纯文本所需空间更多,(2)要解释及处理纯文本文件,计算上的代价可能更昂贵。 取决于你的应用,这两种情况或其中之一可能让人无法接受——例如,在存储卫星遥测数据时,或是用做关系数据库的内部格式时。 但即使是在这些情况下,用纯文本存储关于原始数据的元数据也可能是可以接受的(参见“元程序设计”,144页)。 有些开发者可能会担心,用纯文本存储元数据,是在把这些数据暴露给系统的用户。这种担心放错了地方。与纯文本相比,二进制数据也许更晦涩难懂,但却并非更安全。如果你担心用户看到密码,就进行加密。如果你不想让他们改变配置参数,就在文件中包含所有参数值的安全哈希值作作为校验和。 文本的威力 既然更大和更慢不是用户最想要的特性,为什么还要使用纯文本?好处是什么? l 保证不过时 l 杠杆作用 l 更易于测试 保证不过时 人能够阅读的数据形式,以及自描述的数据,将比所有其他的数据形式和创建它们的应用都活得更长久。句号。 只要数据还存在,你就有机会使用它——也许是在原来创建它的应用已经不存在很久之后。 只需部分地了解其格式,你就可以解析这样的文件;而对于大多数二进制文件,要成功地进行解析,你必须了解整个格式的所有细节。 考虑一个来自某遗留系统的数据文件。关于原来的应用你的了解很少;对你来说最要紧的是它保存了客户的社会保障号列表,你需要找出这些保障号,并将其提取出来。在数据文件中,你看到: 123-45-6789 ... 567-89-0123 ... 901-23-4567 识别出了社会保障号的格式,你可以很快写一个小程序提取该数据——即使你没有关于文件中其他任何东西的信息。 但设想一下,如果该文件的格式是这样的: AC27123456789B11P ... XY43567890123QTYL ... 6T2190123456788AM 你可能就不会那么轻松地识别出这些数字的含义了。这是人能够阅读(human readable)与人能够理解(human understandable)之间的区别。 在我们进行解析时,FIELD10的帮助也不大。改成 123-45-6789 就会让这个练习变得一点也不费脑子——而且这些数据保证会比创建它的任何项目都活得更长久。 杠杆作用 实际上,计算世界中的每一样工具,从源码管理系统到编译器环境,再到编辑器及独立的过滤器,都能够在纯文本上进行操作。 Unix哲学 提供“锋利”的小工具、其中每一样都意在把一件事情做好——Unix因围绕这样的哲学进行设计而著称。这一哲学通过使用公共的底层格式得以实行:面向行的纯文本文件。用于系统管理(用户及密码、网络配置,等等)的数据库全都作为纯文本文件保存(有些系统,比如Solaris,为了优化性能,还维护有特定数据的二进制形式。纯文本版本保留用作通往二进制版本的接口)。 当系统崩溃时,你可能需要通过最小限度的环境进行恢复(例如,你可能无法访问图形驱动程序)。像这样的情形,实在可以让你欣赏到纯文本的简单性。 例如,假定你要对一个大型应用进行产品部署,该应用具有复杂的针对具体现场的配置文件(我们想到sendmail)。如果该文件是纯文本格式的,你可以把它置于源码控制系统的管理之下(参见源码控制,86页),这样你就可以自动保存所有改动的历史。像diff和fc这样的文件比较工具允许你查看做了哪些改动,而sum允许你生成校验和,用以监视文件是否受到了偶然的(或恶意的)修改。 更易于测试 如果你用纯文本创建用于驱动系统测试的合成数据,那么增加、更新、或是修改测试数据就是一件简单的事情,而且无须为此创建任何特殊工具。与此类似,你可以非常轻松地分析回归测试(regression test)输出的纯文本,或通过Perl、Python及其他脚本工具进行更为全面彻底的检查。 最小公分母 即使在未来,基于XML的智能代理已能自治地穿越混乱、危险的Internet、自行协商数据交换,无处不在的纯文本也仍然会存在。事实上,在异种环境中,纯文本的优点比其所有的缺点都重要。你需要确保所有各方能够使用公共标准进行通信。纯文本就是那个标准。 相关内容: l 源码控制,86页 l 代码生成器,102页 l 元程序设计,144页 l 黑板,165页 l 无处不在的自动化,230页 l 全都是写,248页 挑战 l 使用你喜欢的语言,用直接的二进制表示设计一个小地址簿数据库(姓名、电话号码,等等)。完成以后再继续往下读。 1. 把该格式转换成使用XML的纯文本格式。 2. 在这两个版本中,增加一个新的、叫做方向的变长字段,在其中你可以输入每个人的住宅所在的方向。 在版本管理与可扩展性方面会遇到什么问题?哪种形式更易于修改?转换已有的数据呢? 15 shell游戏 每个木匠都需要好用、坚固、可靠的工作台,用以在加工工件时把工件放置在方便的高度上。工作台成为木工房的中心,随着工件的成形,木匠会一次次回到工作台的近旁。 对于操纵文本文件的程序员,工作台就是命令shell。在shell提示下,你可以调用你的全套工具,并使用管道、以这些工具原来的开发者从未想过的方式把它们组合在一起。在shell下,你可以启动应用、调试器、浏览器、编辑器以及各种实用程序。你可以搜索文件、查询系统状态、过滤输出。通过对shell进行编程,你可以构建复杂的宏命令,用来完成你经常进行的各种活动。 对于在GUI界面和集成开发环境(IDE)上成长起来的程序员,这似乎显得很极端。毕竟,用鼠标指指点点,你不是也同样能把这些事情做好吗? 简单的回答:“不能”。GUI界面很奇妙,对于某些简单操作,它们也可能更快、更方便。移动文件、阅读MIME编码的电子邮件以及写信,这都是你可能想要在图形环境中完成的事情。但如果你使用GUI完成所有的工作,你就会错过你的环境的某些能力。你将无法使常见任务自动化,或是利用各种可用工具的全部力量。同时,你也将无法组合你的各种工具,创建定制的宏工具。GUI的好处是WYSIWYG——所见即所得(what you see is what you get)。缺点是WYSIAYG——所见即全部所得(what you see is all you get)。 GUI环境通常受限于它们的设计者想要提供的能力。如果你需要超越设计者提供的模型,你大概不会那么走运——而且很多时候,你确实需要超越这些模型。注重实效的程序员并非只是剪切代码、或是开发对象模型、或是撰写文档、或是使构建过程自动化——所有这些事情我们全都要做。通常,任何一样工具的适用范围都局限于该工具预期要完成的任务。例如,假定你需要把代码预处理器集成进你的IDE中(为了实现按合约设计、多处理编译指示,等等)。除非IDE的设计者明确地为这种能力提供了挂钩,否则,你无法做到这一点。 你也许已经习惯于在命令提示下工作,在这种情况下,你可以放心地跳过这一节。否则,你也许还需要我们向你证明,shell是你的朋友。 作为注重实效的程序员,你不断地想要执行特别的操作——GUI可能不支持的操作。当你想要快速地组合一些命令,以完成一次查询或某种其他的任务时,命令行要更为适宜。这里有一些例子: 找出修改日期比你的Makefile的修改日期更近的全部.c文件。 Shell find . -name ' *.c' –newer Makefile –print GUI 打开资源管理器,转到正确的目录,点击Makefile,记下修改时间。然后调出 “工具/查找”,在指定文件处输入*.c。选择“日期”选项卡,在第一个日期字段中输入你记下的Makefile的日期。然后点击“确定”。 构造我的源码的zip/tar存档文件。 Shell zip archive.zip *.h *.c 或 tar cvf archive.tar *.h *.c GUI 调出ZIP实用程序(比如共享软件WinZip[URL 41]),选择[创建新存档文件],输入它的名称,在“增加”对话框中选择源目录,把过滤器设置为“*.c”,点击“增加”,把过滤器设置为“*.h”,点击“增加”,然后关闭存档文件。 在上周哪些Java文件没有改动过? Shell find . -name '*.java' -mtime +7 –print GUI 点击并转到“查找文件”,点击“文件名”字段,敲入“*.java”,选择“修改日期”选项卡。然后选择“介于”。点击“开始日期”,敲入项目开始的日期。点击“结束日期”,敲入1周以前的日期(确保手边有日历)。点击“开始查找”。 上面的文件中,哪些使用了awt库? Shell find . -name '*.java' -mtime +7 -print | xargs grep 'java.awt' GUI 把前面的例子列出的各个文件装入编辑器,搜索字符串“Java.awt”。把含有该字符串的文件的名字写下来。 显然,这样的例子还可以一直举下去。shell命令可能很晦涩,或是太简略,但却很强大,也很简练。同时,因为shell命令可被组合进脚本文件(或是Windows下的命令文件)中,你可以构建命令序列,使你常做的事情自动化。 提示21 Use the Power of Command Shells 利用命令shell的力量 去熟悉shell,你会发现自己的生产率迅速提高。需要创建你的Java代码显式导入的全部软件包的列表(重复的只列出一次)?下面的命令将其存储在叫做“list”的文件中: grep '^import ' *.java | sed -e's/.*import *//' -e's/;.*$//' | sort -u >list 如果你没有花大量时间研究过你所用系统上的命令shell的各种能力,这样的命令会显得很吓人。但是,投入一些精力去熟悉你的shell,事情很快就会变得清楚起来。多使用你的命令shell,你会惊讶它能使你的生产率得到怎样的提高。 shell实用程序与Windows系统 尽管随Windows系统提供的命令shell在逐步改进,Windows命令行实用程序仍然不如对应的Unix实用程序。但是,并非一切都已无可挽回。 Cygnus Solutions公司有一个叫做Cygwin[URL 31]的软件包。除了为Windows提供Unix兼容层以外,Cygwin还带有120多个Unix实用程序,包括像ls、grep和find这样的很受欢迎的程序。你可以自由下载并使用这些实用程序和库,但一定要阅读它们的许可。随同Cygwin发布的还有Bash shell。 在Windows下使用Unix工具 在Windows下有高质量的Unix工具可用,这让我们很高兴;我们每天都使用它们。但是,要注意存在一些集成问题。与对应的MS-DOS工具不同,这些实用程序对文件名的大小写敏感,所以ls a*.bat不会找到AUTOEXEC.BAT。你还可能遇到含有空格的文件名、或是路径分隔符不同所带来的问题。最后,在Unix shell下运行需要MS-DOS风格的参数的MS-DOS程序时,会发生一些有趣的问题。例如,在Unix下,来自JavaSoft的Java实用程序使用冒号作为CLASSPATH分隔符,而在MS-DOS下使用的却是分号。结果,运行在Unix机器上的Bash或ksh脚本在Windows下也同样能运行,但它传给Java的命令行却会被错误地解释。 另外,David Korn(因Korn shell而闻名)制作了一个叫做UWIN的软件包。其目标与Cygwin相同——它是Windows下的Unix开发环境。UWIN带有Korn shell的一个版本。也可从Global Technologies, Ltd.[URL 30]获取商业版本。此外,AT&T提供了该软件包的自由下载版本,用于评估和学术研究。再次说明,在使用之前要先阅读它们的许可。 最后,Tom Christiansen(在本书撰写的同时)正在制作Perl Power Tools,尝试用Perl可移植地实现所有常见的Unix实用程序[URL 32]。 相关内容: l 无处不在的自动化,230页 挑战 l 你目前是否在GUI中用手工做一些事情?你是否曾将一些说明发给同事,其中涉及许多“点这个按钮”、“选哪一项”之类的步骤?它们能自动化吗? l 每当你迁往新环境时,要找出可以使用的shell。看是否能把现在使用的shell带过去。 l 调查各种可用于替换你现在的shell的选择。如果你遇到你的shell无法处理的问题,看其他shell是否能更好地应对。 16 强力编辑 先前我们说过,工具是手的延伸。噢,与任何其他软件工具相比,这都更适用于编辑器。你需要能尽可能不费力气地操纵文本,因为文本是编程的基本原材料。让我们来看一些能帮助你最大限度地利用编辑环境的一些常见特性和功能。 一种编辑器 我们认为你最好是精通一种编辑器,并将其用于所有编辑任务:代码、文档、备忘录、系统管理,等等。如果不坚持使用一种编辑器,你就可能会面临现代的巴别塔大混乱。你可能必须用每种语言的IDE内建的编辑器进行编码,用“all-in-one”办公软件编辑文档,或是用另一种内建的编辑器发送电子邮件。甚至你用于在shell中编辑命令行的键击都有可能不同。如果你在每种环境中有不同的编辑约定和命令,要精通这些环境中的任何一种都会很困难。 你需要的是精通。只是依次输入、并使用鼠标进行剪贴是不够的。那样,在你的手中有了一个强大的编辑器,你却无法发挥出它的效能。敲击十次<-或BACKSPACE,把光标左移到行首,不会像敲击一次^A、Home或0那样高效。 提示22 Use a Single Editor Well 用好一种编辑器 选一种编辑器,彻底了解它,并将其用于所有的编辑任务。如果你用一种编辑器(或一组键绑定)进行所有的文本编辑活动,你就不必停下来思考怎样完成文本操纵:必需的键击将成为本能反应。编辑器将成为你双手的延伸;键会在滑过文本和思想时歌唱起来。这就是我们的目标。 确保你选择的编辑器能在你使用的所有平台上使用。Emacs、vi、CRiSP、Brief及其他一些编辑器可在多种平台上使用,并且常常既有GUI版本,也有非GUI(文本屏幕)版本。 编辑器特性 除了你认为特别有用、使用时特别舒适的特性之外,还有一些基本能力,我们认为每个像样的编辑器都应该具备。如果你的编辑器缺少其中的任何能力,那么你或许就应该考虑换一种更高级的编辑器了。 l 可配置。编辑器的所有方面都应该能按你的偏好(preference)配置,包括字体、颜色、窗口尺寸以及键击绑定(什么键执行什么命令)。对于常见的编辑操作,与鼠标或菜单驱动的命令相比,只使用键击效率更高,因为你的手无须离开键盘。 l 可扩展。编辑器不应该只因为出现了新的编程语言就变得过时。它应该能集成你在使用的任何编译器环境。你应该能把任何新语言或文本格式(XML、HTML第9版,等等)的各种细微差别“教”给它。 l 可编程。你应该能对编辑器编程,让它执行复杂的、多步骤的任务。可以通过宏或内建的脚本编程语言(例如,Emacs使用了Lisp的一个变种)进行这样的编程。 此外,许多编辑器支持针对特定编程语言的特性,比如: l 语法突显 l 自动完成 l 自动缩进 l 初始代码或文档样板 l 与帮助系统挂接 l 类IDE特性(编译、调试,等等) 像语法突显这样的特性听起来也许像是无关紧要的附加物,但实际上却可能非常有用,而且还能提高你的生产率。一旦你习惯了看到关键字以不同的颜色或字体出现,远在你启动编译器之前,没有以那样的方式出现的、敲错的关键字就会在你面前跳出来。 对于大型项目,能够在编辑器环境中进行编译、并直接转到出错处非常方便。Emacs特别擅长进行这种方式的交互。 生产率 我们遇到的用Windows notepad编辑源码的人数量惊人。这就像是把茶匙当做铁锹——只是敲键和使用基本的基于鼠标的剪贴是不够的。 有什么样的事情需要你做,你却无法以这样的方式做到呢? 嗯,让我们以光标移动的例子作为开始。与重复击键、一个字符一个字符或一行一行移动相比,按一次键、就以词、行、块或函数为单位移动光标,效率要高得多。 再假设你在编写Java代码。你想要按字母顺序排列import语句,而另外有人签入(check in)了一些文件,没有遵守这一标准(这听起来也许很极端,但在大型项目中,这可以让你节省大量时间,不用逐行检查一大堆import语句)。你想要快速地从头到尾检查一些文件,并对它们的一小部分区域进行排序。在像vi和Emacs这样的编辑器中,你可以很容易完成这样的任务(参见图3.1)。用notepad试试看! 图3.1 在编辑器中对文本行进行排序 有些编辑器能帮助你使常用操作流水线化。例如,当你创建特定语言的新文件时,编辑器可以为你提供模板。其中也许包括: l 填好的类名或模块名(根据文件名派生) l 你的姓名和/或版权声明 l 该语言中的各种构造体(construct)的骨架(例如,构造器与析构器声明) 自动缩进是另一种有用的特性。你不必(使用空格或tab)进行手工缩进,编辑器会自动在适当的时候(例如,在敲入左花括号时)为你进行缩进。这一特性让人愉快的地方是,你可以用编辑器为你的项目提供一致的缩进风格[20]。 然后做什么 这种建议特别难写,因为实际上每个读者对他们所用编辑器的熟悉程度和相关经验都有所不同。那么,作为总结,并为下一步该做什么提出一些指导方针,在下面的左边一栏中找到与你的情况相符的情况,然后看右边一栏,看你应该做什么。 如果这听起来像你…… 那么考虑…… 我使用许多不同的编辑器,但只使用其基本特性。 选一种强大的编辑器,好好学习它。 我有最喜欢的编辑器,但不使用其全部特性。 学习它们。减少你需要敲击的键数。 我有最喜欢的编辑器,只要可能就使用它。 设法扩展它,并将其用于比现在更多的任务。 我认为你们在胡说。notepad就是有史以来最好的编辑器。 只要你愿意,并且生产率很高,那就这样吧!但如果你发现自己在“羡慕”别人的编辑器,你可能就需要重新评估自己的位置了。 有哪些编辑器可用 此前我们建议你掌握一种像样的编辑器,那么我们推荐哪种编辑器呢?嗯,我们要回避这个问题;你对编辑器的选择是一个个人问题(有人甚至会说这是个“信仰问题”!)。但是,在附录A(266页)中,我们列出了许多流行的编辑器和获取它们的途径。 挑战 l 有些编辑器使用完备的语言进行定制和脚本编写。例如,Emacs采用了Lisp。作为本年度你将学习的新语言之一,学习你的编辑器使用的语言。如果你发现自己在重复做任何事情,开发一套宏(或等价的东西)加以处理。 l 你是否知道你的编辑器所能做的每一件事情?设法难倒使用同样的编辑器的同事。设法通过尽可能少的键击完成任何给定的编辑任务。 17 源码控制 进步远非由变化组成,而是取决于好记性。不能记住过去的人,被判重复过去。 ——George Santayana, Life of Reason 我们在用户界面中找寻的一个重要的东西是UNDO键——一个能原谅我们的错误的按钮。如果环境支持多级撤消(undo)与重做(redo),那就更好了,这样你就可以回去,撤消几分钟前发生的事情。但如果错误发生在上周,而你那以后已经把计算机打开关闭了十次呢?噢,这是使用源码控制系统的诸多好处之一:它是一个巨大的UNDO键——一个项目级的时间机器,能够让你返回上周的那些太平日子,那时的代码还能够编译并运行。 源码控制系统(或范围更宽泛的配置管理系统)追踪你在源码和文档中做出的每一项变动。 更好的系统还能追踪编译器及OS版本。有了适当配置的源码控制系统,你就总能够返回你的软件的前一版本。 但源码控制系统(SCCS)能做的远比撤消错误要多。好的SCCS让你追踪变动,回答这样的问题:谁改动了这一行代码?在当前版本与上周的版本之间有什么区别?在这次发布的版本中我们改动了多少行代码?哪个文件改动最频繁?对于bug追踪、审计、性能及质量等目的,这种信息非常宝贵。 SCCS还能让你标识你的软件的各次发布。一经标识,你将总是能够返回并重新生成该版本,并且不受在其后发生的变动的影响。 我们常常使用SCCS管理开发树中的分支。例如,一旦你发布了某个软件,你通常会想为下一次发布继续开发。与此同时,你也需要处理当前发布的版本中的bug,把修正后的版本发送给客户。(如果合适)你想要让这些bug修正合并进下一次发布中,但你不想把正在开发的代码发送给客户。通过SCCS,在每次生成一个发布版本时,你可以在开发树中生成分支。你把bug修正加到分支中的代码上,并在主干上继续开发。因为bug修正也可能与主干有关,有些系统允许你把选定的来自分支的变动自动合并回主干中。 源码控制系统可能会把它们维护的文件保存在某个中央仓库(repository)中——这是进行存档的好候选地。 最后,有些产品可能允许两个或更多用户同时在相同的文件集上工作,甚至在同一文件中同时做出改动。系统随后在文件被送回仓库时对这些改动进行合并。尽管看起来有风险,在实践中这样的系统在所有规模的项目上都工作良好。 提示23 Always Use Source Code Control 总是使用源码控制 总是。即使你的团队只有你一个人,你的项目只需一周时间;即使那是“用过就扔”的原型;即使你的工作对象并非源码;确保每样东西都处在源码控制之下——文档、电话号码表、给供应商的备忘录、makefile、构建与发布流程、烧制CD母盘的shell小脚本——每样东西。我们例行公事地对我们敲入的每一样东西进行源码控制(包括本书的文本)。即使我们不是在开发项目,我们的日常工作也被安全地保存在仓库中。 源码控制与构建 把整个项目置于源码控制系统的保护之下具有一项很大的、隐蔽的好处:你可以进行自动的和可重复的产品构建。 项目构建机制可以自动从仓库中取出最近的源码。它可以在午夜运行,在每个人都(很可能)回家之后。你可以运行自动的回归测试,确保当日的编码没有造成任何破坏。构建的自动化保证了一致性——没有手工过程,而你也不需要开发者记住把代码拷贝进特殊的构建区域。 构建是可重复的,因为你总是可以按照源码将给定日期的内容重新进行构建。 但我们团队没有使用源码控制 他们应该感到羞耻!听起来这是个“布道”的机会!但是,在等待他们看到光明的同时,也许你应该实施自己私人的源码控制。使用我们在附录A中列出的可自由获取的工具,并确保把你个人的工作安全地保存进仓库中(并且完成你的项目所要求的无论什么事情)。尽管这看起来像是重复劳动,我们几乎可以向你担保,在你须要回答像“你对xyz模块做了什么?”和“是什么破坏了构建?”这样的问题时,它将使你免受困扰(并为你的项目节省金钱)。这一方法也许还能有助于使你们的管理部门确信,源码控制确实行之有效。 不要忘了,SCCS也同样适用于你在工作之外所做的事情。 源码控制产品 附录A(271页)给出了一些有代表性的源码控制系统的URL,有些是商业产品,有些可自由获取。还有许多其他的产品可用——你可以在配置管理FAQ中寻求建议。 相关内容: l 正交性,34页 l 纯文本的力量,73页 l 全都是写,248页 挑战 l 即使你无法在工作中使用SCCS,也要在个人的系统上安装RCS或CVS。用它管理你的“宠物项目”、你撰写的文档、以及(可能的)应用于计算机系统自身的配置变动。 l 在Web上有些开放源码项目的存档对外公开(比如Mozilla[URL51]、KDE[URL54]、以及Gimp[URL55]),看一看这样的项目。你怎样获取源文件的更新?你怎样做出改动?——项目是否会对访问进行管制,或是对改动的并入进行裁决? 18 调试 这是痛苦的事:看着你自己的烦忧,并且知道不是别人、而是你自己一人所致 ——索福克勒斯:《埃阿斯》 自从14世纪以来,bug(虫子、臭虫)一词就一直被用于描述“恐怖的东西”。COBOL的发明者,海军少将Grace Hopper博士据信观察到了第一只计算机bug——真的是一只虫子,一只在早期计算机系统的继电器里抓到的蛾子。在被要求解释机器为何未按期望运转时,有一位技术人员报告说,“有一只虫子在系统里”,并且负责地把它——翅膀及其他所有部分——粘在了日志簿里。 遗憾的是,在我们的系统里仍然有“bug”,虽然不是会飞的那种。但与以前相比,14世纪的含义——可怕的东西——现在也许更为适用。软件缺陷以各种各样的方式表现自己,从被误解的需求到编码错误。糟糕的是,现代计算机系统仍然局限于做你告诉它的事情,而不一定是你想要它做的事情。 没有人能写出完美的软件,所以调试肯定要占用你大量时间。让我们来看一看调试所涉及的一些问题,以及一些用于找出难以捉摸的虫子的一般策略。 调试的心理学 对于许多开发者,调试本身是一个敏感、感性的话题。你可能会遇到抵赖、推诿、蹩脚的借口、甚或是无动于衷,而不是把它当做要解决的难题发起进攻。 要接受事实:调试就是解决问题,要据此发起进攻。 发现了他人的bug之后,你可以花费时间和精力去指责让人厌恶的肇事者。在有些工作环境中,这是文化的一部分,并且可能是“疏通剂”。但是,在技术竞技场上,你应该专注于修正问题,而不是发出指责。 提示24 Fix the Problem, Not the Blame 要修正问题,而不是发出指责 bug是你的过错还是别人的过错,并不是真的很有关系。它仍然是你的问题。 调试的思维方式 最容易欺骗的人是一个人自己。 ——Edward Bulwer-Lytton, The Disowned 在你开始调试之前,选择恰当的思维方式十分重要。你须要关闭每天用于保护自我(ego)的许多防卫措施,忘掉你可能面临的任何项目压力,并让自己放松下来。最重要的是,记住调试的第一准则: 提示25 Don’t Panic 不要恐慌 人很容易恐慌,特别是如果你正面临最后期限的到来、或是正在设法找出bug的原因,有一个神经质的老板或客户在你的脖子后面喘气。但非常重要的事情是,要后退一步,实际思考什么可能造成你认为表征了bug的那些症状。 如果你目睹bug或见到bug报告时的第一反应是“那不可能”,你就完全错了。一个脑细胞都不要浪费在以“但那不可能发生”起头的思路上,因为很明显,那不仅可能,而且已经发生了。 在调试时小心“近视”。要抵制只修正你看到的症状的急迫愿望:更有可能的情况是,实际的故障离你正在观察的地方可能还有几步远,并且可能涉及许多其他的相关事物。要总是设法找出问题的根源,而不只是问题的特定表现。 从何处开始 在开始查看bug之前,要确保你是在能够成功编译的代码上工作——没有警告。我们例行公事地把编译器警告级设得尽可能高。把时间浪费在设法找出编译器能够为你找出的问题上没有意义!我们需要专注于手上更困难的问题。 在设法解决任何问题时,你需要搜集所有的相关数据。糟糕的是,bug报告不是精密科学。你很容易被巧合误导,而你不能承受把时间浪费在对巧合进行调试上。你首先需要在观察中做到准确。 bug报告的准确性在经过第三方之手时会进一步降低——实际上你可能需要观察报告bug的用户的操作,以获取足够程度的细节。 Andy曾经参与过一个大型图形应用的开发。快要发布时,测试人员报告说,每次他们用特定的画笔画线,应用都会崩溃。负责该应用的程序员争辩说,这个画笔没有任何问题;他试过用它绘图,它工作得很好。几天里这样的对话来回进行,大家的情绪急速上升。 最后,我们让他们坐到同一个房间里。测试人员选了画笔工具,从右上角到左下角画了一条线。应用程序炸了。“噢”,程序员用很小的声音说。他随后像绵羊一样承认,他在测试时只测试了从左下角画到右上角的情况,没有暴露出这个bug。 这个故事有两个要点: l 你也许需要与报告bug的用户面谈,以搜集比最初给你的数据更多的数据。 l 人工合成的测试(比如那个程序员只从下画到上)不能足够地演练(exercise)应用。你必须既强硬地测试边界条件,又测试现实中的最终用户的使用模式。你需要系统地进行这样的测试(参见无情的测试,237页)。 测试策略 一旦你认为你知道了在发生什么,就到了找出程序认为在发生什么的时候了。 再现bug(reproduction,亦有“繁殖”之意——译注) 不,我们的bug不会真的繁殖(尽管其中有一些可能已经到了合法的生育年龄)。我们谈论的是另一种“再现”。 开始修正bug的最佳途径是让其可再现。毕竟,如果你不能再现它,你又怎么知道它已经被修正了呢? 但我们想要的不是能够通过长长的步骤再现的bug;我们要的是能够通过一条命令再现的bug。如果你必须通过15个步骤才能到达bug显露的地方,修正bug就会困难得多。有时候,强迫你自己隔离显示出bug的环境,你甚至会洞见到它的修正方法。 要了解沿着这些思路延伸的其他想法,参见无处不在的自动化(230页)。 使你的数据可视化 常常,要认识程序在做什么——或是要做什么——最容易的途径是好好看一看它操作的数据。最简单的例子是直截了当的“variable name = data value”方法,这可以作为打印文本、也可以作为GUI对话框或列表中的字段实现。 但通过使用允许你“使数据及其所有的相互关系可视化”的调试器,你可以深入得多地获得对你的数据的洞察。有一些调试器能够通过虚拟现实场景把你的数据表示为3D立交图,或是表示为3D波形图,或是就表示为简单的结构图(如下一页的图3.2所示)。在单步跟踪程序的过程中,当你一直在追猎的bug突然跳到你面前时,这样的图远胜于千言万语。 即使你的调试器对可视化数据的支持有限,你仍然自己进行可视化——或是通过手工方式,用纸和笔,或是用外部的绘图程序。 DDD调试器有一些可视化能力,并且可以自由获取(参见[URL 19])。有趣的是,DDD能与多种语言一起工作,包括Ada、C、C++、Fortran、Java、Modula、Pascal、 图3.2 一个循环链表的调试器示例图。箭头表示指向节点的指针 Perl以及Python(显然是正交的设计)。 跟踪 调试器通常会聚焦于程序现在的状态。有时你需要更多的东西——你需要观察程序或数据结构随时间变化的状态。查看栈踪迹(stack trace)只能告诉你,你是怎样直接到达这里的。它无法告诉你,在此调用链之前你在做什么,特别是在基于事件的系统中。 跟踪语句把小诊断消息打印到屏幕上或文件中,说明像“到了这里”和“x的值 = 2”这样的事情。与IDE风格的调试器相比,这是一种原始的技术,但在诊断调试器无法诊断的一些错误种类时却特别有效。在时间本身是一项因素的任何系统中,跟踪都具有难以估量的价值:并发进程、实时系统、还有基于事件的应用。 你可以使用跟踪语句“钻入”代码。也就是,你可以在沿着调用树下降时增加跟踪语句。 跟踪消息应该采用规范、一致的格式:你可能会想自动解析它们。例如,如果你需要跟踪资源泄漏(比如未配平(unbalanced)的open/close),你可以把每一次open和每一次close 记录在日志文件中。通过用Perl处理该日志文件,你可以轻松地确定 坏变量?检查它们的邻居 有时你检查一个变量,希望看到一个小整数值,得到的却是像0x6e69614d这样的东西。在你卷起袖子、郑重其事地开始调试之前,先快速地查看一下这个坏变量周围的内存。这常常能带给你线索。在我们的例子中,把周边的内存作为字符进行检查得到的是: 20333231 6e69614d 2c745320 746f4e0a 1 2 3 M a i n S t , /n N o t 2c6e776f 2058580a 31323433 00000a33 o w n , /n x x 3 4 2 1 3/n/0/0 看上去像是有人把街道地址“喷”到了我们的计数器上。现在我们知道该去查看什么地方了。 有问题的open是在哪里发生的。 橡皮鸭 找到问题的原因的一种非常简单、却又特别有用的技术是向别人解释它。他应该越过你的肩膀看着屏幕,不断点头(像澡盆里上下晃动的橡皮鸭)。他们一个字也不需要说;你只是一步步解释代码要做什么,常常就能让问题从屏幕上跳出来,宣布自己的存在。 这听起来很简单,但在向他人解释问题时,你必须明确地陈述那些你在自己检查代码时想当然的事情。因为必须详细描述这些假定中的一部分,你可能会突然获得对问题的新洞见。 消除过程 在大多数项目中,你调试的代码可能是你和你们团队的其他成员编写的应用代码、第三方产品(数据库、连接性、图形库、专用通信或算法,等等)、以及平台环境(操作系统、系统库、编译器)的混合物。 bug有可能存在于OS、编译器、或是第三方产品中——但这不应该是你的第一想法。有大得多的可能性的是,bug存在于正在开发的应用代码中。与假定库本身出了问题相比,假定应用代码对库的调用不正确通常更有好处。即使问题确实应归于第三方,在提交bug报告之前,你也必须先消除你的代码中的bug。 我们参加过一个项目的开发,有位高级工程师确信select系统调用在Solaris上有问题。再多的劝说或逻辑也无法改变他的想法(这台机器上的所有其他网络应用都工作良好这一事实也一样无济于事)。他花了数周时间编写绕开这一问题的代码,因为某种奇怪的原因,却好像并没有解决问题。当最后被迫坐下来、阅读关于select的文档时,他在几分钟之内就发现并纠正了问题。现在每当有人开始因为很可能是我们自己的故障而抱怨系统时,我们就会使用“select没有问题”作为温和的提醒。 提示26 “Select” Isn’t Broken “Select”没有问题 记住,如果你看到马蹄印,要想到马,而不是斑马。OS很可能没有问题。数据库也很可能情况良好。 如果你“只改动了一样东西”,系统就停止了工作,那样东西很可能就需要对此负责——直接地或间接地,不管那看起来有多牵强。有时被改动的东西在你的控制之外:OS的新版本、编译器、数据库或是其他第三方软件都可能会毁坏先前的正确代码。可能会出现新的bug。你先前已绕开的bug得到了修正,却破坏了用于绕开它的代码。API变了,功能变了;简而言之,这是全新的球赛,你必须在这些新的条件下重新测试系统。所以在考虑升级时要紧盯着进度表;你可能会想等到下一次发布之后再升级。 但是,如果没有显而易见的地方让你着手查看,你总是可以依靠好用的老式二分查找。看症状是否出现在代码中的两个远端之一,然后看中间。如果问题出现了,则臭虫位于起点与中点之间;否则,它就在中点与终点之间。以这种方式,你可以让范围越来越小,直到最终确定问题所在。 造成惊讶的要素 在发现某个bug让你吃惊时(也许你在用我们听不到的声音咕哝说:“那不可能。”),你必须重新评估你确信不疑的“事实”。在那个链表例程中——你知道它坚固耐用,不可能是这个bug的原因——你是否测试了所有边界条件?另外一段代码你已经用了好几年——它不可能还有bug。可能吗? 当然可能。某样东西出错时,你感到吃惊的程度与你对正在运行的代码的信任及信心成正比。这就是为什么,在面对“让人吃惊”的故障时,你必须意识到你的一个或更多的假设是错的。不要因为你“知道”它能工作而轻易放过与bug有牵连的例程或代码。证明它。用这些数据、这些边界条件、在这个语境中证明它。 提示27 Don’t Assume it – Prove It 不要假定,要证明 当你遇到让人吃惊的bug时,除了只是修正它而外,你还需要确定先前为什么没有找出这个故障。考虑你是否需要改进单元测试或其他测试,以让它们有能力找出这个故障。 还有,如果bug是一些坏数据的结果,这些数据在造成爆发之前传播通过了若干层面,看一看在这些例程中进行更好的参数检查是否能更早地隔离它(分别参见120页与122页的关于早崩溃及断言的讨论)。 在你对其进行处理的同时,代码中是否有任何其他地方容易受这同一个bug的影响?现在就是找出并修正它们的时机。确保无论发生什么,你都知道它是否会再次发生。 如果修正这个bug需要很长时间,问问你自己为什么。你是否可以做点什么,让下一次修正这个bug变得更容易?也许你可以内建更好的测试挂钩,或是编写日志文件分析器。 最后,如果bug是某人的错误假定的结果,与整个团队一起讨论这个问题。如果一个人有误解,那么许多人可能也有。 去做所有这些事情,下一次你就将很有希望不再吃惊。 调试检查列表 l 正在报告的问题是底层bug的直接结果,还是只是症状? l bug真的在编译器里?在OS里?或者是在你的代码里? l 如果你向同事详细解释这个问题,你会说什么? l 如果可疑代码通过了单元测试,测试是否足够完整?如果你用该数据运行单元测试,会发生什么? l 造成这个bug的条件是否存在于系统中的其他任何地方? 相关内容: l 断言式编程,122页 l 靠巧合编程,172页 l 无处不在的自动化,230页 l 无情的测试,237页 挑战 l 调试已经够有挑战性了。 19 文本操纵 注重实效的程序员用与木匠加工木料相同的方式操纵文本。在前面的部分里,我们讨论了我们所用的一些具体工具——shell、编辑器、调试器。这些工具与木匠的凿子、锯子、刨子类似——它们都是用于把一件或两件工作做好的专用工具。但是,我们不时也需要完成一些转换,这些转换不能由基本工具集直接完成。我们需要通用的文本操纵工具。 文本操纵语言对于编程的意义,就像是刳刨机(router)对于木工活的意义。它们嘈杂、肮脏、而且有点用“蛮力”。如果使用有误,整个工件都可能毁坏。有人发誓说在工具箱里没有它们的位置。但在恰当的人的手中,刳刨机和文本操纵语言都可以让人难以置信地强大和用途广泛。你可以很快把某样东西加工成形、制作接头、并进行雕刻。如果适当使用,这些工具拥有让人惊讶的精微与巧妙。但你需要花时间才能掌握它们。 好的文本操纵语言的数目正在增长。Unix开发者常常喜欢利用他们的命令shell的力量,并用像awk和sed这样的工具加以增强。偏爱更为结构化的工具的人喜欢Python[URL 9]的面向对象本质。有人把Tcl[URL 23]当作自己的首选工具。我们碰巧喜欢用Perl[URL 8]编写短小的脚本。 这些语言是能赋予你能力的重要技术。使用它们,你可以快速地构建实用程序,为你的想法建立原型——使用传统语言,这些工作可能需要5倍或10倍的时间。对于我们所做的实验,这样的放大系数十分重要。与花费5小时相比,花费30分钟试验一个疯狂的想法要好得多。花费1天使项目的重要组件自动化是可以接受的;花费1周却不一定。在The Practice of Programming[KP99]一书中,Kernighan与Pike用5种不同的语言构建同一个程序。Perl版本是最短的(17行,而C要150行)。通过Perl你可以操纵文本、与程序交互、进行网络通信、驱动网页、进行任意精度的运算、以 及编写看起来像史努比发誓的程序。 提示28 Learn a Text Manipulation Language 学习一种文本操纵语言 为了说明文本操纵语言的广泛适用性,这里列出了我们过去几年开发的一些应用示例: l 数据库schema维护。一组Perl脚本读取含有数据库schema定义的纯文本文件,根据它生成: - 用于创建数据库的SQL语句 - 用于填充数据词典的平板(flat)数据文件 - 用于访问数据库的C代码库 - 用于检查数据库完整性的脚本 - 含有schema描述及框图的网页 - schema的XML版本 l Java属性(property)访问。限制对某个对象的属性的访问,迫使外部类通过方法获取和设置它们,这是一种良好的OO编程风格。但是,属性在类的内部由简单的成员变量表示是一种常见情况,在这样的情况下要为每个变量创建获取和设置方法既乏味,又机械。我们有一个Perl脚本,它修改源文件,为所有做了适当标记的变量插入正确的方法定义。 l 测试数据生成。我们的测试数据有好几万记录,散布在若干不同的文件中,其格式也不同,它们需要汇合在一起,并转换为适于装载进关系数据库的某种形式。Perl用几小时就完成了这一工作(在此过程中还发现了初始数据的几处一致性错误)。 l 写书。我们认为,出现在书籍中的任何代码都应首先进行测试,这十分重要。本书中的大多数代码都经过了测试。但是,按照DRY原则(参见“重复的危害”,26页),我们不想把代码从测试过的程序拷贝并粘贴到书里。那意味着代码是重复的,实际上我们肯定会在程序被改动时忘记更新相应的例子。对于有些例子,我们也不想用编译并运行例子所需的全部框架代码来烦扰你。我们转向了Perl。在我们对书进行格式化时,会调用一个相对简单的脚本——它提取源文件中指定的片段,进行语法突显,并把结果转换成我们使用的排版语言。 l C与Object Pascal的接口。某个客户有一个在PC上编写Object Pascal应用的开发团队。他们的代码需要与用C编写的一段代码接口。我们开发了一个短小的Perl脚本,解析C头文件,提取所有被导出函数的定义,以及它们使用的数据结构。随后我们生成Object Pascal单元:用Pascal记录对应所有的C结构,用导入的过程定义对应所有的C函数。这一生成过程变成了构建的一部分,这样无论何时C头文件发生变化,新的Object Pascal单元都会自动被构造。 l 生成Web文档。许多项目团队都把文档发布在内部网站上。我们编写了许多Perl程序,分析数据库schema、C或C++源文件、makefile以及其他项目资源,以生成所需的HTML文档。我们还使用Perl,把文档用标准的页眉和页脚包装起来,并把它们传输到网站上。 我们几乎每天都使用文本操纵语言。与我们注意到的其他任何语言相比,本书中的许多想法都可以用这些语言更简单地实现。这些语言使我们能够轻松地编写代码生成器,我们将在下一节讨论这一主题。 相关内容: l 重复的危害,26页 练习 11. 你的C程序使用枚举类型表示100种状态。为进行调试,你想要能把状态打印成(与数字对应的)字符串。编写一个脚本,从标准输入读取含有以下内容的文件: (解答在285页) name state_a state_b : : 生成文件name.h,其中含有: extern const char* NAME_names[]; typedef enum { state_a, state_b, : : } NAME; 以及文件name.c,其中含有: const char* NAME_names[] = { "state_a", "state_b", : : }; 12. 在本书撰写的中途,我们意识到我们没有把use strict指示放进我们的许多Perl例子。编写一个脚本,检查某个目录中的.pl文件,给没有use strict指示的所有文件在初始注释块的末尾加上该指示。要记住给你改动的所有文件保留备份。 (解答在286页) 20 代码生成器 当木匠面临一再地重复制作同一样东西的任务时,他们会取巧。他们给自己建造夹具或模板。一旦他们做好了夹具,他们就可以反复制作某样工件。夹具带走了复杂性,降低了出错的机会,从而让工匠能够自由地专注于质量问题。 作为程序员,我们常常发现自己也处在同样的位置上。我们需要获得同一种功能,但却是在不同的语境中。我们需要在不同的地方重复信息。有时我们只是需要通过减少重复的打字,使自己免于患上腕部劳损综合症。 以与木匠在夹具上投入时间相同的方式,程序员可以构建代码生成器。一旦构建好,在整个项目生命期内都可以使用它,实际上没有任何代价。 提示29 Write Code That Writes Code 编写能编写代码的代码 代码生成器有两种主要类型: 1. 被动代码生成器只运行一次来生成结果。然后结果就变成了独立的——它与代码生成器分离了。在198页的邪恶的向导中讨论的向导,还有某些CASE工具,都是被动代码生成器的例子。 2. 主动代码生成器在每次需要其结果时被使用。结果是用过就扔的——它总是能由代码生成器重新生成。主动代码生成器为了生成其结果,常常要读取某种形式的脚本或控制文件。 被动代码生成器 被动代码生成器减少敲键次数。它们本质上是参数化模板,根据一组输入生成给定的输出形式。结果一经产生,就变成了项目中有充分资格的源文件;它将像任何其他文件一样被编辑、编译、置于源码控制之下。其来源将被忘记。 被动代码生成器有许多用途: l 创建新的源文件。被动代码生成器可以生成模板、源码控制指示、版权说明以及项目中每个新文件的标准注释块。我们设置我们的编辑器,让它在我们每次创建新文件时做这样的工作:编辑新的Java程序,新的编辑器缓冲区将自动包含注释块、包指示以及已经填好的概要的类声明。 l 在编程语言之间进行一次性转换。我们开始撰写本书时使用的是troff系统,但我们在完成了15节以后转向了LaTeX。我们编写了一个代码生成器,读取troff源,并将其转换到LaTeX。其准确率大约是90%,余下部分我们用手工完成。这是被动代码生成器的一个有趣的特性:它们不必完全准确。你需要在你投入生成器的努力和你花在修正其输出上的精力之间进行权衡。 l 生成查找表及其他在运行时计算很昂贵的资源。许多早期的图形系统都使用预先计算的正弦和余弦值表,而不是在运行时计算三角函数。在典型情况下,这些表由被动代码生成器生成,然后拷贝到源文件中。 主动代码生成器 被动代码生成器只是一种便利手段,如果你想要遵循DRY原则,它们的“表亲”主动代码生成器却是必需品。通过主动代码生成器,你可以取某项知识的一种表示形式,将其转换为你的应用需要的所有形式。这不是重复,因为衍生出的形式可以用过就扔,并且是由代码生成器按需生成的(所以才会用主动这个词)。 无论何时你发现自己在设法让两种完全不同的环境一起工作,你都应该考虑使用主动代码生成器。 或许你在开发数据库应用。这里,你在处理两种环境——数据库和你用来访问它的编程语言。你有一个schema,你需要定义低级的结构,反映特定的数据库表的布局。你当然可以直接对其进行编码,但这违反了DRY原则:schema的知识就会在两个地方表示。当schema变化时,你需要记住改变相应的代码。如果某一列从表中被移走,而代码库却没有改变,甚至有可能连编译错误也没有。只有等你的测试开始失败时(或是用户打电话过来),你才会知道它。 另一种办法是使用主动代码生成器——如图3.3所示,读取schema,使用它生成结构的源码。现在,无论何时schema发生变化,用于访问它的代码也会自动变化。如果某一列被移走,那么它在结构中相应的字段也将消失,任何使用该列的更高级的代码就将无法通过编译。 图3.3 主动代码生成器根据数据库schema创建代码 你在编译时就能抓住错误,不用等到投入实际运行时。当然,只有在你让代码生成成为构建过程自身的一部分的情况下,这个方案才能工作。 使用代码生成器融合环境的另一个例子发生在不同的编程语言被用于同一个应用时。为了进行通信,每个代码库将需要某些公共信息——例如,数据结构、消息格式、以及字段名。要使用代码生成器,而不是重复这些信息。有时你可以从一种语言的源文件中解析出信息,并将其用于生成第二种语言的代码。但如下一页的图3.4所示,用更简单、语言中立的表示形式来表示它,并为两种语言生成代码,常常更简单。再看一看268页上练习13的解答,里面有怎样把对平板文件表示的解析与代码生成分离开来的例子。 代码生成不一定要很复杂 所有这些关于“主动这个”和“被动那个”的谈论可能会给你留下这样的印象:代码生成器是复杂的东西。它们不一定要很复杂。最复杂的部分通常是负责分析输入文件的解析器。让输入格式保持简单,代码生成器就会变得简单。看一看练习13的解答(286页):实际的代码生成基本上是print语句。 图3.4 根据语言中立的表示生成代码。在输入文件中,以‘M’开始的行标志着消息定义的开始。‘F’行定义字段,‘E’是消息的结束 代码生成器不一定要生成代码 尽管本节的许多例子给出的是生成程序源码的代码生成器,事情并不是非如此不可。你可以用代码生成器生成几乎任何输出:HTML、XML、纯文本——可能成为你的项目中别处输入的任何文本。 相关内容: l 重复的危害,26页 l 纯文本的力量,73页 l 邪恶的向导,198页 l 无处不在的自动化,230页 练习 13. 编写一个代码生成器,读取图3.4中的输入文件,以你选择的两种语言生成输出。设法使它容易增加新语言。 (解答在286页) 21 按合约设计(1) 提示30 You Can’t Write Perfect Software 你不可能写出完美的软件 这刺痛了你?不应该。把它视为生活的公理,接受它,拥抱它,庆祝它。因为完美的软件不存在。在计算技术简短的历史中,没有一个人曾经写出过一个完美的软件。你也不大可能成为第一个。除非你把这作为事实接受下来,否则你最终会把时间和精力浪费在追逐不可能实现的梦想上。 那么,给定了这个让人压抑的现实,注重实效的程序员怎样把它转变为有利条件?这正是这一章的话题。 每个人都知道只有他们自己是地球上的好司机。所有其他的人都等在那里要对他们不利,这些人乱冲停车标志、在车道之间摇来摆去、不作出转向指示、打电话、看报纸、总而言之就是不符合我们的标准。于是我们防卫性地开车。我们在麻烦发生之前小心谨慎、预判意外之事、从不让自己陷入无法解救自己的境地。 编码的相似性相当明显。我们不断地与他人的代码接合——可能不符合我们的高标准的代码——并处理可能有效、也可能无效的输入。所以我们被教导说,要防卫性地编码。如果有任何疑问,我们就会验证给予我们的所有信息。我们使用断言检测坏数据。我们检查一致性,在数据库的列上施加约束,而且通常对自己感到相当满意。 但注重实效的程序员会更进一步。他们连自己也不信任。知道没有人能编写完美的代码,包括自己,所以注重实效的程序员针对自己的错误进行防卫性的编码。我们将在“按合约设计(Design by Contract)”中描述第一种防卫措施:客户与供应者必须就权利与责任达成共识。 在“死程序不说谎”中,我们想要确保在找出bug的过程中,不会造成任何破坏。所以我们设法经常检查各种事项,并在程序出问题时终止程序。 “断言式编程”描述了一种沿途进行检查的轻松方法——编写主动校验你的假定的代码。 与其他任何技术一样,异常如果没有得到适当使用,造成的危害可能比带来的好处更多。我们将在“何时使用异常”中讨论各种相关问题。 随着你的程序变得更为动态,你会发现自己在用系统资源玩杂耍——内存、文件、设备,等等。在“怎样配平资源(How to Balance Resources)”中,我们将提出一些方法,确保你不会让其中任何一个球掉落下来。 不完美的系统、荒谬的时间标度、可笑的工具、还有不可能实现的需求——在这样一个世界上,让我们安全“驾驶”。 当每个人都确实要对你不利时,偏执就是一个好主意。 ——Woody Allen 21 按合约设计 没有什么比常识和坦率更让人感到惊讶。 ——拉尔夫•沃尔多•爱默生,《散文集》 与计算机系统打交道很困难。与人打交道更困难。但作为一个族类,我们花费在弄清楚人们交往的问题上的时间更长。在过去几千年中我们得出的一些解决办法也可应用于编写软件。确保坦率的最佳方案之一就是合约。 合约既规定你的权利与责任,也规定对方的权利与责任。此外,还有关于任何一方没有遵守合约的后果的约定。 或许你有一份雇用合约,规定了你的工作时数和你必须遵循的行为准则。作为回报,公司付给你薪水和其他津贴。双方都履行其义务,每个人都从中受益。 全世界都——正式地或非正式地——采用这种理念帮助人们交往。我们能否采用同样的概念帮助软件模块进行交互?答案是肯定的。 DBC Bertrand Meyer[Mey97b]为Eiffel语言发展了按合约设计的概念[25]。这是一种简单而强大的技术,它关注的是用文档记载(并约定)软件模块的权利与责任,以确保程序正确性。什么是正确的程序?不多不少,做它声明要做的事情的程序。用文档记载这样的声明,并进行校验,是按合约设计(简称DBC)的核心所在。 软件系统中的每一个函数和方法都会做某件事情。在开始做某事之前,例程对世界的状态可能有某种期望,并且也可能有能力陈述系统结束时的状态。Meyer这样描述这些期望和陈述: l 前条件(precondition)。为了调用例程,必须为真的条件;例程的需求。在其前条件被违反时,例程决不应被调用。传递好数据是调用者的责任(见115页的方框)。 l 后条件(postcondition)。例程保证会做的事情,例程完成时世界的状态。例程有后条件这一事实意味着它会结束:不允许有无限循环。 l 类不变项(class invariant)。类确保从调用者的视角来看,该条件总是为真。在例程的内部处理过程中,不变项不一定会保持,但在例程退出、控制返回到调用者时,不变项必须为真(注意,类不能给出无限制的对参与不变项的任何数据成员的写访问)。 让我们来看一个例程的合约,它把数据值插入惟一、有序的列表中。在iContract(用于Java的预处理器,可从[URL 17]获取)中,你可以这样指定: /** * @invariant forall Node n in elements() | * n.prev() != null * implies * n.value().compare To(n.prev().value()) > 0 */ public class dbc_list { /** * @pre contains(aNode) == false * @post contains(aNode) == true */ public void insertNode(final Node aNode) { // ... 这里我们所说的是,这个列表中的节点必须以升序排列。当你插入新节点时,它不能是已经存在的,我们还保证,在你插入某个节点后,你将能够找到它。 你用目标编程语言(或许还有某些扩展)编写这些前条件、后条件以及不变项。例如,除了普通的Java构造体,iContract还提供了谓词逻辑操作符——forall、exists、还有implies。你的断言可以查询方法能够访问的任何对象的状态,但要确保查询没有任何副作用(参见124页)。 DBC与常量参数 后条件常常要使用传入方法的参数来校验正确的行为。但如果允许例程改变传入的参数,你就有可能规避合约。Eiffel不允许这样的事情发生,但Java却允许。这里,我们使用Java关键字final指示我们的意图:参数在方法内不应被改变。这并非十分安全——子类有把参数重新声明为非final的自由。另外,你可以使用iContract语法variable@pre获取变量在进入方法时的初始值。 这样,例程与任何潜在的调用者之间的合约可解读为: 如果调用者满足了例程的所有前条件,例程应该保证在其完成时、所有后条件和不变项将为真。 如果任何一方没有履行合约的条款,(先前约定的)某种补偿措施就会启用——例如,引发异常或是终止程序。不管发生什么,不要误以为没能履行合约是bug。它不是某种决不应该发生的事情,这也就是为什么前条件不应被用于完成像用户输入验证这样的任务的原因。 提示31 Design with Contracts 通过合约进行设计 在“正交性”(34页)中,我们建议编写“羞怯”的代码。这里,强调的重点是在“懒惰”的代码上:对在开始之前接受的东西要严格,而允诺返回的东西要尽可能少。记住,如果你的合约表明你将接受任何东西,并允诺返回整个世界,那你就有大量代码要写了! 继承和多态是面向对象语言的基石,是合约可以真正闪耀的领域。假定你正在使用继承创建“是一种(is-a-kind-of)”关系,即一个类是另外一个类的“一种”。你或许会想要坚持Liskov替换原则(Lis88): 子类必须要能通过基类的接口使用,而使用者无须知道其区别。 换句话说,你想要确保你创建的新子类型确实是基类型的“一种”——它支持同样的方法,这些方法有同样的含义。我们可以通过合约来做到这一点。要让合约自动应用于将来的每个子类,我们只须在基类中规定合约一次。子类可以(可选地)接受范围更广的输入,或是作出更强的保证。但它所接受的和所保证的至少与其父类一样多。 例如,考虑Java基类java.awt.Component。你可以把AWT或Swing中的任何可视组件当作Component,而不用知道实际的子类是按钮、画布、菜单,还是别的什么。每个个别的组件都可以提供额外的、特殊的功能,但它必须至少提供Component定义的基本能力。但并没有什么能阻止你创建Component的一个子类型,提供名称正确、但所做事情却不正确的方法。你可以很容易地创建不进行绘制的paint方法,或是不设置字体的setFont方法。AWT没有用于抓住你没有履行合约的事实的合约。 没有合约,编译器所能做的只是确保子类符合特定的方法型构(signature)。但如果我们适当设定基类合约,我们现在就能够确保将来任何子类都无法改变我们的方法的含义。例如,你可能想要这样为setFont建立合约,确保你设置的字体就是你得到的字体: /** * @pre f != null * @post getFont() == f */ public void setFont(final Font f) { // ... 21 按合约设计(2) 实现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页) 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/n'%s': expected %d, got %d/n", 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 (iter.hasmoreElements () { Test.ASSERT(iter.nextElements() != null); object obj = iter.nextElement(); // .... } ASSERT中的.nextElement()调用有副作用:它会让迭代器越过正在读取的元素,这样循环就会只处理集合中的一半元素。这样编写代码会更好: while (iter.hasmoreElements()) { object obj = iter.nextElement(); 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 (socket.read(name) != OK) { retcode = BAD_READ; } else { processName(name); if (socket.read(address) != OK) { retcode = BAD_READ; } else { processAddress(address); if (socket.read(telNo) != OK) { retcode = BAD_READ; } else { // etc, etc... } } } return retcode; 幸运的是,如果编程语言支持异常,你可以通过更为简洁的方式重写这段代码: retcode = OK; try { socket.read(name); process(name); socket.read(address); processAddress(address); socket.read(telNo); // etc, etc... } catch (IOException e) { retcode = BAD_READ; Logger.log("Error reading individual: " + e.getMessage()); } return retcode; 现在正常的控制流很清晰,所有的错误处理都移到了一处。 什么是异常情况 关于异常的问题之一是知道何时使用它们。我们相信,异常很少应作为程序的正常流程的一部分使用;异常应保留给意外事件。假定某个未被抓住的异常会终止你的程序,问问你自己:“如果我移走所有的异常处理器,这些代码是否仍然能运行?”如果答案是“否”,那么异常也许就正在被用在非异常的情形中。 例如,如果你的代码试图打开一个文件进行读取,而该文件并不存在,应该引发异常吗? 我们的回答是:“这取决于实际情况。”如果文件应该在那里,那么引发异常就有正当理由。某件意外之事发生了——你期望其存在的文件好像消失了。另一方面,如果你不清楚该文件是否应该存在,那么你找不到它看来就不是异常情况,错误返回就是合适的。 让我们看一看第一种情况的一个例子。下面的代码打开文件/etc/passwd,这个文件在所有的UNIX系统上都应该存在。如果它失败了,它会把FileNotFoundException传给它的调用者。 public void open_passwd() throws FileNotFoundException { // This may throw FileNotFoundException... ipstream = new FileInputStream("/etc/passwd"); // ... } 但是,第二种情况可能涉及打开用户在命令行上指定的文件。这里引发异常没有正当理由,代码看起来也不同: public boolean open_user_file(String name) throws FileNotFoundException { File f = new File(name); if (!f.exists()) { return false; } ipstream = new FileInputStream(f); return true; } 注意FileInputStream调用仍有可能生成异常,这个例程会把它传递出去。但是,这个异常只在真正异常的情形下才生成;只是试图打开不存在的文件将生成传统的错误返回。 提示34 Use Exceptions for Exceptional Problems 将异常用于异常的问题 我们为何要提出这种使用异常的途径?嗯,异常表示即时的、非局部的控制转移——这是一种级联的(cascading)goto。那些把异常用作其正常处理的一部分的程序,将遭受到经典的意大利面条式代码的所有可读性和可维护性问题的折磨。这些程序破坏了封装:通过异常处理,例程和它们的调用者被更紧密地耦合在一起。 错误处理器是另一种选择 错误处理器是检测到错误时调用的例程。你可以登记一个例程处理特定范畴的错误。处理器会在其中一种错误发生时被调用。 有时你可能想要使用错误处理器,或者用于替代异常,或者与异常一起使用。显然,如果你使用像C这样不支持异常的语言,这是你的很少几个选择之一(参见下一页的“挑战”)。但是,有时错误处理器甚至也可用于拥有良好的内建异常处理方案的语言(比如Java)。 考虑一个客户-服务器应用的实现,它使用了Java的Remote Method Invocation(RMI)设施。因为RMI的实现方式,每个对远地例程的调用都必须准备处理RemoteException。增加代码处理这些异常可能会变得让人厌烦,并且意味着我们难以编写既能与本地例程、也能与远地例程一起工作的代码。一种绕开这一问题的可能方法是把你的远地对象包装在非远地的类中。这个类随即实现一个错误处理器接口,允许客户代码登记一个在检测到远地异常时调用的例程。 相关内容: l 死程序不说谎,120页 挑战 l 不支持异常的语言常常拥有一些其他的非局部控制转移机制(例如,C拥有longjmp/setjmp)。考虑一下怎样使用这些设施实现某种仿造的异常机制。其好处和危险是什么?你需要采取什么特殊步骤确保资源不被遗弃?在你编写的所有C代码中使用这种解决方案有意义吗? 练习 21. 在设计一个新的容器类时,你确定可能有以下错误情况: (解答在292页) (1) add例程中的新元素没有内存可用 (2) 在fetch例程中找不到所请求的数据项 (3) 传给add例程的是null指针 应怎样处理每种情况?应该生成错误、引发异常、还是忽略该情况? 25怎样配平资源 “我把你带进这个世界,”我的父亲会说:“我也可以把你赶出去。那没有我影响。我要再造另一个你。” ——Bill Cosby,Fatherhood 只要在编程,我们都要管理资源:内存、事务、线程、文件、定时器——所有数量有限的事物。大多数时候,资源使用遵循一种可预测的模式:你分配资源、使用它,然后解除其分配。 但是,对于资源分配和解除分配的处理,许多开发者没有始终如一的计划。所以让我们提出一个简单的提示: 提示35 Finish What You Start 要有始有终 在大多数情况下这条提示都很容易应用。它只是意味着,分配某项资源的例程或对象应该负责解除该资源的分配。让我们通过一个糟糕的代码例子来看一看该提示的应用方式——这是一个打开文件、从中读取消费者信息、更新某个字段、然后写回结果的应用。我们除去了其中的错误处理代码,以让例子更清晰: void readCustomer(const char *fName, Customer *cRec) { cFile = fopen(fName, "r+"); fread(cRec, sizeof(*cRec), 1, cFile); } void writeCustomer(Customer *cRec) { rewind(cFile); fwrite (cRec, sizeof(*cRec), 1, cFile); fclose(cFile); } void updateCustomer(const char *fName, double newBalance) { Customer cRec; readCustomer(fName, &cRec); cRec.balance = newBalance; writeCustomer(&cRec); } 初看上去,例程updateCustomer相当好。它似乎实现了我们所需的逻辑——读取记录,更新余额,写回记录。但是,这样的整洁掩盖了一个重大的问题。例程readCustomer和writeCustomer紧密地耦合在一起[27]——它们共享全局变量cFile。readCustomer打开文件,并把文件指针存储在cFile中,而writeCustomer使用所存储的指针在其结束时关闭文件。这个全局变量甚至没有出现在updateCustomer例程中。 这为什么不好?让我们考虑一下,不走运的维护程序员被告知规范发生了变化——余额只应在新的值不为负时更新。她进入源码,改动updateCustomer: void updateCustomer(const char *fName, double newBalance) { Customer cRec; readCustomer(fName, &cRec); if (newBalance >= 0.0) { cRec.balance = newBalance; writeCustomer(&cRec); } } 在测试时一切似乎都很好。但是,当代码投入实际工作,若干小时后它就崩溃了,抱怨说打开的文件太多。因为writeCustomer在有些情形下不会被调用,文件也就不会被关闭。 这个问题的一个非常糟糕的解决方案是在updateCustomer中对该特殊情况进行处理: void updateCustomer(const char *fName, double newBalance) { Customer cRec; readCustomer(fName, &cRec); if (newBalance >= 0.0) { cRec.balance = newBalance; writeCustomer(&cRec); } else fclose(cFile); } 这可以修正问题——不管新的余额是多少,文件现在都会被关闭——但这样的修正意味着三个例程通过全局的cFile耦合在一起。我们在掉进陷阱,如果我们继续沿着这一方向前进,事情就会开始迅速变糟。 要有始有终这一提示告诉我们,分配资源的例程也应该释放它。通过稍稍重构代码,我们可以在此应用该提示: void readCustomer(FILE *cFile, Customer *cRec) { fread(cRec, sizeof(*cRec), 1, cFile); } void writeCustomer(FILE *cFile, Customer *cRec) { rewind(cFile); fwrite(cRec, sizeof(*cRec), 1, cFile); } void updateCustomer(const char *fName, double newBalance) { FILE *cFile; Customer cRec; cFile = fopen(fName, "r+"); // >--- readCustomer(cFile, &cRec); // / if (newBalance >= 0.0) { // / cRec.balance = newBalance; // / writeCustomer(cFile, &cRec); // / } // / fclose(cFile); // <--- } 现在updateCustomer例程承担了关于该文件的所有责任。它打开文件并(有始有终地)在退出前关闭它。例程配平了对文件的使用:打开和关闭在同一个地方,而且显然每一次打开都有对应的关闭。重构还移除了丑陋的全局变量。 嵌套的分配 对于一次需要不只一个资源的例程,可以对资源分配的基本模式进行扩展。有两个另外的建议: 1. 以与资源分配的次序相反的次序解除资源的分配。这样,如果一个资源含有对另一个资源的引用,你就不会造成资源被遗弃。 2. 在代码的不同地方分配同一组资源时,总是以相同的次序分配它们。这将降低发生死锁的可能性。(如果进程A申请了resource1,并正要申请resource2,而进程B申请了resource2,并试图获得resource1,这两个进程就会永远等待下去。) 不管我们在使用的是何种资源——事务、内存、文件、线程、窗口——基本的模式都适用: 无论是谁分配的资源,它都应该负责解除该资源的分配。但是,在有些语言中,我们可以进一步发展这个概念。 对象与异常 分配与解除分配的对称让人想起类的构造器与析构器。类代表某个资源,构造器给予你该资源类型的特定对象,而析构器将其从你的作用域中移除。 如果你是在用面向对象语言编程,你可能会发现把资源封装在类中很有用。每次你需要特定的资源类型时,你就实例化这个类的一个对象。当对象出作用域或是被垃圾收集器回收时,对象的析构器就会解除所包装资源的分配。 配平与异常 支持异常的语言可能会使解除资源的分配很棘手。如果有异常被抛出,你怎样保证在发生异常之前分配的所有资源都得到清理?答案在一定程度上取决于语言。 在C++异常机制下配平资源 C++支持try…catch异常机制。遗憾的是,这意味着在退出某个捕捉异常、并随即将其重新抛出的例程时,总是至少有两条可能的路径: void doSomething(void) { Node *n = new Node; try { // do something } catch (...) { delete n; throw; } delete n; } 注意我们创建的节点是在两个地方释放的——一次是在例程正常的退出路径上,一次是在异常处理器中。这显然违反了DRY原则,可能会发生维护问题。 但是,我们可以对C++的语义加以利用。局部对象在从包含它们的块中退出时会被自动销毁。这给了我们一些选择。如果情况允许,我们可以把“n”从指针改变为栈上实际的Node对象: void doSomething1(void) { Node n; try { // do something } catch (...) { throw; } } 在这里,不管是否抛出异常,我们都依靠C++自动处理Node对象的析构。 如果不可能不使用指针,可以通过在另一个类中包装资源(在这个例子中,资源是一个Node指针)获得同样的效果。 // Wrapper class for Node resources class NodeResource { Node *n; public: NodeResource() { n = new Node; } ~NodeResource() { delete n; } Node *operator->() { return n; } }; void doSomething2(void) { NodeResource n; try { // do something } catch (...) { throw; } } 现在包装类NodeResource确保了在其对象被销毁时,相应的节点也会被销毁。为了方便起见,包装提供了解除引用操作符->,这样它的使用者可以直接访问所包含的Node对象中的字段。 因为这一技术是如此有用,标准C++库提供了模板类auto_ptr,能自动包装动态分配的对象。 void doSomething3(void) { auto_ptr p (new Node); // Access the Node as p->... // Node automatically deleted at end } 在Java中配平资源 与C++不同,Java实现的是自动对象析构的一种“懒惰”形式。未被引用的对象被认为是垃圾收集的候选者,如果垃圾收集器回收它们,它们的finalize方法就会被调用。尽管这为开发者提供了便利,他们不再须要为大多数内存泄漏承受指责,但同时也使得实现C++方式的资源清理变得很困难。幸运的是,Java语言的设计者考虑周详地增加了一种语言特性进行补偿:finally子句。当try块含有finally子句时,如果try块中有任何语句被执行,该子句中的代码就保证会被执行。是否有异常抛出没有影响(即或try块中的代码执行了return语句)——finally子句中的代码都将会运行。这意味着我们可以通过这样的代码配平我们的资源使用: public void doSomething() throws IOException { File tmpFile = new File(tmpFileName); FileWriter tmp = new FileWriter(tmpFile); try { // do some work } finally { tmpFile.delete(); } } 该例程使用了一个临时文件,不管例程怎样退出,我们都要删除该文件。finally块使得我们能够简洁地表达这一意图。 当你无法配平资源时 有时基本的资源分配模式并不合适。这通常会出现在使用动态数据结构的程序中。一个例程将分配一块内存区,并把它链接进某个更大的数据结构中,这块内存可能会在那里呆上一段时间。 这里的诀窍是为内存分配设立一个语义不变项。你须要决定谁为某个聚集数据结构(aggregate data structure)中的数据负责。当你解除顶层结构的分配时会发生什么?你有三个主要选择: 1. 顶层结构还负责释放它包含的任何子结构。这些结构随即递归地删除它们包含的数据,等等。 2. 只是解除顶层结构的分配。它指向的(没有在别处引用的)任何结构都会被遗弃。 3. 如果顶层结构含有任何子结构,它就拒绝解除自身的分配。 这里的选择取决于每个数据结构自身的情形。但是,对于每个结构,你都须明确做出选择,并始终如一地实现你的选择。在像C这样的过程语言中实现其中的任何选择都可能会成问题:数据结构自身不是主动的。在这样的情形下,我们的偏好是为每个重要结构编写一个模块,为该结构提供分配和解除分配设施(这个模块也可以提供像调试打印、序列化、解序列化和遍历挂钩这样的设施)。 最后,如果追踪资源很棘手,你可以通过在动态分配的对象上实现一种引用计数方案,编写自己有限的自动垃圾回收机制。More Effective C++[Mey96]一书专设了一节讨论这一话题。 检查配平 因为注重实效的程序员谁也不信任,包括我们自己,所以我们觉得,构建代码、对资源确实得到了适当释放进行实际检查,这总是一个好主意。对于大多数应用,这通常意味着为每种资源类型编写包装,并使用这些包装追踪所有的分配和解除分配。在你的代码中的特定地方,程序逻辑将要求资源处在特定的状态中:使用包装对此进行检查。 例如,一个长期运行的、对请求进行服务的程序,很可能会在其主处理循环的顶部的某个地方等待下一个请求到达。这是确定自从上次循环执行以来,资源使用未曾增长的好地方。 在一个更低、但用处并非更少的层面上,你可以投资购买能检查运行中的程序的内存泄漏情况(及其他情况)的工具。Purify(www.rational.com)和Insure++(www.parasoft.com)是两种流行的选择。 相关内容: l 按合约设计,109页 l 断言式编程,122页 l 解耦与得墨忒耳法则,138页 挑战 l 尽管没有什么途径能够确保你总是释放资源,某些设计技术,如果能够始终如一地加以应用,将能对你有所帮助。在上文中我们讨论了为重要数据结构设立语义不变项可以怎样引导内存解除分配决策。考虑一下,“按合约设计”(109页)可以怎样帮助你提炼这个想法。 练习 22. 有些C和C++开发者故意在解除了某个指针引用的内存的分配之后,把该指针设为NULL。这为什么是个好主意? (解答在292页) 23. 有些Java开发者故意在使用完某个对象之后,把该对象变量设为NULL,这为什么是个好主意? (解答在292页) 相关内容: l 原型与便笺,53页 l 重构,184页 l 易于测试的代码,189页 l 无处不在的自动化,230页 l 无情的测试,237页 挑战 l 如果有人——比如银行柜台职员、汽车修理工或是店员——对你说蹩脚的借口,你会怎样反应?结果你会怎样想他们和他们的公司?
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。