您的位置:首页 > 其它

《程序设计实践》笔记

2015-07-18 20:12 218 查看

第一章 风格

全局变量使用具有说明性的名字,局部变量用短名字。

按常规方式使用的局部变量可以采用极短的名字。

保持一致性。相关的东西应给以相关的名字,以说明它们的关系和差异。

对返回布尔类型值 (真或者假)的函数命名,应该清楚地反映其返回值情况。

使用表达式的自然形式。表达式应该写得你能大声念出来。

关系运算符 ( < <= == != >= > )比逻辑运算符(& &和| |)的优先级更高。

特定风格远没有一致地使用它们重要。应该取一种风格,当然作者希望是他们所采用的风格,然后一致地使用。

一致地使用习惯用法还有另一个优点,那就是使非标准的循环很容易被注意到,这种情况常常预示着有什么问题。

应该把所有的else垂直对齐,而不是分别让每个else与对应的if对齐。采用垂直对齐能够强调所有测试都是顺序进行的,而且能防止语句不断退向页的右边缘。

“从上面掉下”的方式在一种情况下是可以接受的,那就是几个case使用共同的代码段。

函数宏最常见的一个严重问题是:如果一个参数在定义中出现多次,它就可能被多次求值。

除了0和1之外,程序里出现的任何数大概都可以算是神秘的数,它们应该有自己的名字。

使用宏进行编程是一种很危险的方式,因为宏会在背地里改变程序的词法结构。我们应该让语言去做正确的工作。

如果在注释中只说明代码本身已经讲明的事情,或者与代码矛盾,或是以精心编排的形式干扰读者,那么它们就是帮了倒忙。最好的注释是简洁地点明程序的突出特征,或是提供一种概观,帮助别人理解程序。

注释应该提供那些不能一下子从代码中看到的东西,或者把那些散布在许多代码里的信息收集到一起。

有些代码原本非常复杂,可能是因为算法本身很复杂,或者是因为数据结构非常复杂。在这些情况下,用一段注释指明有关文献对读者也很有帮助。此外,说明做出某种决定的理由也很有价值。

许多注释在写的时候与代码是一致的。但是后来由于修正错误,程序改变了,可是注释常常还保持着原来的样子,从而导致注释与代码的脱节。无论产生脱节的原因何在,注释与代码矛盾总会使人感到困惑。由于误把错误注释当真,常常使许多实际查错工作耽误了大量时间。

注释是一种工具,它的作用就是帮助读者理解程序中的某些部分,而这些部分的意义不容易通过代码本身直接看到。

第二章 算法与数据结构

略。

第三章 设计与实现

本章设计了一个用马尔科夫链随机生成可以读的英文文本的程序,算法如下:

设置w1和w2为文本的前两个词
输出w1和w2
循环:
随机地选出 w3,它是文本中w1、w2的后缀中的一个。
打印w3。
把w1和w2分别换成w2和w3。
重复循环


选择了数据结构:

每个状态由一个前缀和一个后缀链表组成。所有这些信息存在一个散列表里,以前缀作为关键码。每个前缀是一个固定大小的词集合。如果一个后缀在给定前缀下的出现多于一次,则每个出现都单独包含在有关链表里。


并分别用Java、C++、Awk和Perl实现了它。不同语言的性能对比如下:



本章最后一节是关于设计的经验教训,主要内容有:

很容易把 Perl和Awk程序改造成使用一个词或三个词前缀的程序,但要想使这个选择能够参数化,就会遇到很多麻烦。

使用较高级的语言比更低级的语言写出的程序速度更慢,但这种说法只是定性的,把它随意推广也是不明智的。大型构件,如 C++的STL或脚本语言里的关联数组、字符串处理,能使代码更紧凑,开发时间也更短。

当系统内部提供的代码太多时,人们将无法知道程序在其表面之下到底做了什么。我们应该如何评价这种对控制和洞察力的丧失,这是更不清楚的事情。这也就是 STL版本中遇到的情况,它的性能无法预料,也没有很容易的办法去解决问题。

