[编程风格要素] II 表达

Read《The Elements of Programming Style》 Kernighan and Plauger 2e.

翻译本书练习惯;下本书段练水平。


写计算机程序最终归结于用某种语言来写一系列的语句。怎么表达每条语句在很大程度上决定整个程序的可理解性;大量的注释、规范的格式(排版)以及额外的文档都不能代替表达良好的语句。毕竟,每条语句决定程序的实际功能。


用一系列过于简单的表达式去实现深奥问题容易出错。一个比较极端的例子如下:


10行代码,有4行赋值表达式和6行GOTO语句;这个程序肯定发生了一些事。在深入读这些代码之前,测试一下。这个程序实现了什么功能?


SMALL是一个中间变量---- 程序设置SMALL为X,Y, Z中的最小值。有很多方法来实现这样的计算。如果我们的目的是怎么得到最小值,我们可以将它写成:


这个程序表达直接且直达主题。根本不需要标号和GOTO语句。并且,这段程序也可以作为计算多个元素中的最小值的通用方法。


如果我们只是纯粹的想完成这个任务,我们用Fortran语言写一个AMIN1函数,这个函数的功能是计算2个或者更多浮点数中的最小值:

SMALL = AMINI1(X, Y, Z)

1行代码就代替了刚刚的10行代码。是不是应该考虑一下按照数大小来排序的程序块若太大时的可靠性?这样过大的程序块会拥有更大的几率让人产生迷惑,因此Bugs产生的机会也变多。若要更改这样的代码必将要理解更多的东西。

像AMIN1这样的库函数是明显减少程序复杂性的一个方法;库函数帮助程序员写尺寸合理(更易管理)的程序,并且,由于调用了库函数,程序员可以花更多的精力在程序的其它方面,而不是从一开始就在程序的每个点上都花时间。



但如果代码写的过分的简洁而使程序而弱化了程序的逻辑性,那么也将让人花更多的努力才会理解这样的程序。举个例子:


此程序虽然用了PL/I语言的内建函数INDEX和SUBSTR,但此程序不怎么清楚。INDEX(TEXT,‘ ‘)返回TEXT中第一个空格的位置,如果TEXT中无空格则返回0。SUBSTR(TEXT,A+1)从位置A+1处产生TEXT的子字符串;然后重新分配给EXT,连同最左边剩余的空格一起,处理了字符串。经稍加思考后,我们能够看出这个程序是在数TEXT内的空格数。


假如你正在尝试着教某个新手怎么用程序去数字符串中的空格,你会怎么教?你肯定不会用这种高端而神秘的方法----你会用“看字符串中的每个字符,如果这个字符是一个空格,那么记住它的个数”。或者,在PL/I语言中这样写:


这个程序也过多地使用了PL/I提供的内建函数,但它用这些内建函数表面了解决问题的思路。在最开始的时候,业内的都知道,调试一个程序比写一个程序要难两倍。当你在写程序时,如果你足够深思,那么你应该想到你调试程序的场景了?


用独有的方式表达程序经常不会写成有效的代码。程序员往往有一部分关于编译器如何生存代码的知识,所以用编译器“知道”的表达会更好。举个例子:



不管“高效”的意思是“花更少的时间”还是“生成更少的机器指令”,注释不总是对的。许多编译器在遇到F1**2是会将其生成F1*F1这样的代码。有一些编译器会将F1**2生成如下代码:

10 FX = (X1 _ X2)**2 + (1.0 – X2)**2

生成的代码要比原来的代码更短,执行的时间也会更快。(原版产生15条指令,后版生成13条指令)。


后续版本的可读性更强,同时也消除了只有少许价值的临时变量F1和F2。在程序中,临时变量越少,没被初始化或在使用前被意外更改的就会减少。“临时”在程序中是一个恶劣的词---- 使用这样的变量比使用一个通常(永久)的变量少些思考,并且可以重复使用这样的变量去保存计算的中间结果。这两种都是危险的用法。



