程序设计实践读后感
程序设计实践读后感
风格
程序代码不仅要能高效执行,还要可读性强。
代码应该是清楚的和简单的—具有直截了当的逻辑、自然的表达式、通行的语言使用方式、有意义的名字和有帮助作用的注释等,应该避免耍小聪明的花招,不使用非正规的结构。
名字
一个变量的作用域越大,它的名字所携带的信息就应该越多。
全局变量使用具有说明性的名字,局部变量用短名字。根据定义,全局变量可以出现在整个
程序中的任何地方,因此它们的名字应该足够长,具有足够的说明性,以便使读者能够记得
它们是干什么用的。给每个全局变量声明附一个简短注释也非常有帮助。
现实中存在许多命名约定或者本地习惯。
有些程序设计工场采用的规则更加彻底,他们要求把变量的类型和用途等都编排进变量名字中。
函数采用动作性的名字。函数名应当用动作性的动词,后面可以跟着名词。
表达式和语句
给运算符两边加空格
表达式精简
当心副作用。像 ++ 这一类运算符具有副作用,它们除了返回一个值外,还将隐含地改变变量
的值。
一致性和习惯用法
一致性带来的将是更好的程序。如果程序中的格式很随意,例如对数组做循环,一会儿 采用下标变量从下到上的方式,一会儿又用从上到下的方式;对字符串一会儿用 s t r c p y做复 制,一会儿又用f o r循环做复制;等等。这些变化就会使人很难看清实际上到底是怎么回事。 而如果相同计算的每次出现总是采用同样方式,任何变化就预示着是经过了深思熟虑,要求 读程序的人注意。
绝不要使用函数g e t 等不安全的函数。
函数宏
C语言中尽量少用宏,或者宏公式之外加个括号
神秘的数
多用sizeof函数
注释
注释应该提供那些不能一下子从代码中看到的东西,或者把那些散布在许多代码里的信 息收集到一起。当某些难以捉摸的事情出现时,注释可以帮助澄清情况。如果操作本身非常 明了,重复谈论它们就是画蛇添足了。
不要与代码矛盾。许多注释在写的时候与代码是一致的。但是后来由于修正错误,程序改变 了,可是注释常常还保持着原来的样子,从而导致注释与代码的脱节。
为何对此费心
书写良好的代码更容易阅读和理解,几乎可以保证其中的错误更少。进 一步说,它们通常比那些马马虎虎地堆起来的、没有仔细推敲过的代码更短小。在这个拼命 要把代码送出门、去赶上最后期限的时代,人们很容易把风格丢在一旁,让将来去管它们吧。 但是,这很可能是一个代价非常昂贵的决定。
算法与数据结构
一个好的算法或数据结 构可能使某个原来需要用成年累月才能完成的问题在分秒之中得到解决。
检索
文章讲了二分检索的优势,其实就是空间换时间
排序
文章讲了快速排序的优越性。但是也有不足:如果每次 对基准值的选择都能将元素划分为数目差不多相等的两组,上面的分析就是正确的。但是, 如果执行中经常出现不平均的划分,算法的运行时间就可能接近于按 n 2增长。我们在实现中 采用随机选取元素作为基准值的方法,减少不正常的输入数据造成过多不平均划分的情况 。 但如果数组里所有的值都一样,这个实现每次都只能切下一个元素,这就会使算法的运行时 间达到与n 2成比例。
库
C和C++ 的标准库里已经包含了排序函数。它应该能够对付恶劣的输入数据,并运行得尽可能快。
大O记法
在这种描述中使用的基本参数是n,即问题实例的规模,把复杂性或运行时间表达为 n的函数 。这里的“O”表示量级( o r d e r ),比如说“二分检索是 O( l o gn)的”,也就是说它需要“通过 l o gn量级的步骤去检索一 个规模为n的数组”。记法O ( f(n) )表示当n增大时,运行时间至多将以正比于 f(n)的速度增长。 O(n 2 )和O(nl o gn)都是具体的例子。这种渐进估计对算法的理论分析和大致比较是非常有价值 的,但在实践中细节也可能造成差异。
数组
表
数组和表之间有一些很重要的差别。首先,数组具有固定的大小,而表则永远具有恰好能容纳其所有内容的大小,在这里每个项目都需要一个指针的附加存储开销。第二,通过修改几个指针,表里的各种情况很容易重新进行安排,与数组里需要做大面积的元素移动相比,修改几个指针的代价要小得多。最后,当有某些项被插入或者删除时,其他的项都不必移动。如果把指向一些项的指针存入其他数据结构的元素中,表的修改也不会使这些指针变为非法的。
树
树是一种分层性数据结构。在一棵树里存储着一组项,每个项保存一个值,它可以有指针指向0个或多个元素,但只能被另一个项所指。树根是其中惟一的例外,没有其他项的指针
指向它。
访问方式包含前序遍历、中序遍历、后序遍历。
散列表
它是由数组、表和一些数学方法相结合,构造起来的一种能够有效支持动态数据的存储和提取的结构。
散列表的思想就是把关键码送给一个散列函数,产生出一个散列值,这种值平均分布在一个适当的整数区间中。散列值被用作存储信息的表的下标。 J a v a提供了散列表的标准界面。在C和C++ 里,常见做法是为每一个散列值 (或称“桶”)关联一个项的链表,这些项共有这同一个散列值。
设计与实现
马尔科夫链算法
我们可以把输入想像成由一些互相重叠的短语构成的序列,而该算法把每个短语分割为两个部分:一部分是由多个词构成的前缀,另一部分是只包含一个词的后缀。马尔可夫链算法能够生成输出短语的序列,其方法是依据 (在我们的情况下)原文本的统计性质,随机性地选择跟在前缀后面的特定后缀。采用三个词的短语就能够工作得很好——利用连续两个词构成的前缀来选择作为后缀的一个词。
数据结构的选择
在C中构造数据结构
Java
C++
Awk和Perl
性能
C和C++ 程序都用带优化的编译器完成编译, J a v a程序运行时打开了即时编译功能。 I r i x的C和C + +编译是从三个不同编译器里选出的最快的一个,由 Sun SPA R C和DEC Alpha机器得到的数据也差不多。 C版本的程序是最快的,比别的程序都快得多。 P e r l程序的速度次之。表格里的时间是我们试验的一个剪影,用的是特定的编译器和库。在你自己的环境里做,得到的结果也可能与此有很大差别。
经验教训
界面
设计时需要考虑:界面、信息隐藏、资源管理、错误处理
逗号分隔的值
逗号分隔的值(comma-separated value),或C S V,是个术语,指的是一种用于表示表格数据的自然形式,使用很广泛。这里表格的每行是个正文行,行中不同的数据域由逗号分隔。看前面一章最后的那个表格,其开始的一段用 C S V格式表示大概是:
一个原型库
为别人用的库
错误处理很重要。一定要灵活用好try、exception
C++实现
我们不应该为了速度就使用 m e m c p y,为了安全又去用 m e m m o v e,最好是始终用一个总是安全的,而又比较快的函数。
界面原则
外部一致性,与其他东西的行为类似也是非常重要的。例如, C函数库里的m e m . . .函数是在 s t r . . .函数之后设计的,完全借用了它们前驱的风格。如果标准 I / O函数 f r e a d和f w r i t e看起来更像r e a d和w r i t e(这是它们的由来 )一些,它们也会更容易记忆。 U n i x系统的命令行参数都由一个负号引导,但是同一个选项字母可能意味着完全不同的东西,甚至对一些相互有关的程序也常常是这样。
资源管理
养成定时回收空间的好习惯。
注意临界资源的使用。
终止、重试或失败
假定我们写的函数不是为了自己用,而是为别人写程序提供一个库。那么,如果库里的 一个函数发现了某种不可恢复性的错误,它又该怎么办呢?在本章前面写的函数里,采取的做法是显示一段信息并令程序结束。这种方式对很多程序是可以接受的,特别是对那些小的独立工具或应用程序。而对另一些程序,终止就可能是错误的,因为这将完全排除程序其他部分进行恢复的可能性。例如,一个字处理系统必须由错误中恢复出来,这样它才能不丢掉你键入的文档内容。在某些情况下库函数甚至不应该显示信息,因为本程序段可能运行在某种特定的环境里,在这里显示信息有可能干扰其他显示数据,这种信息也可能被直接丢掉,没留下任何痕迹。在这种环境里,一种常用的方式是输出诊断情况,写入一个显式的记录文件,这个文件可以独立地进行监控和检查。
简而言之:多写日志。
用户界面
报错不要只报哪个参数错误,要把参数的范围,类型指出来。
排错
每个为预防某些问题而设置的语言特征都会带来它自己的代价。如果一个高级语言能自动地去掉一些简单的错误,其代价就是使得它本身很容易产生一个高级的错误。没有任何语言能够防止你犯错误。
排错系统
以单步方式遍历程序的方式,还不如努力思考,辅之以在关键位置加打印语句和检查代码。后者的效率更高。与审视认真安排的显示输出相比,通过点击经过许多语句花费的时间更长。确定在某个地方安放打印语句比以单步方式走到关键的代码段更快,即使是你已经知道要找的位置。更重要的是,用于排错的语句存在于程序之中,而排错系统的执行则是转瞬即逝的。
好线索,简单错误
实际中不应该出现的参数值也是重要线索,例如空指针,应该很小的整数值现在却特别大,应该是正的值现在是负的,字符串里的非字母字符等等。
有时你看到的代码实际上是你自己的意愿,而不是你实际写出的东西。离开它一小段时间能够松弛你的误解,帮助代码显出其本来面目。
无线索,难办的错误
把错误弄成可以重现的。第一步应该是设法保证你能够使错误按自己的要求重现。想驱除一 个并不是每次都出现的错误困难要大得多。
最后的手段
如果你在做了大量努力后还是不能找到错误,那么就应该休息一下。清醒一下你的头脑,做一些别的事情,和一个朋友谈谈,请求帮助。问题的答案可能会突然从天而降。即使情况不是这样,在下一次再做排错时,你多半也不会再走上次的老路了。
不可重现的错误
不能始终重现的错误是最难对付的,而且这种问题又不像硬件故障那么明显。根据这种非确定性的行为,能确认无疑的事实也就是信息本身,但这通常意味着多半不是算法本身有什么毛病,而是这些代码以某种方式使用了什么信息,而这些信息在每一次程序运行时又可能是不同的.
排错工具
排错系统并不是惟一能帮人检查程序错误的工具。还有许多程序也能帮我们的忙,例如从长长的输出里选出要点,发现其中的奇怪现象;或者重新安排数据,使人更容易看清情况是如何发展变化的,等等。很多这类程序都是标准工具箱的组成部分,还有的则需要我们自己写,以帮助发现一些特殊错误,或用于分析特定的程序。
其他人的程序错误
各种工具在这里都可能很有帮助。 g r e p一类的文本搜索程序有助于找到所有出现的名字;交叉引用程序可以帮人看清程序结构的某些思想;显示函数调用图 (如果不太大的话)也很有价值;用一个排错系统,以步进方式一个一个函数地执行程序,可以帮人看清事件发生的顺序;程序的版本历史可以给人一些线索,显示出随着时间变化人们对程序做了些什么。代码中的频繁改动是个信号,常常说明对问题的理解不够,或者表示需求发生了变化,这些经常是潜在错误的根源。
测试
测试和排错常常被说成是一个阶段,实际上它们根本不是同一件事。简单地说,排错是在你已经知道程序有问题时要做的事情。而测试则是在你在认为程序能工作的情况下,为设法打败它而进行的一整套确定的系统化的试验。
在编码过程中测试
注意边界测试。
系统化测试
代码分段写,分段测试。最后将每段正常运行的代码连接在一起。
测试自动化
自动回归测试。自动化的最基本形式是回归测试,也就是说执行一系列测试,对某些东西的新版本与以前的版本做一个比较。在更正了一个错误之后,人们往往有一种自然的倾向,那就是只检查所做修改是否能行,但却经常忽略问题的另一面,所做的这个修改也可能破坏了其他东西。回归测试的作用就在这里,它要设法保证,除了有意做过的修改之外,程序的行为没有任何其他变化。
在 U n i x上,像c m p或d i f f这样的文件比较程序可用于做输出的比较,s o r t可以把共同的元素弄到一起, g r e p可以过滤输出,w c、s u n和f r e q对输出做某些总结。利用所有这些很容易构造出一个专门的测试台。或许这些对于大程序还不够,但是对个人或者一个小组维护的程序则完全是适用的。
测试台
测试台的主要工作是设置输入参数、调用被测试函数,然后检查结果。如果要测试一个部分完成的程序,构造测试台可能就是个大工作了。
函数memset(s, c, n) 把存储器里从s开始的n个字节设置为c,最后返回s。如果不考虑速度,这个函数很容易写出来。
测试采用在可能出问题的点上做穷尽检查和边界条件测试相结合的方式。对于 m e m s e t而言,边界情况包括n的一些明显的值,如0、1和2,但也应包括2的各个幂次以及与之相近的值,
既有很小的值,也包括 2 1 6这样的大数,它对应许多机器的一种自然边界情况,一个 1 6位的字。2的幂值得注意,因为有一种加速 m e m s e t的方法就是一次设置多个字节,这可以通过特殊的指令做;也可能采用一次存一个字的方式,而不是按字节工作。与此类似,我们还需要检查数组开始位置的各种对齐情况,因为有些错误与开始位置或者长度有关。我们将把作为目标的数组放在一个更大的数组里面,以便在数组两边各建立一个缓冲区域或安全边缘,这也使我们可以方便地试验各种对齐情况。
应力测试
采用大量由机器生成的输入是另一种有效的测试技术。机器生成的输入对程序的压力与 人写的输入有所不同。量大本身也能够破坏某些东西,因为大量的输入可能导致输入缓冲区、数组或者计数器的溢出。这对于发现某些问题,例如程序对在固定大小存储区上的操作缺乏检查等,是非常有效的。人本身常常有回避“不可能”实例的倾向,这方面的例子如空的输入,超量级、超范围的输入、不大可能建立的特别长的名字或者特别大的数据值等等。与人相反,计算机会严格按照它的程序做生成,不会回避任何东西。
测试秘诀
程序都应该检查数组的界限 (如果语言本身不做这件事的话 ),但是,如果数组本身的大小比典型输入大得多时,这种检查代码就常常测试不到。为演习这种检查,我们可以临时地把数组改为很小的值,这样做比建立大的测试实例更容易些。
测试输出中应该包括所有的参数设置,这可以使人容易准确地重做同样的测试。如果你的程序使用了随机数,应当提供方法,设置和打印开始的种子值,并使程序的行为与测试中是否使用随机性无关。应当保证能准确标识测试输入和对应的输出,这样才能理解和重新生成它们。
谁来测试
由程序实现者或其他可以接触源代码的人做的测试有时也被称为白箱测试。
在黑箱测试中测试者不知道部件的实现方式。
黑箱测试的测试者对代码的内部结构毫不知情,也无法触及。这样就可能发现另一类的错误,因为做测试的人对该在哪里做检查可能另有一些猜测。
测试马尔可夫程序
性能
为大学课程作业而写的程序不会再去使用,速度一般是没有关系的。对于大部分个人程序、偶尔用用的工具、测试框架、各种试验和原型程序而言,通常也都没有速度问题。而在另一方面,对一个商业产品或者其中的核心部件,例如一个图形库,性能可能就是非常关键的。因此,我们还是需要理解如何去考虑性能问题。
瓶颈
为了解决特定问题,可以根据需求改写库函数。
本章的后面部分将详细讨论用于发现性能问题、隔离出过慢的代码并使其加速的各种技术。
计时和轮廓
如果一个函数消耗的时间远远不到一秒,那么就应该把它放在一个循环里运行,但这时就需要考虑循环本身的开销,如果这个开销所占的比例很大的话。
加速策略
让编译程序做优化。有一种毫不费力的改变就可能产生明显的加速效果,那就是打开编译系统的所有优化开关。现代编译程序已经做得非常好了,这实际上大大减小了程序员对程序做各种小改进的必要性。
优化程序只对被编译的源代码工作,也不再去处理系统库。实际上,也有些编译系统能做全局优化,它们分析整个程序,寻找所有可以改进的地方。如果在你的系统里有这种编译程序,那么也不妨试试它,说不定它能够进一步挤出一些指令周期来。
编译优化做得越多越深入,把错误引进编译结果 (程序)的可能性也就越大。
代码调整
在发现热点之后,存在许多可以使用的能缩短运行时间的技术。这里提出一些建议,不过使用时都应该小心,使用之后应该做回归测试,确认结果代码还能工作。请记住,好的编译程序能为我们做其中的许多优化工作,而且你所做的事情有时也可能使程序变得更加复杂,从而可能妨碍编译程序的工作。无论你做了些什么,都应该通过测量,确定其作用,弄清它是否真的有所帮助。
空间效率
如果可能的话就只使用一个位。请不要使用 C或C + +的位域,它们是高度不可移植的,而且倾向于产生大量的低效代码。你应该把所需要的操作都封装在函数里面,在这些操作中利用移位和掩码,取出或者设置字或字的数组里的位。
采用隐含的方式或者一种共同的形式存储公共值,在其他值上花更多的空间和时间。如果其中共性最强的值真正是共有的,这个招数就奏效了。
估计
对于常规的程序设计语言,可以用一个为有代表性的代码序列做计时的程序。在这里实际上存在着许多困难,比如很难取得可以重现的结果、难以消除无关开销等等。不过,这至少是一种不费多少事就能得到一些有用信息的途径。
可 移 植 性
应该设法写这样的软件,它能工作在它必须活动于其中的各种标准、界面和环境的交集里。不要为纠正每个移植性问题写一段特殊代码,正相反,应该修改这个软件本身,使它能够在新增加的限制下工作。利用抽象和封装机制限制和控制那些无法避免的
不可移植代码。通过将软件维持在各种限制的交集里面,局部化它的系统依赖性,这样你的代码在被移植后仍将更加清晰、更具通用性。
语言
得到可移植代码的第一步当然是使用某种高级语言,应该按照语言标准 (如果有的话)去写程序。二进制不可能很容易地移植,但是源代码可以。
头文件和库
使用标准库。在这里,应该提出与核心语言同样的建议:盯紧标准,特别是其中比较成熟的、构造良好的成分。C语言定义了标准库,其中包括许多函数,它们处理输入输出、字符串操作、字符类检测、存储分配以及另外的许多工作。如果你把与操作系统的交互限制在这些函数的范围内,那么如果要从一个系统搬到另一个,你的代码很有希望还能具有同样的行为方式,执行得很好。不过你也要当心,因为存在许多标准库的实现,其中有些包含了标准里未定义的行为。ANSIC没有定义串复制函数 s t r d u p,然而许多系统里都提供了它,甚至在那些声明自己完全符合标准的系统里。
程序组织
达到可移植性的方式,最重要的有两种,我们将把它们称为联合的方式和取交集的方式。联合方式使用各个特殊途径的最佳特征,采用条件式的编译和安装,根据各个具体环境的特殊情况分别进行处理。这样,结果代码是所有方案的一种联合,它可以利用各系统在能力方面的优点。这种方式的缺点包括:安装过程的规模和复杂性,由代码中大量费解的编译条件造成的复杂性等等。
只使用到处都可用的特征。我们建议采用取交集的方式,即:只使用那些在所有目标系统里都存在的特性,绝不使用那些并不是到处都能用的特征。强求使用普遍可用特性也有危险性,这可能限制了目标系统的范围,或者限制了程序的功能。此外,也可能在某些系统里导致性能方面的损失。
隔离
把系统依赖性局限在独立文件里。如果不同系统需要不同的代码,应该使这种差异局限在独立的文件里,一个文件对应一个系统。
数据交换
如果一个程序的输出并不正好适合做另一个程序的输入,我们可以用一个 Aw k或P e r l脚本去矫正它;可以用g r e p选择或者删除其中的一些行;可以用你最喜欢的编辑器对它做各种更复杂的
修改。正文文件很容易做文档,甚至可能再不需要做文档,因为人完全可以直接阅读它们。正文文件里可以写注释,指明处理这些数据需要什么版本的软件。例如在 P o s t S c r i p t文件里的
第一行就说明了它的编码方式。
字节序
写可移植的代码,总按照正规的顺序写出各个字节。
可移植性和升级
向后兼容是使程序符合其过去规范的一种能力。如果你打算修改一个程序,那就应该保证你没有破坏老的程序和依赖于它的数据。应该正确地修改文档,提供一些办法去恢复原来的行为方式。最重要的是,应该仔细考虑你计划做的改变是不是真正的改进,与你将引进的不可移植性的代价相比,是不是真正值得去做。
国际化
U T F - 8对A S C I I的向后兼容性是极其重要的。因为这就能保证,把正文当作不加解释的字节流的程序能在任何语言里对 U n i c o d e工作。