当所有东西都正常运转时,功能丰富的程序设计环境可以是非常有生产效率的,但是如果它们出了毛病,那就没什么东西可以依靠了。如果问题牵涉到的是性能或者某些难于捉摸的逻辑错误时,我们很可能根本没有意识到有什么东西出了毛病。

最好是从数据结构开始,在关于可以使用哪些算法的知识的指导下进行详细设计。当数据结构安置好后,代码就比较容易组织了。

要想先把一个程序完全设计好,然后再构造它,这是非常困难的。构造现实的程序总需要重复和试验。构造过程逼迫人们去把前面粗略做出的决定弄清楚。

做产品代码要花费的精力比做原型多得多。例如可以把这里给出的程序看作是产品代码( 因为它们已经被仔细打磨过,并经过了彻底的测试 )。产品质量要求我们付出的努力要比个人使用的程序高一两个数量级。

第四章 界面

在本章中,作者实现了一个可以读csv文件,并允许用户读取其中某一行或某一列的库。

首先,作者实现了一个原型,这个原型具有最基本的功能,但在随后的过程中将被抛弃。如果这个程序是提供给别人用的,在原始设计中的这些仓促选择引起的麻烦就可能到许多年后才浮现出来。这正是许多不良界面的历史画卷。

接下来,作者构造了一个具有普遍使用价值的库。在此过程中,需要考虑的主要问题有:

信息隐藏。这个库应该对输入行长或域的个数没有限制。为了达到这个目的,或者是让调用程序为它提供存储,或者是被调用者 ( 库) 自己需要做分配。

资源管理。必须确定谁负责共享的信息。函数 c s v g e t l i n e是直接返回原始数据,还是做一个拷贝?

谁来打开和关闭文件?做文件打开的部分也应负责关闭:互相匹配的操作应该在同一个层次或位置完成。

在错误发生的时候,库函数绝不能简单地死掉,而是应该把错误状态返回给调用程序,以便那里能采取适当的措施。

如果 csvfield或 csvnfiled在csvgetline遇到EOF之后被调用,它们的返回值是什么?具有错误形式的域将如何处理?

C++实现:略。

界面原则:

隐藏实现细节。对于程序的其他部分而言,界面后面的实现应该是隐藏的,这样才能使它的修改不影响或破坏别的东西。

应该避免全局变量。如果可能,最好是把所有需要引用的数据都通过函数参数传递给函数。

一般地说,窄的界面比宽的界面更受人欢迎,至少是在有了强有力的证据,说明确实需要给界面增加一些新功能之前。

不要在用户背后做小动作。一个库函数不应该写某个秘密文件、修改某个秘密变量,或者改变某些全局性数据,在改变其调用者的数据时也要特别谨慎。

一个界面在使用时不应该强求另外的东西,如果这样做仅仅是为了设计者或实现者的某些方便。

外部一致性,与其他东西的行为类似也是非常重要的。

在设计库( 或者类、包 ) 的界面时,一个最困难的问题就是管理某些资源,这些资源是库所拥有的,而又在库和它的调用程序之间共享。粗略地说,有关的问题大致涉及初始化、状态维护、共享和复制以及清除等等。

释放资源与分配资源应该在同一个层次进行。控制资源分配和回收有一种基本方式,那就是令完成资源分配的同一个库、程序包或界面也负责完成它的释放工作。

为了避免出问题,我们必须把代码写成可重入的,也就是说,无论存在多少个同时的执行,它都应该能正常工作。可重入代码要求避免使用全局变量、静态局部变量以及其他可能在别的线程里改变的变量。

作为一条具有普遍意义的规则,错误应该在尽可能低的层次上检测和发现,但应该在某个高一些的层次上处理。

如果能把各种各样的异常值 ( 如文件结束、可能的错误状态 )进一步区分开,而不是用单个返回值把它们堆在一起,那当然就更好了。

异常机制不应该用于处理可预期的返回值。读一个文件最终总要遇到文件结束,这个情况就应该以返回值的方式处理,而不是通过异常机制。