哪怕在某个特殊的环境中这些关于效率的注释都是对的,也尽量不要使用带模糊性的表达式模型。我们将在第七章更深入的讨论效率这个问题。现在,我们来读一个要我们通过调试好几遍的方式来读的简单程序。对人来说要读懂此程序片段的意图就需要花更多的努力,在程序变得可用之前将会花程序员更长的时间。尝试并编写成功一个优化功能超过编译器的程序比依靠编译器本身的优化功能要可靠得多



一个演变的版本是:


此程序员通过在涉及二进制表达式中使用固定的二进制常量来避免在运行时的类型转换。此注释强调的是人们可能不清楚二进制串代表50。然而,根据这个提示,读者被期盼能够知道47的二进制。对早期的计算机语言提供的首要系列的服务就包括将十进制转换为二进制。如果我们不得不以二进制的方式来思考,到有些相形见绌,经过这么些年后,在程序中使用二进制被误传为一种“效率”。(大多数的编译器在编译时期会将47转换为对应的二进制。这样,不适用二进制就不存在效率会变低的担心了)


可以在程序中作一些介绍参数的注释,像MAXLINES,在程序文件开始部分已把它初始化为了47。这样,代码的可读性就变得更好,且更易更改。如果这里有比较费劲的转换,它也只会被编译器转换一次。



当浏览一个程序文件时,重复的地方很容易就会被我们看见。计算机是处理一些列重复工作的工具,我们应该从我们的视角习惯去更改一下代码的模式---- 为什么程序员不让机器去做这种重复的工作呢?在计算三角形的参数程序中有这么一段程序


我们理解就会发现定义两个算术函数的好处:



如此,我们就可以将代码写成


这样,程序不但更易编写而且更易更改。例如Fortran II标准中的ATANF在Fortran IV标准中被更改为ATAN。实际上,ATAN(Y/X)的格式应该被改为ATAN2(Y,X)。后者纠正了Y/X当X为0的情况。只需要在函数定义的地方加以修改,那么所有调用此函数进行的计算都被纠正了;这样,就有更大的可能得到正确的结果。原来的程序有一个打字错误的地方:AREA= SQRT(S * (S-BC) * (S-AC) * (S-AB)还差一个右括号。)


Fortran语言的算术函数应用被被不幸的限制在一行内,这样就限制了这些函数的作用。当需要执行的操作更复杂时,需要分别写多个子程序或者函数。后续理解、调试、修改程序的代价开销要比增加额外模块要大得多。



另一个被轻易看见的重复表达如下


我们的第一灵感是为程序中出现了两次较长的表达式写另外一个函数,但进一步查看程序,就会发现程序中还有更多的疏忽。


这个程序将以有100个连续点的VEL来计算X的值。用X相邻的两个值一定能够找到VEL,所以在程序每次迭代后都能够知道这两个值,即使有一个值在上一次迭代中就已经知道了值。精心制作的表达式被用来计算两次是必要的。但糟的是,只要此表达式需要被修改,则就需要修改两处,很有可能只修改了一个,而另外一个被忽略没有被修改。


这段程序还有一个错误:在两个表示式中用到的L,其实应该用AL。没那么严肃但却有潜在麻烦是多次设置了浮点变量(见第6章)。要将拥有一大堆程序错误的程序修改到正确且风格明了,我们需要在每次的循环中更好的计算TIME和THETA,将所有的代码组合如下:


(我们用$作为行连接符,$这个字符是Fortran标准中唯一一个没有任何语法意义的字符。它减少了语句过长会引起的困惑,并且如果将它用错在某列也是一个可见的错误。)


由于我们保存了数百个函数值,我们不必担心在内层循环中对2π的计算。我们依然坚持用标识符L,而不是将所有的改成AL。原始的程序的问题源于R和L;保留一直的批注比记住程序中的更改更为安全可靠。Fortran标准的批注与所需的使用不对应是其中一个不幸的场合。你可以用任何方式来为此争辩,但在这种情况下我们打算增加以下的一条语句:REAL L

这条语句比重命名一个变量更好。如果你有声明变量的习惯,那么将不会以上提及到的问题。


从直观上去写Fortran和PL/I中的算术表达式方面讲,二者有些不同之处。我们习惯按照代数的思维来写表达式而不是将各个表达式分开来写。也就是说,如果我们写了以下表达式:A * B / 2. 0 * C


