多数程序员都接触过这样的程序,即使是优秀程序员多数也都至少编写过一个这样的程序:庞大、混乱、丑陋的程序,而它们本应该可以写得短小、清晰、漂亮。我曾经见过几个程序,本质上它们就相当于如下代码:
if (k == 1) c001++
if (k == 2) c002++
...
if (k == 500) c500++
虽然这些程序确实也完成了稍微复杂一些的任务,但是基本上可以认为它们的作用只是数了数文件中1~500每个整数出现的次数。每个程序的代码都超过了1 000行。今天的程序员多数都会立即意识到,自己可以编写一个长度仅为其零头的程序来完成该任务,方法就是使用一种不同的数据结构——一个有500个元素的数组来代替500个独立的变量。
因此,本章标题的完整意义是:恰当的数据视图实际上决定了程序的结构。本章描述了多种不同的程序,这些程序都可以通过重新组织内部数据而变得更小(并且更好)。
下面要研究的这个程序统计了某个学院的学生所填写的近2万份调查表。其部分输出如下所示:
Total US Perm Temp Male Female
Citi Visa Visa
African American 1 289 1 239 17 2 684 593
Mexican American 675 577 80 11 448 219
Native American 198 182 5 3 132 64
Spanish Surname 411 223 152 20 224 179
Asian American 519 312 152 41 247 270
Caucasian 16 272 15 663 355 33 9 367 6 836
Other 225 123 78 19 129 92
Totals 19 589 18 319 839 129 11 231 8 253
因为一些人没有回答全部问题,所以每个族裔组的男女人数之和比总人数略少。实际的输出则更为复杂。上面给出了全部的七行以及总数行,但仅有6列,分别代表总人数和另外两个大类:身份状态和性别。在实际问题中,共有25列分别代表8个大类,以及3页相似的输出:两页分别代表两个独立的学院,而另一页为这两者的总和。此外,还需要打印其他一些密切相关的表,例如拒绝回答每个问题的学生的数目。每份调查表使用一条记录来表示。在每条记录中,项0为族裔组,编码为0~7的整数(分别对应每一个族裔和“拒绝回答”),项1为学院(编码为0~2的整数),项2为身份状态,依次类推,直到项8。
程序员按照该系统分析员提供的高层设计来编写程序。在努力工作了两个月并完成了1 000行代码以后,程序员估计自己才完成了一半的工作量。在阅读了原始设计之后,我理解了该程序员的困境:程序使用350个不同的变量来实现——25列乘以7行,再乘以2页。完成变量声明之后,程序采用一系列的嵌套逻辑来判定在读入每条记录时,应该增加哪个变量。请用几分钟的时间思考一下这个问题,看看你会如何实现。
关键的决定是应当使用数组来存储这些数。作下一个决定则更难:该数组应该按照其输出的结构(学院、族裔组和25列)来组织,还是应该按照其数据输入的结构(学院、族裔组、大类和大类中的数值)来组织?忽略学院信息,上述方法可以表示如下:
这两种方法都可行。我编写的程序中所使用的三维视图(左)方法在数据读取的时候需要完成的工作量稍多些,而在输出时需要完成的工作量稍少些。程序由150行代码组成:80行构建该表,30行产生前述的输出,40行用来产生其他的表。
上述的计数程序和调查程序都过于庞大。两者都包含大量的用一个数组就可以代替的变量。将代码的长度减少一个数量级不仅可以得到开发周期更短的正确程序,而且更易于测试和维护。虽然在这两个应用中差别不是很大,但是,这两个小程序在运行时间和存储空间上还是会比大程序更高效。
在小程序可以完成任务的情况下,为什么程序员非要编写大程序呢?一个原因是他们缺少在2.5节中提到的重要的惰性。他们急于完成其最初的想法。在前面描述的两个问题中,有更深层次的原因:程序员在考虑该问题时受到了语言的限制。在他们所用的编程语言中,数组通常是固定的表格,并且必须在程序开始的时候初始化,此后不能再改变。在1.7节提到的James Adams的书中,他会说程序员遇到了“概念壁垒”,阻碍了计数器动态数组的使用。
导致程序员犯这类错误的原因还有很多。在准备编写这一章内容时,我在自己的调查程序中发现了一个类似的例子。程序的主输入循环由8个5条语句的块构成,共计40行代码。前两个语句块可以表示如下:
ethnicgroup = entry[0]
campus = entry[1]
if entry[2] == refused
declined[ethnicgroup, 2]++
else
j = 1 + entry[2]
count[campus, ethnicgroup, j]++
if entry[3] == refused
declined[ethnicgroup, 3]++
else
j = 4 + entry[3]
count[campus, ethnicgroup, j]++
将数组offset初始化为 0, 0, 1, 4, 6, …以后,我使用6行代码取代了原来的40行代码。
for i = [2, 8]
if entry[i] == refused
declined[ethnicgroup, i]++
else
j = offset[i] + entry[i]
count[campus, ethnicgroup, j]++
我对代码长度减少了一个数量级太满意了,结果忽视了另一个就在眼皮底下的问题。
在常去的网店键入你的名字和密码并成功登录以后,弹出的下一页网页类似这样:
Welcome back,Jane!
We hope that you and all the members
of the Public family are constantly
reminding your neighbors there
on Maple Street to shop with us.
AS usual,we will ship your order to
Ms. Jane Q. public
600 Maple Street
Your Town, Iowa 12345
...
作为程序员,你会意识到隐藏在这一幕之后所发生的事情——计算机在数据库中查找你的用户名并返回如下所示的字段:
Public|Jane|Q|Ms.|600|Maple Street|Your Town|Iowa|12345
但是,程序如何依据你的个人数据库记录来构建这个定制的网页呢?急躁的程序员可能会试图按照下面所示的方式开始编写程序:
read lastname, firstname, init, title, streetnum,
streetname, tomn, state, zip
print "Welcome back,",firstname, "!"
print "We hope that you and all the members"
print "of the", lastname, "family are constantly"
print "reminding your neighbous there"
print "on", streetname, "to shop with us. "
print "As usual, we will ship your order to"
print " ",title, firstname, init ".", lastname
print " ", streetnum, streetname
print " ", town ",", state, zip
...
这样的程序很有诱惑性,但是也很乏味。
一个更巧妙的方法是编写一个格式信函发生器(form letter generator)。该发生器基于下面所示的格式信函模板(form letter schema):
Welcome back, $1!
We hope that you and all the members
of the $0 family are constantly
reminding your neighbors there
on $5 to shop with us.
As usual, we will ship your order to
$3 $1 $2. $0
$4 $5
$6, $7 $8
...
符号$i代表记录中的第i个字段。于是,$0代表姓,等等。模板使用下面的伪代码来解释。在伪代码中,文字符号$在输入模板中记为$$。
read fields from database
loop from start to end of schema
c = next character in schema
if c ! ='$'
printchar c
else
c = next character in schema
case c of
'$': printchar '$'
'0' - '9': printstring field[c]
default: error("bad schema")
在程序中,该模板使用一个长字符串数组表示。数组中的文本行以换行符结束。(Perl和其他脚本语言使其更容易实现。可以使用形如$lastname的变量。)
编写该发生器和模板程序比编写显而易见的程序要简单些。将数据从控制中分离会获得许多好处:如果重新设计信函,那么模板可以使用文本编辑器来修改,从而第二个特定页的准备会很简单。
报表模板的概念曾极大地简化了我维护过的一个5 300行代码的Cobol程序。程序的输入是家庭财务状况的描述,其输出是一个小册子,总结了财务现状并推荐未来理财策略。这里是一些相关数值:120个输入字段、18页上的400行输出语句、300行用来清除输入数据的代码、800行用于计算的代码以及4 200行用于输出的代码。据我估算:4 200行的输出代码可以使用一个最多几十行代码的解释程序和一个400行的模板来代替,而代码的计算部分保持不变。按这种形式编写原始程序所得到的Cobol代码的长度至多为原来的三分之一,并且维护起来也容易得多。
菜单。我希望我的Visual Basic程序的用户可以通过点击菜单项来实现在几个选项之间的选择。我浏览了一系列的优秀示例程序,发现了一个允许用户在选项中进行八选一操作的程序。查看该菜单对应的代码,得到如下所示的选项0的代码:
sub menuitem0_click()
menuitem0.checked = 1
menuitem1.checked = 0
menuitem2.checked = 0
menuitem3.checked = 0
menuitem4.checked = 0
menuitem5.checked = 0
menuitem6.checked = 0
menuitem7.checked = 0
选项1的代码几乎是一样的,相异的部分如下:
sub menuitem1_click()
menuitem0.checked = 0
menuitem1.checked = 1
...
依次类推,选项2至选项7亦是如此。总而言之,菜单项的选择总计需要大约100行代码。
我自己编写的程序也与之相似。我从有两个选项的菜单着手编程,此时的代码是合理的。当我添加第三个、第四个和后续的选项时,我为代码所具有的功能而倍感兴奋,以至于没能停下来去整理混乱的代码。
稍作观察以后,可以将大部分代码转化为一个函数uncheckall,该函数将每个checked字段置0。于是第一个函数变成:
sub menuitem0_click()
uncheckall
menuitem0.checked = 1
但是,此时的代码中还是有7个相似的函数。
幸运的是,Visual Basic支持菜单选项数组。因此可以将8个相似的函数使用一个函数表示:
sub menuitem_click(int choice)
for i = [0, numchoices)
menuitem[i].checked = 0
menuitem[choice].checked = 1
将重复的代码使用通用的函数表示,使程序由100行减少至25行,而数组的恰当使用又使代码减至4行。添加下一个选择也更容易,并且可能存在错误的程序现在犹如水晶一般晶莹剔透。该方法仅仅使用了几行代码就解决了我的问题。
出错信息。混乱系统的数百个出错信息散布在所有代码中。同时,这些出错信息又与其他输出语句混杂在一起。而清晰系统则通过一个专用函数来访问这些出错信息。考虑一下分别使用“混乱”和“清晰”两种组织形式来实现下面这种需求的难度:产生所有可能的出错信息列表,使每个“严重”出错信息产生一声报警并将出错信息翻译成法语或德语。
日期函数。给定年份和该年中的某一天,返回该天所处的月份和月中的日子。例如,2004年的第61天是3月1日。在其Elements of Programming Style中,Kernighan和Plauger给出了一个直接从他人的程序中摘录出来的实现该任务的55行程序。随后,他们用一个5行的程序解决了该问题,该程序用到了一个有26个整数的数组。习题4介绍了关于日期函数表示的问题。
单词分析。许多计算问题都是由英文单词的分析引起的。在13.8节将会看到拼写检查器如何使用“后缀去除”来精简字典:例如单词“laugh”就不存储其所有的不同结尾(“-ing”“-s”“-ed”等)。语言学家们已经得出了对应这些任务的一系列法则。1973年,Doug McIlroy在编写他的第一个实时文本语音合成器的时候,就知道代码并不适合表示这些法则。他更愿意使用1 000行代码和一个400行的表来实现。有人尝试在不增加表的情况下修改程序,其结果是增加20%的内容就需要增加2 500行额外的代码。McIlroy声称他现在可以通过增加更多的表,使用少于1 000行的代码来完成该扩充任务。需要自己尝试一下类似的法则集的话,见习题5。
什么才是结构清晰的数据?随着时间的推移,其标准也在逐步提高。早些年,结构化数据就意味着选择恰当的变量名。后来,在程序员使用平行数组(parallel array)[2]或寄存器偏移量的地方,编程语言加入了记录或结构以及指向它们的指针。我们学会了使用名为insert或search的函数来代替处理数据的代码,这有助于在改变数据的表达方式时不损坏程序的其他部分。David Parnas[3]对这种方法进行了扩展,他发现对系统待处理数据进行研究可以深入认识到优秀的模块化结构。
下一步是“面向对象编程”。程序员们学会识别设计中的基本对象,向外公开一个抽象的对象及其基本操作,并隐藏具体的实现细节。使用诸如Smalltalk和C++的编程语言,可以将这些对象封装在类中。在第13章中,我们在研究集合的抽象和实现时会仔细研究这种方法。
曾几何时,程序员需要从头开始编写每个应用程序。现代工具允许程序员(以及其他人员)花费最少的精力来编写应用程序。本节所列出的一些工具仅为示范性的,并不完备。每种工具都使用数据的某一视图来解决特定但又通用的问题。诸如Visual Basic、Tcl等语言和各种shell都提供了连接这些对象的“胶水”。
超文本。在20世纪90年代早期,网站的数量还只有数千个的时候,我所阅读的入门参考书都是存储在CD-ROM上面的。那些资料令人眼花缭乱,包括百科全书、字典、年鉴、电话号码簿、古典文学、教科书、系统参考手册等,所有这些资料都可以放在我的手掌心里。不幸的是,不同资料集的用户界面也是一样地令人头晕目眩:每个程序都有其特别之处。现在我可以轻松地访问所有CD上的或网上的数据(甚至更多),而我所用的界面通常就是网页浏览器。这使用户和开发人员都轻松多了。
名字—值对。书目数据库中的项可能如下所示:
%title The C++ Programming Language, Third Edition
%author Bjarne Stroustrup
%publisher Addison-Wesley
%city Reading, Massachusetts
%yesr 1997
Visual Basic使用这种方法描述界面的控件。窗体左上角的文本框可以使用如下的属性(名字)和设置(值)来描述:
(完整的文本框包含36个名字—值对。)例如要展宽文本框时,可以使用鼠标拖动右边框,或者输入一个更大的整数来替代215,或者使用运行时赋值语句
txtSample.Width = 400
程序员可以选择最方便的方式来操作这个简单但功能很强大的结构。
电子表格。搞明白本部门的预算对我来说似乎有点困难。习惯上,我会为这项工作编写一个庞大的程序,用户界面也是沉闷生硬的。而另一位程序员从一个更广的视角入手,采用电子表格实现该程序,同时也使用了少量的Visual Basic函数。用户界面对财务人员等主要用户来说很熟悉。(如果今天我还需要编写大学调查程序,数据为数值数组的这个事实会促使我尝试将数据放到电子表格中。)
数据库。多年以前,一位程序员在纸质日志上记录了他最初的十几次跳伞的详细信息以后,决定将自己跳伞数据的记录自动化。再早几年,记录这样的数据需要使用复杂的记录格式,并且需要使用手工程序(或使用“报表程序发生器”)来完成数据的录入、更新和提取。当时,该程序员和我都被他完成该工作时所使用的新发明的商业数据库震惊了。他可以在几分钟之内完成数据库操作的新界面,而不再需要几天的时间。
特定领域的编程语言。图形用户界面(GUI)已经替代了许多古老沉闷的文本语言。但是特殊用途的编程语言在某些应用程序中依然很有效。当需要计算数据时,我并不喜欢使用鼠标在屏幕上点击一个虚拟的计算器,而是倾向于采用如下所示的方式直接输入数学公式:
n = 1000000
47 * n * log(n)/log(2)
相比于用炫丽的文本框和操作按钮组合来定义一个查询,我更倾向于用下面这样的语言来写:
(design or architecture) and not building
以前使用数百行可执行代码来定义的窗口,现在可以使用数十行HTML代码来定义。这些语言对一般的用户输入来说可能不够时尚了,但是在某些应用场合它们依然是有效的工具。
虽然本章中的故事横跨数十年并涉及多种编程语言,但是每个故事的精髓都是一致的:“能用小程序实现的,就不要编写大程序”。许多结构都见证了Polya在How to Solve It [4]一书中提到的发明家悖论:“更一般性的问题也许更容易解决”。对于程序设计来说,这意味着直接编写解决23种情况的问题很困难;而编写一个处理n种情况的通用程序,再令n = 23来得到最终结果,却相对要容易一些。
本章集中讨论了数据结构对软件的一个贡献:将大程序缩减为小程序。数据结构设计还有许多其他正面影响,包括节省时间和空间、提高可移植性和可维护性。Fred Brooks[5]在《人月神话》第9章中的评论就是针对节省空间的。而对于想要获得其他属性的程序员来说,下面的建议可谓金玉良言:
程序员在节省空间方面无计可施时,将自己从代码中解脱出来,退回起点并集中心力研究数据,常常能有奇效。(数据的)表示形式是程序设计的根本。
下面是退回起点进行思考时的几条原则。
1.本书行将出版之时,美国的个人收入所得税分为5种不同的税率,其中最大的税率大约为40%。以前的情况则更为复杂,税率也更高。下面所示的程序文本采用25个if语句的合理方法来计算1978年的美国联邦所得税。税率序列为0.14,0.15,0.16,0.17,0.18,…。序列中此后的增幅大于0.01。有何建议呢?
if income <= 2200
tax = 0
else if income < 2700
tax = .14 * (income - 2200)
else if income <= 3200
tax = 70 + .15 * (income - 2700)
else if income <= 3700
tax = 145 + .16 * (income - 3200)
else if income <= 4200
tax = 225 + .17 * (income - 3700)
...
else
tax = 53090 + .70 * (income - 102200)
2.k阶常系数线性递归定义的级数如下:
,
其中,
,…,
为实数。编写一个程序,其输入为k,
,…,
,
,…,
和m,输出为
至
。
该程序与计算一个具体的15阶递归的程序相比会复杂多少?不使用数组又如何实现呢?
3.编写一个“banner”函数,该函数的输入为大写字母,输出为一个字符数组,该数组以图形化的方式表示该字母。
4.编写处理如下日期问题的函数:给定两个日期,计算两者之间的天数;给定一个日期,返回值为周几;给定月和年,使用字符数组生成该月的日历。
5.本习题处理英语中的一小部分连字符问题。下面所示的规则描述了以字母“c”结尾的单词的一些合法的连字符现象:
et-ic al-is-tic s-tic p-tic -lyt-ic ot-ic an-tic n-tic c-tic at-ic h-nic n-ic m-ic l-lic b-lic -clic l-ic h-ic f-ic d-ic -bic a-ic -mac i-ac
规则的应用必须按照上述顺序进行;因此,有连字符“eth-nic”(由规则“h-nic”捕获)和“clin-ic”(前一测试失败,然后满足“n-ic”)。如何用函数来表达该规则?要求函数的输入为单词,返回值必须是后缀连字符。
6.编写一个“格式信函发生器”,使之可以通过数据库中的每条记录来生成定制的文档(这常常称为“邮件归并”特性)。设计简短的模板和输入文件来测试程序的正确性。
7.常见的字典允许用户查找单词的定义。习题2.1描述了允许用户查找变位词的字典。设计查找单词正确拼写的字典和查找单词的押韵词的字典。讨论具有以下功能的字典:查找整数序列(例如,1,1,2,3,5,8,13,21,…)、化学结构或者歌曲韵律结构。
8.[S. C. Johnson]七段显示设备实现了十进制数字:
的廉价显示。七段显示通常如下编号:
编写一个使用5个七段显示数字来显示16位正整数的程序。输出为一个5字节的数组,当且仅当数字j中的第i段点亮时,字节j中的位i置1。
本文摘自《编程珠玑 第2版》,[美] 乔恩·本特利(Jon Bentley) 著,黄倩,钱丽艳 译
多年以来,当让程序员推选喜爱的计算机图书时,《编程珠玑》总是位于前列。正如自然界里珍珠出自细沙对牡蛎的磨砺,计算机科学大师乔恩·本特利以其独有的洞察力和创造力,从磨砺程序员的实际问题中凝结出一篇篇编程“珠玑”,成为世界计算机界名刊《ACM通讯》历史上*受欢迎的专栏,*终结集为两部计算机科学经典名著,影响和激励着一代又一代程序员和计算机科学工作者。本书为第一卷,主要讨论计算机科学中*本质的问题:如何正确选择和高效地实现算法。
在书中,作者选取许多具有典型意义的复杂编程和算法问题,生动描绘了历史上大师们在探索解决方案中发生的轶事、走过的弯路和不断精益求精的历程,引导读者像真正的程序员和软件工程师那样富于创新性地思考,并透彻阐述和总结了许多独特而精妙的设计原则、思考和解决问题的方法以及实用程序设计技巧。解决方案的代码均以C/C++语言编写,不仅有趣,而且有很大的实战示范意义。每章后所附习题极具挑战性和启发性,书末给出了简洁的解答。