异常机制常常被人过度使用。由于异常是对控制流的一种旁路,它们可能使结构变得非常复杂,以至成为错误的根源。文件无法打开很难说是什么异常,在这种情况下产生一个异常有点过分。异常最好是保留给那些真正无法预期的事件,例如文件系统满或者浮点错误等等。

在发生错误时应该如何恢复有关的资源?如果发生了错误,库函数应该设法做这种恢复吗?通常它们不做这些事,但也可以在这方面提供一些帮助:提供尽可能清楚的信息和以尽可能无害的方式退出。

错误信息、提示符或对话框中的文本应该对合法输入给出说明。不要简单地说一个参数太大,而应说明参数值的合法范围。如果可能的话,给出的这段文本本身最好就是一段合法的输入,比如提供一个带合适参数的完整命令行。

从用户的观点看,风格问题,如简单性、清晰性、规范性、统一性、熟悉性和严谨性等,对于保证一个界面容易使用都是非常重要的,不具有这些性质的界面必定是令人讨厌的难对付的界面。

第五章 排错

排错系统:

在程序设计语言的发展中,一个重要的努力方向就是想通过语言特征的设计帮助避免错误。

每个为预防某些问题而设置的语言特征都会带来它自己的代价。如果一个高级语言能自动地去掉一些简单的错误,其代价就是使得它本身很容易产生一个高级的错误。

有些程序用排错系统很难处理,例如多进程的或多线程的程序、操作系统和分布式系统,这些程序通常只能通过低

级的方法排错。

作为个人的观点,我们倾向于除了为取得堆栈轨迹和一两个变量的值之外不去使用排错系统。

有线索的简单错误:

检查最近的改动。哪个是你的最后一个改动?如果你在程序发展中一次只改动了一个地方,那么错误很可能就在新的代码里,或者是由于这些改动而暴露出来。

不要两次犯同样的错误。当你改正了一个错误后,应该问问自己是否在程序里其他地方也犯过同样错误。

一个有效的但却没有受到足够重视的排错技术,那就是非常仔细地阅读代码,仔细想一段时间,但是不要急于去做修改。

应该稍微休息一下。有时你看到的代码实际上是你自己的意愿,而不是你实际写出的东西。离开它一小段时间能够松弛你的误解,帮助代码显出其本来面目。

另一种有效技术就是把你的代码解释给其他什么人,这常常会使你把错误也给自己解释清楚了。

无线索,难办的错误:

把错误弄成可以重现的。第一步应该是设法保证你能够使错误按自己的要求重现。

如果无法把错误弄成每次都出现的,那么就应该设法弄清为什么做不到。是否在某些条件下能使它比在其他条件下出现得更频繁?即使你无法保证错误每次都出现,如果你能减少等待它出现的时间,也就能够更快地找到它。

能否把导致程序失败的输入弄得更小一点,或者更集中一点?设法构造出最小的又能保证错误现身的输入,这样可以减少可能性。什么样的变化使错误不见了?

采用二分检索的方式,丢掉一半输入,看看输出是否还是错的。如果不是,回到前面状态,丢掉输入的另一半。

研究错误的计数特性。有时失败的实例具有计数特征方面的模式,这常常是很好的线索,能使我们在寻找中集中注意力。

显示输出,使搜索局部化。

写自检测代码。如果需要更多的信息,你可以写自己的检查函数去测试某些条件、打印出相关变量的值或者终止程序。

另一种战术是写一个记录文件,以某种固定格式写出一系列的排错输出。当程序垮台的时候,这个文件里已经记录了垮台前发生的情况。

如果上面的建议都没有用,那么又该怎么办?这可能是使用一个好的排错系统,以步进方式遍历程序的时候了。

如果你在做了大量努力后还是不能找到错误,那么就应该休息一下。清醒一下你的头脑,做一些别的事情,和一个朋友谈谈,请求帮助。问题的答案可能会突然从天而降。

偶然也会遇到这种情况,问题确实出在编译系统,或者库,或者操作系统,甚至是计算机硬件,特别是如果在错误出现的环境里的什么东西刚刚换过。

不可重现的错误:

应该先检查所有的变量是否都正确地进行了初始化。