我们想表达的原意是 (A*B) / (2.0 * C)    /*错误*/


但在Fortran和PL/I语言中,它们表示的实际含义是 ((A*B)/2.0) *C

只有用括号或者重新安排计算顺序我们才能够避免这种潜在的问题。


在一个反正切程序中有一个含有更为阴险模糊的运算符的表达式:TERM = TERM * (-X**2) / DENOM


在这个表达式中,X是先取反再平方,还是先平方了再取反?Fortran语言的参考手册很少对讲解这种问题的细节;如果不同过运行这段代码来测试的话,就很难回答这个问题。事实上,在Fortran的ANSI标准中是后一种解释– 变量X是先平方再取反– 这句代码应该这样写:TERM = -TERM * X**2 / DENOM


第一种写法已导致读者产生困惑,但是编译器能够识别。除非是读者和编译器都能够理解程序,不然这个程序就有写得模糊的嫌疑。



变量命名同样也有安全和危险之分:8 NO5S = NO5S + 1

“NOSS”这个表达式是由“N,字符O,5,S”组成的,还是由“N,0,5,S”组成的,甚至或者是“NOSS”?像这样的变量名,将其读错的可能性很多。你能够确保他人来抄写这段程序的时候一定能够抄写正确?使用相似的字符(字符O和数字0,字符l和数字1,等)、长的标识符区别在最后都是不安全的。使用XPOS,YPOS,不要使用POSITIONX和POSITIONY。当有缩写时,时常都是保持首要的字符,使用有利于“发音”的格式(XPOS,不使用XPSTN),以上提到的所有建议都可以被一致采纳。


通常,使用相似的标识符是危险的。某个程序中不会包含这样的序列:


然后进一步用WRITE(6,60)N, NN, NNN,…来读变量,很明显,第二行就有了拼写错误。一个比较好的选择命名方式是N,NSQ,NCUBE。尝试以广泛不同的方式来命名;拼写错误和错字就会更少发生。当然,选择的名字也要能够向读者透漏些信息,让代码的目的更加的明确一些。(我们将会在第8章深入讨论这个问题。)


我们花了相当多的时间来讨论了算术表达式,条件表达式至少和写程序一样重要。不管是在PL/I还是在Fortran程序中,条件表达式中几乎总会包含一条IF语句,这样的语句依据一些条件来确定另一条语句是否被执行。PL/I允许条件语句混合使用,所以程序就可能变得极其复杂,我们将在第三章展现出这些负责的例子。由于Fortran语言表达的限制,一些误用条件表达式的Fortran例子程序才是最糟糕的。


造成这个现状有一些历史原因。FortranII只有一个IF算术语句,这个语句不会像我们前面段落介绍那样工作。它不会在三条语句中实现分支,而是依靠算术表达式是否为负,零或者正来实现分支。逻辑语句IF在FortranIV中被增加,其有更清晰的表达方式。逻辑IF经常被用来代替算术IF语句,尤其是在三个标记中有两个标记几乎相同的情况下。


写一个让人理解的程序的一个富有成效的方法是减少有关联语句间的层次(缩进层),这样每个部分都可以被当作独立的模块来理解。Fortran中的IF算术语句的一个主要的副作用是它会增加各语句之间的联系:


给读者的第一感是满篇满篇的代码。第一行语句表达如果C-COMMA是负或者正时,控制流转向语句55处;如果结果为0,控制转向70。换句话讲,如果C等于COMMA,就分支往70处;否则,就全运行55处的程序。55和60处的程序也有类似的问题。


考虑所有的情况,如果C不是一个都喊并且C也不是分号、破折号,那么语句NC=NC+1将会被执行。用Fortran语言将这个含义用以下语句表达:50 IF (C .NE. COMMA .AND. C .NE. SCOL .AND. C .NE. DASH) NC = NC + 1


大多数人在理解算术IF是都会不自觉的将其转换为逻辑IF来理解,就像我们刚刚写这条语句一样。这里这样用算术IF是毫无根据的。


