必读网 - 人生必读的书

TXT下载此书 | 书籍信息


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

程序员修炼之道

_7 Andrew(美)
  当你遇到让人吃惊的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() |
* ev() != null
* implies
* lue().compare To(ev().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基类t.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形式的)注释,生成新的包含了断言逻辑的源文件。
返回书籍页