如果在增加排错代码之后错误的行为改变了,甚至是消失了,那么它很可能就是一个存储分配错误—某个时候你的代码在被分配的存储之外写了什么东西。

其他人的程序错误:

grep一类的文本搜索程序有助于找到所有出现的名字;

交叉引用程序可以帮人看清程序结构的某些思想;

显示函数调用图 (如果不太大的话)也很有价值;

用一个排错系统,以步进方式一个一个函数地执行程序,可以帮人看清事件发生的顺序;

程序的版本历史可以给人一些线索,显示出随着时间变化人们对程序做了些什么。

第六章 测试

问题当然是发现得越早越好。如果你在写代码时就系统地考虑了应该写什么,那么也可以在程序构造过程中验证它的简单性质。

测试代码的边界情况。

防止问题发生的另一个方法,是验证在某段代码执行前所期望的或必须满足的性质 ( 前条件)、执行后的性质 ( 后条件) 是否成立。

断言机制对于检验界面性质特别有用,因为它可以使人注意到调用和被调用之间的不一致性,并可以进一步指出麻烦究竟是出在哪里。

有一种很有用的技术,那就是在程序里增加一些代码,专门处理所有“不可能”出现的情况,也就是处理那些从逻辑上讲不可能发生,但是或许 ( 由于其他地方的某些失误)可能出现的情况。

检查错误的返回值。一个常被忽略的防御措施是检查库函数或系统调用的返回值。

检查输出函数( 例如fprintf或fwrite)的返回值也可以发现一些错误,例如,要向一个文件写入,而磁盘上已经没有空间了。

在编程的过程中测试,其花费是最小的,而回报却特别优厚。在写程序过程中考虑测试问题,得到的将是更好的代码,因为在这时你对代码应该做些什么了解得最清楚。如果不这样做,而是一直等到某种东西崩溃了,到那时你可能已经忘记了代码是怎样工作的。即使是在强大的工作压力下,你也还必须重新把它弄清楚,这又要花费许多时间。

系统化测试:

以递增方式做测试。测试应该与程序的构造同步进行。

测试应该首先集中在程序中最简单的最经常执行的部分,只有在这些部分能正确工作之后,才应该继续下去。这样,在每个步骤中你使更多的东西经过了测试,对程序基本机制能够正确工作也建立了信心。

如果一个程序有逆计算,那么就检查通过该逆计算能否重新得到输入。

检验应保持不变的特征。

对于那些应该保持不变的特征,实际上也可以在程序内部进行检查。

有时一个回答可以由两条完全不同的途径得到,或许你可以写出一个程序的某种简单版本,作为一个慢的但却又是独立的参照物。

度量测试的覆盖面(完全覆盖常常很难做到,即使是不考虑那些“不可能发生”的语句)。

测试自动化:

自动化的最基本形式是回归测试,也就是说执行一系列测试,对某些东西的新版本与以前的版本做一个比较。在更正了一个错误之后,人们往往有一种自然的倾向,那就是只检查所做修改是否能行,但却经常忽略问题的另一面,所做的这个修改也可能破坏了其他东西。

回归测试实际上有一个隐含假定,假定程序以前的版本产生的输出是正确的。这个情况必须在开始时仔细进行审查,使这些不变性质能够一丝不苟地维持下去。

如果你发现了一个程序错误,那么又该怎么办?如果这个错误不是通过已有的测试发现的,那么你就应该建立一个能发现这个问题的新测试,并用那个崩溃的代码版本检验这个测试。

不要简单地把测试丢掉,因为它能够帮你确定一个错误报告是否正确,或是说明某些东西已经更正了。

要孤立地测试一个部件,通常必须构造出某种框架或者说是测试台,它应能提供足够的支持,并提供系统其他部分的一个界面,被测试部分将在该系统里运行。

应力测试:

采用大量由机器生成的输入是另一种有效的测试技术。机器生成的输入对程序的压力与人写的输入有所不同。量大本身也能够破坏某些东西,因为大量的输入可能导致输入缓冲区、数组或者计数器的溢出。