在这个程序中用这个版本的算术IF还有一个难点。左边缘的所有的标号代表程序分支到其它部分的标号。如果没有通读整个程序,那么你就不能确定是否还有其它的分支。但如果将这些语句都用一条IF来表达,无疑可以清楚的知道这段程序的开始和结束处,并且这段程序和其余的语句都没有联系。逻辑IF明显减少了程序的复杂性。


有时,算术IF的第三个分支能够直接成为错误处理代码。这时常需要我们合理的考虑来处理这样的情况。即使算术IF的三个分支都完全不同,然而,用两个字逻辑IF语句或一个GOTO将会增加程序的可读性。



算术IF会经常被当作逻辑IF使用。例如:IF((X(I) – X(N)) .LE. 0.) GO TO 300.

这个语句从字面上看就是一个将算术IF转换为了逻辑IF,这应该被写为:

IF (X(I) .LE. X(N)) GOTO 300


(写清楚程序具体的含义)。以下程序


被写成以下版本会更好:IF (MOD(K, N1) .EQ. 0) WRITE (6,4) K, X


在PL/I程序中也能看到相同的问题:



程序中只有一个GOTO分支语句,没有必要这样用。应被重写为:



使用Fortran的GOTO来计算会使条件表达式的含义模糊:


GOTO跳转的地方应该有一个定义,但是这里没有定义。由于标号65和70不能出现在程序的其它地方,所以这段程序可以被更好的写成:IF (PRNT .NE. 2) WRITE (6,105)  X


这样就消除了两条语句。现在我们看一眼程序就会明白,这里只有满足括号内的条件才能执行到WRITE语句。


最后还会列出三个例子来说明跟在IF语句后面的分支,即使没有必要使用这么多的IF语句。这样的程序会导致程序的功能具有拐弯抹角性,如:



IF控制了一个我们首先想执行的分支周围的一个分支。整理一下这个逻辑,可写为以下方式:



留给读者去思考是否要将IF语句换为:GRVAL= AMAX1(GRVAL, A(I)),当我们在两个元素中找更大的那个元素时。



尽管PL/I有足够的机制在不用分支语句时也可完成一个程序,但这一点常被被称为“带分号的Fortran”的风格忽略。滥用PL/I最终导致的代码会像这个排序程序:


THEN GOTO语句结构可能会使程序提前退出循环,但是它更可能会隐藏一些错误。这里只有在三条语句处分支,并没有退出循环。为什么不转换掉周围的测试或标签,这样就没有GOTO?(ELSE后面没有任何语句,是一个空的ELSE,没有提供任何作用;它仅会给程序带来困惑。)先做减法在和0比较的方式是一个较为不妥的方法,因为有上溢或下溢的危险;两个数一个直接的比较会更加安全且更易让他人理解。外层循环只需要执行N– 1次,内层循环需要执行N-M次。当然,PL/I允许在DO循环内有表达式,所以没有必要设置临时变量K。不规格的缩进也应该统一,这样就能够体现出各语句之间的层次关系。对程序做这些改进后如下所示:




潜在逻辑表述不清会导致杂乱的控制流,如这段用作计算机基本的日期服务的程序:


在阳光照耀之前我们曾在IF和GOTO语句的丛林中艰难的探索了很长的时间。这个程序在MALE(I)和FEM(I)值相同时写IBOY。Fortran标准不允许我们直接比较两个LOGICAL变量是否相等,但我们可以用.AND.和.OR.来提升程序的可读性:


只要FEM(I)与相关联的MALE(I)不同,这个程序告知我们程序不用打印IBOY就会继续读取下一个输入行。


我们也删掉了执行不到的STOP语句,同时也精确了READ语句的索引值,对代码进行了缩进,并对代码标号进行了排序。


另外,在日期程序中提供了一个简单的例子来表明了合适的数据呈现能够使程序更加的简单。使用INTEGER变量而不是用LOGICAL,我们就可以直接比较它们:


程序中的数据也不得不被改变,从T的1变道F的0,但是这个过程都对机器来说是比较简单的操作。我们将会在第三章深入讨论数据结构。


逻辑IF后面括号内的表达式时LOGICAL类型;它的值要么是.TRUE. 或者 .FALSE.。在大多数时间我们都仅用了一个关系运算符,如.LE.或者.EQ.去判别条件的真假。但其实我们可以用像.AND., .OR. 以及.NOT.这样的布尔运算符去表示任意复杂的逻辑表达式。布尔代数没有传统的算术运用广泛,我们再写逻辑表达式时要更加的小心免得困惑读者。


看以下代码:


读完这段代码才知道ICOUNT在ARRAY(I)在X1和X2之间时加1。倒置和GOTO语句都会降低程序的可读性,我们应该避免使用。将这段代码重新写为:6 IF (ARRAY(I) .GT. X1 .AND. ARRAY(I) .LT. X2) ICOUNT = ICOUNT + 1

这样的程序会然读者更加容易明白逻辑表达式的含义。


相互关联的逻辑表达式常能够结合在一起,并且它们的结合往往只需要单个操作运算符。例如:


不用使用冗余的0(毕竟0就是0,增多0的个数不会使得0更加精确),程序中有一个被质疑的规律:所有的跳转都是到语句500处。


使用逻辑表达式我们可以给出以下版本:


这个版本仍旧有些拗口,但由于每个部分都有相同的结构,都由相同的运算符构成,它也能够轻易被读者读懂了。


在PL/I中更容易写一个好的逻辑表达式,但它不能保证写出来的程序足够清晰,如:IF K=0 | (~(PRINT=’YES’  |  PRINT=’NO’)) THEN DO;


倒置和双括号降低了程序的可读性。在括号碗面使用’not’操作符会更好。根据摩根定律

~(A| B) 等价于 ~A& ~B

~(A & B) 等价于 ~A | ~B


我们可以将程序写为:IF K = 0 | (PRINT ~= ‘YES’ & PRINT ~= ‘NO’) THEN DO;


虽然这个表达式依旧显得不简单,但它似乎更接近我们所描述的逻辑了。注意我们依旧保持括号,尽管在这里没有必要,这样的括号对读者和编译器都具有良好的可读性。


测试程序是否清楚的一个有用的方法是“电话测试”。如果有人能够在电话的另一端听懂你所读的程序,那么这个程序就足够清楚了。否则,程序就需要重写。


在使用逻辑表达式时,灵活地使用摩根定律会提升程序的可读性。但也需要注意怎么实践它才可用。一个转换逻辑访问一个存储在线性表里的稀疏矩阵就是一个有陷阱的程序例子。这个程序在找到对应的行和列时就返回一个表值;否则就返回0。


如果NROW(K) .EQ. I .AND. NCOL(K) .EQ. J则稀疏矩阵在数组元素(I,J)的值为VALUE(K),用.NOT. (N ROW(K) .EQ. I) .OR.  .NOT.(NCOL(K) .EQ. J)来否定这个IF语句,函数返回的NROW(K).NE. I .OR. NCOL(K) .NE. J。


用函数内的IF语句来和这句作个比较。SPARSE函数是错误的;无论是I还是J匹配它都会返回。.AND.应该换成.OR.(这个错误在原稿的后续版本中已经被修正)。事实上,这个程序可以被更清楚的写成以下方式:


我们已经讨论了许多个含条件表达式的程序,它们要么难于被理解,要么误导人的思维,或者根本就不正确。让我们通过一个比较大的例子来给本章下一个结论,展示通过注意提到的这些小事情,怎么快速摆脱将一个程序难以被理解的一面。(这是第一个比较大的PL/I程序– 希望没有吓到你。)这个程序通过梯形法依据不同的步长来求抛物线y= x2在x=0和x=1之间的值。


这个程序长似手臂,给人留下十分深刻的印象。其中包含很多个数据声明的语句,接下来的计算明显复杂,完全可以写子程序来代替。声明语句都对得比较整齐,执行得语句间也相互交错显出了各语句间的层次。字符串可以再缩进一番,数字标识符可以暗示出每个结果是怎么得来的。对一个适度复杂的问题,通常的表达式转换要使得控制流通畅。


对这个程序进行进一步的检查,恰好相反。


每个输出信息都只被用了一次,应该更好的将其放在PUT语句中,而不是用赋值语句将声明和初始化分开。(有一条信息设置有拼写错误。)最开始的两条PUT语句可以被整理成:


还有这奇怪的“PUTSKIP EDIT(‘ ‘) A(A(1));”语句转变成了一句简单的PUTSKIP语句。这里并没有必要指定字符串的长度,计算机能够计算其长度。


赋值语句“M= 1 / K;”的意图显得不够清楚。要不要防御这个语句中的一些隐性转换?它会有显型的转换?或者程序员会不会担心计算机的除法会比乘法慢?很少有程序在将除法改为乘法之后程序的效率就变高了,并且M仅出现了两次。效率在这里显得不是那么重要,但不包含程序包含“AREA1= .5 * * *2 *L);”代码的情况。这里有冗余的乘法(但没有除法!)。M可以被消除。类似的,N,LMTS,L以及AREA1也能够省略。


我们可以把所有的用作保存中间结果的陌生声明都溢去掉。剩下的声明就由两个不同类型组成。再仔细观察发现K没有被声明。默认的K会被当做FIXEDBINARY类型,如此就会发生类型转换,这样没有好处。K应该被声明。


将所有的外来赋值都溢出,程序的结构就会显得清晰。这样,也更加容易的看清每个缩进。但,变量I是用来干嘛的?它保持和J相等,好让在程序的最后一次迭代中时调用OUT。显然,I是不必要的,J可以达到这个目的。而且,也不必测试;在内存循环终止后OUT会悲哀自动执行。但,OUT根本就不必被调用,因为它的代码之尅出现在一个地方。这样,程序结构就被简化了许多。


现在我们可以看到变量AREA在每层循环开始时被初始化。在进入循环前比在循环结束时要清晰得多– 这样就比较难看到这条语句。我们对这个程序的主要批判是OUT没有存在的必要,因为OUT有教学的原因,但是它在没有必要的前提下修改了AREA并用了LMTS。像这样摧毁模块、将本地变量随意的放在程序中的某个位置将会很易造成BUG。当这段代码被重写排版时,或许有的本地变量已经被改变,但错误能够被确定出来。


将这段代码改进如下:


现在的这个程序反应了程序表达的直接性(比较原始和现在的版本,参见问题2.4)


原始的程序能给出正确的答案,我们还能对此进行提升。成功的操作并不能保证其程序就是一个好程序。我们对程序作的改变可以减少程序的运行时间(由于其运行时间太少而几乎不能被可靠的测量出来),同时还减少了存储空间(提升了30%)。我们一直在考虑优化问题,我们应该将因子1/K3放到AREA外面计算。


我们对这个程序进行了哪些改进?主要是可读性,还有布局和程序结构的简单性。AREA在使用前被初始化,而不是在被分开的两个地方。这个计算从上到下都没有用无意义的子程序。原始程序充满了没有必要的声明和表达式,一个简单的计算却充满了复杂的结构。


程序并不是只用来运行一次的,也不是在写好后就永远不被修改。它们需要不断的被优化。整数程序的新版本将拥有一个更大的改进,尽管它没有任何的BUG。程序需要被维护。这就是我们修改的目标。


对本章用几点来总结:

(1)   将程序写清楚。如果你发现所写程序在分支旁或者在符号语句旁还有分支,用相关的测试语句换之。对每个GOTO语句,要保证其可读性良好。避免使用像Fortran语言的算术IF,不然将不得不使用GOTO语句和标号。

(2)   不要使用过多的临时变量。过多的临时变量将会困惑读者(包括作者),也有可能成为编译器优化代码的障碍。

(3)   将程序意图表达明确。使用括号且更改相似的标识符来避免令人产生误解的可能性。

(4)   不要构建所有的工具:使用标准库诸如ABS、AMIN1之类的函数。如果不存在所需功能的函数,那么独自写这样一个函数,并把他加进自己的库中。要充分保证自己写的函数在将来能被自己和别人再利用。

(5)   确保条件测试语句清晰。试着将它们大声念出来;重写笨拙的测试。


[2015.01.10 –01.23]

[Translate En Note Over]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值