3.2 语句级控制结构
语句级控制结构是语言用来构造各种语句执行顺序的机制。传统语言有3种语句级控制结构:顺序(Sequencing)、选择(Selection)和重复(Repetition)。语句级控制结构对程序的可读性和可维护性是很重要的,下面将对3种控制结构分别加以讨论。
3.2.1 顺序结构
顺序结构是语言可用的、最简单的控制结构,例如
A;B
表明语句A执行之后,紧接着执行语句B。其中“;”是用来分隔语句并对语句定序的控制算符,称为顺序运算符(Sequencing Operator)。若干个语句可以通过顺序运算符组合在一起作为一个单独的语句,这样的语句在ALGOL 60和Pascal中称为复合语句(Compound State-ment),它们是用语句括号begin和end括起来的。形式如下:
begin S1;S2;…;Sn end
其中,S1,S2,…,Sn都是语句。
采用行格式的语言,例如FORTRAN,以行结束符来分隔语句,并强制规定它们按顺序执行。
3.2.2 选择结构
选择控制结构允许程序员在某些可选择的语句中做出一种选择来执行。FORTRAN语言的逻辑IF语句是选择语句的一个例子,它根据布尔表达式的值来规定执行语句的顺序。例如
IF(I.GT.0)I=I-1
规定若I为正数,I的值减1;否则,不执行I=I-1。
if语句是功能更强的选择语句,类ALGOL及FORTRAN 77都有这样的语句。if子句是测试条件,then和else子句是两个可选择项,根据执行if子句的测试结果对它们做出一种选择,即从两个控制路径中做出一种选择。例如
if i=0 then i:=j else begin i:=i+1; j:=j-1 end
若i=0,则j的值赋予i;反之,i加1,j减1。可选择项可以是任意语句,包括复合语句。若上例中删除begin和end,那么在i=0时也要执行j:=j-1
ALGOL 60语言的选择结构会引起二义性(Ambiguity),例如
if x>0 then if x<10 then x:=0 else x:=1000
不能确定else是内层条件(if x<10)还是外层条件(if x>0)的分支。若在执行if语句之前,x具有值15,那么,按照两种不同的解释,x可能被赋予1000,或者根本不改变,存在两种不同的结果。为了消除二义性,ALGOL 60要求then分支不允许再嵌入条件语句,若一定要嵌入,可加入语句括号begin和end。这样,上述语句应写成
if x>0 then begin if x<10 then x:=0 else x:=1000 end
或
if x>0 then begin if x<10 then x:=0 end else x:=1000
PL/1和Pascal语言为了解决二义性问题,规定else分支自动匹配最近的无else的条件,这就是所谓“最近匹配”原则。按照这一原则,前一种条件语句取消语句括号begin和end也是正确的。这样的无二义规则在语言定义中已说明,它虽然消除了二义性,但条件结构若多层嵌套,读起来也是非常困难的。所以,使用begin和end来显式指明if结构的分支是明智的,以免产生不必要的错误。
ALGOL 68语言为了解决这个问题,在语法上为if语句增加了一个结束符号(或称右半括号)fi,这样在then和else分支不再使用语句括号begin和end。上述if语句在ALGOL 68中可以分别写成
if x>0 then i:=j else i:=i+1; j:=j-1 fi
和
if x>0 then if x<0 then x:=0 else x:=1000 fi fi
及
if x>0 then if x<10 then x:=0 fi else x:=1000 fi
Ada语言采用类似的方法,相对if的右半括号用end if。
若要在许多选择项的情况下做出一个选择,ALGOL 68和Ada允许程序员根据不同条件采用缩写的办法。例如
if a then S1 else if b then S2 else if c then S3 else S4 fi fi fi
是一个正确的程序段,但很难读,可用ALGOL 68缩写符elif (Ada使用elsif)来消除内层的fi,即
if a then S1 elif b then S2 elif c then S3 else S4 fi
这样,程序就好读多了。
PL/1语言采用特别的SELECT结构来规定从两个以上的分支中做出选择。上例在PL/1语言中可以改写成
SELECT: WHEN(A)S1; WHEN(B)S2; WHEN(C)S3; OTHERWISE S4; END;
在ALGOL 68,Pascal,C和Ada语言中使用case语句来表示多重选择结构,它根据表达式的值规定分支的选择。例如,下列Pascal程序段:
var operator:char; operand1,operand2,result:boolean; …… case operator of '·':result:=operand1 and operand2; '+':result:=operand1 or operand2; '=':result:=operand1=operand2 end
根据字符变量operator的值来确定对operand 1和operand 2的布尔操作,以计算result。
在ALGOL 68中,case语句基于整型表达式的值,若表达式的值是i,那么选择第i个分支执行。而Pascal基于任何有序类型表达式的值,每个分支按照表达式可能计算出的值显式列出,因此每个分支在程序的case中出现的次序是无关紧要的。
原始Pascal定义没有规定当选择表达式的值超出显式列出的值的范围时,应如何获得结果。后来ISO Pascal规定,在这种情况下属于出错。ALGOL 68规定了一个选择子句——out子句,当选择表达式的值不在指明的值的集合中时,执行out子句。若out子句省略,以skip语句(空语句)来代替,即此时什么也不做。许多Pascal使用otherwise选择项来覆盖这种情况。
Ada语言的多重选择结合了ALGOL 68和Pascal的功能。首先,选择分支使用枚举类型和整数类型的表达式;其次,在选择中要提供判别表达式的类型的所有可能值。利用缩写others来代表所有未显式列出的值。这样,上例在Ada中可改写成case OPERATOR of
when"·"=>RESULT:=OPERAND1 and OPERAND 2; when"+"=>RESULT:=OPERAND1 or OPERAND 2; when"="=>RESULT:=OPERAND1 = OPERAND 2; when others=>…产生错误信息… end case;
科学家Dij kstra 1976年提出了一个既简单又具有很强功能的选择结构,它的一般形式为
if B1→S1 □B2→S2 □B3→S3 …… □BN→SN fi
其中,Bi(1≤i≤N)是布尔表达式,称为卫哨(Guard);Si(1≤i≤N)是语句表;Bi→Si(1≤i≤N)称为卫哨命令(Guarded Command)。if语句的语义是,若有多个卫哨计算为真,执行这些卫哨对应的任一Si(显然,选择是非确定的);若所有的Bi计算都不为真,程序失败。因此,强制要求程序列出所有可能的选择。
Dij kstra提出的结构非常著名,其最有趣的特性是对非确定化(Nondeterminism)的抽象,它为并行计算和实时处理提供了良好的结构。在某些实际选择集合互不相交的情况下,程序员可以不必详细说明每一种情况。例如计算A和B的最大值,可写成
if A≤B→MAX:=B □A≥B→MAX:=A fi
这里没有必要把A=B的选择列出来。
在实时处理中,应用这种结构非常方便。例如,在某个控制系统中,卫哨作为控制条件,在某一时刻有多个卫哨同时计算为真,就可能同时去执行几个任务,也可以根据响应时间的要求,去执行最紧迫的任务。
3.2.3 重复结构
在程序设计中,一个上亿次计算的任务,不可能写出上亿条的指令让计算机执行,唯一可行的方法是使许多指令重复执行。因此,许多语言都提供了这样的控制结构,它允许程序员规定在某些语句(指令)上循环。
FORTRAN语言提供DO语句,它引入了一个计数器(循环控制变量)来控制重复次数,这个次数是固定的,循环控制变量的值在整数有限集上,例如
DO 7 I=1,10 A(I)=0 B(I)=0 7 CONTINUE
实现对数组A和B的元素(下标从1到10)置0。这是一种“计数器制导”(Counter-driven)的重复控制结构,也是非常有用的结构。COBOL,ALGOL 60,C和PL/1等大多数语言都采用这种控制结构。Pascal语言允许计数器制导循环,但它的计数变量的值可在任何有序集上。例如
type day=(sun,mon,tus,wed,thu,fri,sat); var week-day:day …… for week-day=mon to fri do… Pascal还允许计数器递减,可写成 for week-day=fri down to mon do…
Pascal还规定控制变量和它的上下界在循环体内不改变,在循环外循环控制变量的值无定义。这种限制并非是为了约束程序员,主要是规定循环计数器的作用域,以增强程序的可读性和可维护性。
在循环计数器值的有限集合上重复,可用来模式化预先知道重复次数的情况,例如对数组进行处理。另一方面,客观世界存在更一般的情况,即预先并不知道重复次数。例如,处理一个文件的所有记录的程序,文件有多少个记录一般是不知道的。为此,在FORTRAN之后出现的大多数语言都提供了“条件制导”(Condition-driven)的重复结构。在这种结构中,设置了一个新限制,这种限制通常是一个布尔表达式,一直重复到布尔表达式的值被改变。
Pascal语言支持两种形式的条件制导循环结构。第一种是while-do循环,它描述任意多次的重复,包括0次;第二种是repeat-until循环,它描述至少一次以上的重复。例如,文件处理程序可用Pascal语言写成
while not eof(f)do begin “从文件f中读一个项”; “处理这个项” end
在执行循环体之前,先要计算eof(它是文件尾标,这里主要是查文件尾标),只要eof(f)不是文件尾标,not eof(f)总为真,执行循环体,直到文件尾标查到,这时not eof(f)为假,停止循环。因此,这个程序对空文件也是正确的,这时一次都不执行循环体。
repeat-until循环除了在循环体出口测试条件外,其他类似于while-do循环。若在上例中,假设文件至少包括一个元素(项),那么可以写成
repeat “从文件f中读一个项”; “处理这个项” until eof(f)
其中,循环次数等于测试条件eof(f)为假的次数。但对空文件这个程序是错误的。
PL/1和ALGOL 68语言把Pascal的计数器制导和条件制导结合后合成一种形式,它是各种可选择成分的组合。ALGOL 68循环的一般形式可写成
for i from j by k to m while b do…od
其中,do和od是封闭循环体的一对符号;for,from,by,to和while等子句是所有可能的选择。若for子句省略,就不存在循环控制变量;若from子句省略,就隐式给定循环控制变量的初值为1;若by子句省略,计数器每次重复之后的增量为1。例如,执行循环体10次的语句可写成
to 10 do a:=a+2 od
该语句使a的值增加20。
省略for,from,by和to等子句后就是一个条件制导循环;若再省略while子句,则确定一个无终止的循环。此时,必须在循环体内显式给出离开循环的出口,循环才会终止。
ALGOL 68的循环控制变量在循环体内不能修改,它的作用域定义为循环体。因此,在循环体外不能访问它。跟在by和to后面的表达式的值在循环体内可以改变,在循环范围内这些表达式仅计算一次,即在开始执行循环之前计算。因此在循环体内对它的操作无论怎样改变,都不影响循环终止条件。
Ada语言仅有一种循环结构,其形式如下:
loop <循环体(语句序列)> end loop;
其中,在第一行的loop前面可以加重复说明,重复说明可以是
while<条件>
或
for<计数变量>in<离散范围>
还可以是
for<计数变量>in reverse<离散范围>
另外,可使用无条件出口语句
exit;
终止循环,也可使用条件出口语句
exit when<条件>;
终止循环。若具有出口语句的循环嵌套在其他循环内,它终止内层循环和包围它的所有外层循环。例如
loop …… loop …… exit MAIN-LOOP while A=0; …… end loop; …… end loop MAIN-LOOP;
在内层中,当A=0时,控制转移到跟在end loop MAIN-LOOP之后的语句执行。出口语句用来控制提前终止循环,它与PL/1的LEAVE语句具有相同的效果。
其他一些语言也提供了终止当前循环的机制,例如C语言的continue(继续)语句。虽然这类结构不常用,但它在程序设计中有特殊功能。例如,在处理一个记录序列时,当发现某个记录有错,就需要结束处理这个记录,跳过去处理下一个记录。
在Ada语言中,使用Dij kstra的卫哨命令表示法,循环可用括号do和od内的各种命令来说明。例如
do B1→S1 □ B2→S2 …… □ BN→SN od
的语义是每重复一次,执行Bi(1≤i≤N)为真的语句Si;若两个或两个以上的Bi为真,选择是非确定的;若都不为真,循环终止。基于卫哨命令的选择和重复促进了良好结构和优美算法的生成,使用这种结构的程序经得住程序正确性的形式证明。
3.2.4 语句级控制结构分析
顺序、选择和重复可以帮助程序员组织语句的控制流程,是基本控制工具。顺序是按计算机程序计数器提供的顺序获得指令的一种抽象。选择和重复是对硬件显式修改程序计数器的值,实现无条件转移和条件转移的抽象,这样的控制既简单又有效。
显然,抽象控制结构比显式控制转移修改指令计数器的低级控制机制更好些,它更面向问题,程序员通过使用顺序、选择和重复的一般模式就能较好地表达他们的意图。
无论如何,高级语言结构最终还是要翻译成传统计算机的条件转移和无条件转移机器代码。但这已与程序员无关,将由编译器生成有效的机器代码,因而采用高度抽象概念有利于程序设计。例如C语言中条件语句
if B S1 else S2
对应的控制可以表示为(伪代码形式)
if B goto B.true goto B.false B.true:/* S1 的代码*/ … goto S.next B.false:/* S2 的代码*/ … S.next:
大多数程序设计语言有goto语句,这是任意修改程序计数器值的抽象。Dij kstra于1968年发表了一篇著名论文,论述了goto语句对程序设计的影响。文章中指出,包含许多goto语句的程序隐含许多出错的机会。他提出废弃goto语句,因为它使得程序正确性证明变得很困难,使用它是危险的。goto语句还破坏了程序各语句的顺序连续性,违反了常规习惯,因此应当拒绝这种结构。
图3-1说明,过多地使用显式转移goto语句,会使程序晦涩难读,容易出错,致使程序开发要付出难以容忍的代价。
图3-1 带显式转移的程序结构
但是,若不允许使用goto语句,而语言又缺乏专门的控制结构,编写的程序也会不符合自然语言习惯而难于理解。另外,至今我们仍不清楚究竟应该提供哪些专门的控制结构,才能更好地满足程序设计的所有需要。
虽然各种语言建立了多种多样的控制结构,但至今没有一种语言的控制结构是令人满意的。Böhm和Jacopini于1966年在理论上证明,使用顺序、选择(if then else)和重复(do while)就可以对计算机所有可能的算法进行编码。因此,这3种结构组成了控制结构的有效集合。然而,仅使用这3种控制结构写出的程序不太自然,更可能产生拙劣的程序。实际上,增加一些控制结构(例如,增加多重选择case,计算器制导循环for和条件制导循环repeat-until等),虽然在理论上是多余的,但这样的确能提高程序的可写性和可读性。
大多数语言都提供了丰富的控制结构,通常也提供了不受限制的goto语句。究竟怎样的选择才是最佳的语言设计,对这个问题仍然有争议。若程序是以条理清晰的方法设计的,那么在这个程序中只有偶然出现的goto语句。然而,一旦允许使用goto语句,就无法强制程序员不使用(或少使用)它,没有经验的程序员就可能编写出结构糟糕的程序。那么,我们只能劝告程序员,有节制地使用goto语句!
Java吸取了Diikstra的建议,废弃了goto语句,因此它不支持goto语句,使程序逻辑更加清晰。但为了实现控制转移,特别设计了break语句和continue语句,从而实现“有节制”地使用goto语句。
综上所述,在语言设计中,影响语言选择控制结构的因素很多,要在一个语言中完全集各家之长是非常困难的。例如,为了易于学习,对于我们所涉及的语言,应当选择什么样的最小控制结构集合才是合理的?这个问题没有确切的答案,只能针对语言的应用领域做出合理的选择。
3.2.5 用户定义控制结构
与数据类型一样,有的语言也提供了用户定义控制结构的机制。
Pascal语言基于计数器循环的控制变量类型可以是任意有序类型,它不只是整数的子集。这样,用户通过定义不同的有序类型即可达到定义不同控制结构的目的。而CLU,Alphard和Euclid等语言提供了进一步的抽象,它们允许程序员拥有任意抽象数据类型的控制变量,并提供说明如何产生这样的控制变量值的顺序构造。Smalltalk语言的方法更为一般,它以不变的对象世界来处理控制结构。
可以认为上述语言是可扩充(Extensible)的,用户可以通过定义新的(抽象)数据类型、操作(过程)和控制结构来扩充基本语言。