通过随机输入测试,考查的主要是程序的内部检查和防御机制,因为在这种情况下一般无法验证程序产生的输出是否正确。这种测试的目标主要是设法引起程序垮台,或者让它出现“不可能发生的情况” ,而不是想发现直接的错误。

有些测试是针对明显的恶意输入进行的。安全性攻击经常使用极大的或者不合法的输入,设法引起对已有数据的覆盖。

测试秘诀:

让散列函数返回某个常数值,使所有元素都跑到同一个散列桶里。

写一个你自己的存储分配函数,有意让它早早地就失败,利用它测试在出现存储器耗尽错误时设法恢复系统的那些代码。

把数组和变量初始化为某个可辨认的值,而不是总用默认的 0。这样,如果出现越界访问,或者取到了一个未初始化的变量值,你将更容易注意到它。

变动你的测试实例,特别是在用手工做小测试时。总使用同样东西很容易使人陷入某种常规,很可能忽略了其他的崩溃情况。

如果已经发现有错误存在,那么就不要继续设法去实现新特征或者再去测试已有的东西,因为那些错误有可能影响测试的结果。

测试输出中应该包括所有的参数设置,这可以使人容易准确地重做同样的测试。

在不同的机器、编译系统和操作系统上做测试。

你应该试着不考虑代码本身,仔细考虑最困难的测试实例,而不是那些容易的。

交互式程序应该能够通过脚本控制,这样我们就可以用脚本模拟用户的行为,使测试可以通过程序完成。这方面的一种技术是捕捉真实用户的动作,并重新播放它;另一种技术是建立一个能表述事件序列和时间的脚本语言。

最后,还应该想一想如何测试所用的测试代码本身。

第七章 性能

优化的第一要义是不做。程序是不是已经足够好了?

测量是改进性能过程中最关键的一环,推断和直觉都很容易受骗,所以在这里必须使用各种工具,如计时命令或轮廓文件等等。

对于写得很好的代码,很小的改变往往就能解决它的性能问题,而对那些设计拙劣的代码,经常会需要大范围地重写。

计时:

自动计时测量。 许多系统里都有这种命令,它们可用于测量一个程序到底用了多少时间。

除了可靠的计时方法外,在性能分析中最重要的工具就是一种能产生轮廓文件的系统。

画一个图。图形特别适合用来表现性能测量的情况。

加速策略(按照获益递减的顺序):

在你知道发生了什么之后,有一些可以采用的策略。我们列出几个,按照获益递减的顺序。

打开编译系统的优化开关

编译优化做得越多越深入,把错误引进编译结果 (程序)的可能性也就越大。

如果已经选择了正确的算法,程序的速度仍然是问题的话,下一步还能做的就是调整代码,整理循环和表达式的细节,设法使事情做得更快些。

如果有一个程序要运行一年,那么就应该从中挤压出你所能做到的一切。甚至在这个程序已经运行了一个月以后,如果你发现了一种能使它改进百分之十的方法,可能也值得从头再开始一次。存在竞争对象的程序—游戏程序、编译系统、字处理系统、电子表格系统和数据库系统等,都应该纳入这个类别。

代码调整:

高速缓存频繁使用的值。程序和人都有这种倾向,那就是重复使用最近访问过的值,或者是近旁的值,而对老的值和远距离的值则用得比较少。

如果程序中需要的经常是同样大小的存储块,采用一个特定用途的存储分配器取代一般的分配器,有可能使速度得到实质性提高。

对输入输出做缓冲。

特殊情况特殊处理。

预先算出某些值。

使用近似值。

在某个低级语言里重写代码。

估计

为一个语言或者系统做一次代价模拟却是比较简单的,它至少能使你对各种重要操作花费的时间有一个粗略的概念

例如,我们用一个 C和C++ 代价模拟程序估计了一些独立语句的代价,采用的方法就是把它们放在循环里,运行成百万次,然后计算出平均时间。

在这里也还有很多变数。一个是编译系统优化的级别。对有关执行情况难以做出预计,另一个重要的原因是计算机的体系结构。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: