如果你觉得本书还不错,并认同本书中的某些观点,那么我向你推荐《C 陷阱与缺陷》[2]。本书与《C 陷阱与缺陷》在内容上并没有多少相同之处,但正是通过阅读《C 陷阱与缺陷》,我才对C 语言的各种问题和现象产生了更深入的思考。《C 陷阱与缺陷》给出了控制结构中常见的三种错误,分别是:“注意作为语句结束标志的分号”、“switch 语句”和“悬挂else 引发的问题”。
关于语句结束标志的分号,我们最容易用错的一个地方就是循环语句,尤其要注意不能在for 循环语句的后面放分号,如程序6-1 第2 行。我原本想输出从0 开始的100 个数,由于有了一个分号,整个这个循环相当于在反复执行100 次空语句,然后执行一次printf,只打印出100 这一个数。
程序6-1 循环体与空语句
int i = 0;
for(;i<100;i++); /等价于for(;i<100;i++){;}
/
{
printf("%d\n",i);
}
while(i<100){
printf("%d\n",i);
}
do{
printf("%d\n",i);
}while(i<100);
造成这个错误的原因在于C 语言中可以定义一个空语句,这个空语句不用写任何内容,只要有一个分号就可以了。如果for 语句后面有一个分号,那么这个分号就被当成一个空语句,空语句本身和for 语句形成了一个循环体。
有的时候我们也有意应用这个循环后面的分号,还记得5.5.4 节中我们用来清空输入缓冲区的while 循环后面那个不太合群的分号吗?那个分号是必须的,否则while 循环就会和后面的一个语句共同构成循环体。当你有意在while 或for 循环后面放一个分号的时候,一个好的方法就是把分号放到下一行一个比较突出的位置,这样有利于“显式”地说明你的意思,避免别人和自己对此产生误判。
while 循环有两种不同的类型,一种是只有一个while 语句的循环,另外一个是do… while 类型的循环,这种循环后面必须要加上一个分号。如程序6-1 中所示,至于到底哪个while 需要加分号,就非常容易让人搞混了。
我可以告诉你一个比较偏门的做法,如果你记不住哪个加分号,哪个不加,那就统统不加分号。当遇到do…while 类型的循环时,如果没有while 后面的分号,编译器会向你咆哮的。“error C2146,语法错误: 缺少‘;’(在标识符“…”的前面)”。提示你忽略了while 后面的分号。这样被编译器虐待几次,你也就记住了。人就是这样,从错误中学习的效果是最好的。编译器向你吼一次,强过我说一万次!
switch 语句最常见的一个错误就是忘记break 语句,这没什么好说的,每一个case 加一个break 就行了。或者来个干脆的,根本就不用switch 语句,凡是能用switch 语句的地方,都可以用if..else 分支来替换。这个是最保险的做法,虽然效率不是很高。
对于“悬挂else 引发的问题”,你只需记住一点,那就是else 与同一语句块内最近的未匹配的if 结合。正确描述if..else 分支的应该是这样一句话:“世界上最远的距离不是天涯海角,而是你在if 中,而我在else 里。”
虽然我不太愿意承认,但这三种错误,我真的都犯过。在我指导学生实验的过程中,这些错误也屡见不鲜。以上错误的具体细节在《C 陷阱与缺陷》中已经描述得很清楚了,出于对原著版权的尊重,这里我就不展开说了,感兴趣的同学可以参考《C 陷阱与缺陷》这本书。
简单来说,语句块就是一对大括号括起来的一段语句。你可以写出程序6-2,但是这并没有什么实际意义,只是演示了变量的作用域。变量有两种作用域,如果变量声明在一个语句块内,那这个变量就是语句块域内有效;如果变量声明在所有语句块外,那这个变量就是文件域内有效。
程序6-2中的两个变量a 存在于不同的语句块中,由于每个变量只在自己的语句块中有效,所以并不会引起冲突。但是这种给不同变量起相同名字的编程风格无论如何也应该避免,无论你多喜欢a 这个名字。
程序6-2 语句块的一个实例
int a = 0;
{
int a = 1;
printf("Inside: a = %d\n", a);
}
printf("Outside: a = %d\n", a);
一般情况下很少像程序6-2 中那样单独使用语句块,基本上都是用来界定一个函数的范围,或者与选择语句if 和循环语句for、while 配合使用,以界定它们的管辖范围。通常,在选择结构和循环结构中,我强烈推荐使用语句块,哪怕这个语句块中只有一个语句。这样做不仅可以使结构清晰,而且可以避免很多潜在的问题,例如6.1 节提到过的“悬挂else 引发的问题”。
for 循环的语法如程序6-3 所示。与其他的循环语句相比,for 循环把操作循环的表达式放到了一起,便于查看,尤其是循环体比较大的时候,这个优点更为明显。
程序6-3 循环基本语法
for(expr1 ; expr2 ; expr3)
statement
关于 for 循环的说明,图6-1 描述得非常到位。
图6-1 for 循环说明
图6-1 来源于《C 和指针》[14],这里我引用一下。一图胜千言,这话说得真是没错。图6-1 不仅说明了for 循环的执行过程,同时也说明了break 和continue 之间的不同,正所谓一箭双雕!
图6-1 中的statement 一般是一对大括号括起的语句块,当然也可以是一个单独的语句,这个时候单独的语句就是循环体。如果你想要一个空的循环体,就在下一行的醒目位置写一个分号,如程序6-4 所示。
程序6-4 三种不同的循环体
for(expr1 ; expr2 ; expr3){
…. / 语句块循环体 /
}
for(expr1 ; expr2 ; expr3)
printf("statement\n") / 单语句循环体 /
for(expr1 ; expr2 ; expr3)
; / 空循环体 /
有一种情况比较特殊,如程序6-5 所示。
程序6-5 隐含循环体
1 for(expr1 ; expr2 ; expr3)
2 if(expr4)
3 while(expr5)
4 printf("statement");
要想分析这种情况的for 的循环体,我们需要从内向外分析。首先while 循环和printf 语句构成一个循环体,这个循环体是一个整体;然后这个循环体在if 判断的逻辑分支中;整个if 判断的逻辑分支和for 循环构成循环体。所以这个for 循环的循环体从第2 行到第4 行。
坦白地说,程序6-5 的风格并不是太好,更好的写法应该是通过一对大括号构造的语句块来清晰地界定循环体的范围。
for 循环的边界条件也值得一说,如果遍历一个10 个元素的数组,我们可以用for(i=0;i<10;i++),也可以用for(i=0;i<=9;i++)。表面上看它们之间没有什么区别,但是第一种写法是一种很好的习惯,从第一种写法中,我们可以马上看出数组中有10 个元素。同时,如果数组为空,我们写出for(i=0;i<0;i++)是没错的,但是for(i=0;i<=0;i++)却有问题。关于边界条件,《C 陷阱和缺陷》中“边界计算与不对称边界”一节介绍得很有深度,强烈推荐大家阅读!
C 语言是一种逐步细化的模块化开发语言,遵循结构化程序设计理念。具体的开发流程如图6-2 所示。
图6-2 模块化开发流程
如何把任务分割成不同的模块、模块的颗粒度等问题,都需要在长期的编程实践中去慢慢体会,如何做到模块本身的高聚合以及模块之间的低耦合,这应该是软件工程和设计模式应该关心的问题,远远超出了C 语言的讨论范围。
结构化程序设计的概念最开始是由荷兰的一位科学家E. W. Dijkstra 提出的。这位伟大的科学家同时还提出了数据结构中计算图最短路径的Dijkstra 算法,还有操作系统中的银行家算法。这些内容都是计算机核心课程中的核心内容,学习和理解起来都不太容易。Dijkstra 于2002 年逝世,用一句名人名言来形容他就是:“有的人活着,可他已经死了;有的人死了,可他就是不让学生们好好活!”
笑谈过后,也有一丝失望。钱学森之问,“中国大学为什么没有培养出大师?”这也是对我们大学中每一位老师的批评。真心希望以后课堂上有更多的以中国人命名的算法和原理,这样我们中国人也不让外国的学生好好活!
在结构化程序设计的基础上,Dijkstra 还提出了“goto 有害论”,认为goto 语句违反了结构化程序设计中“单入口,单出口”这一根本性原则。事实也确实如此,goto 可以不受限制地转向任何地方,使程序随意转向,导致流程混乱不堪。对这种程序有一种比较形象的描述,那就是“面条”程序。当你阅读这种程序的时候,就像把一碗面条里的每一根面条都分离出来一样难。所以一个好的习惯就是从来不用goto 语句,不要尝试使用它,哪怕一次,也会带来图6-3 所示的效果。图中第一幅画中程序员说他要用一个goto 语句,第二幅画中他说用一个goto 语句不会坏到哪去,第三幅画中他真的输入了goto 语句,最后一幅画就是最后的结果了。
图6-3 不要尝试用goto 语句
有些支持者会举出反例,例如,要跳出嵌套的循环,使用goto 语句比较方便。但是通过使用一个变量flag 也能完成同样的任务,如程序6-6 所示。
程序6-6 不用goto 语句跳出嵌套循环
int flag = 1;
for(…; …&&flag; …){
for(…; …&&flag; …){
if(1==end) flag = 0;
}
}
另外一个 goto 语句比较常用的场合就是错误处理,在程序的不同地方,用goto 语句转向统一语句标号处,进行相同的错误处理。这种情况下,我们也可以利用函数完成同样的功能,如程序6-7 所示。
程序6-7 不用goto 语句进行错误处理
error1(){
…….
};
if(condition1) error1();
…..
if(condition2) error1();
总之一句话,凡是能使用goto 语句的地方,都可以利用其他的语句来代替,所以我最后的结论就是彻底忘掉goto 语句吧!既然要学生们忘掉goto 语句,为什么还要说这么多话?一点不说不是更好吗?非也,非也,真正的遗忘不是不认识它,而是放下它!
最后我们还要避免另外一种观点。有些人就算用goto,代码依然很清晰;有些人就算不用goto,代码依然是一团乱麻。goto 语句只是一个诱因,并不是原罪。“面条”程序真正的罪魁祸首是你,而不是goto 语句。如果你自己一脑袋面糊,生产出“面条”是很正常的事情。
虽然现在计算机运行速度很快,但是有的程序依然需要很长的时间,尤其是一些寻优算法。程序6-8 模拟出这样一个较为费时的“寻优算法”。
程序6-8 给出程序提示
for (i = 0;i<10000;i++){
if((i%10)==0){
printf("programme has finished %d",i);
fflush(stdout);
}
for(j = 0;j<10000;j++){
for(k = 0;k<10000;k++){
sum=i+j+k;
}
}
}
如果运行了程序,很久以后发现界面上仍然什么也不显示,任何一个自信的程序员也会有点心虚,程序还在正常运行吗?还是它已经进入了某个循环中跳不出来了?
这个时候你可以摘花瓣并默念着“它在运行”,“它不在运行”,“它在运行”……。不过更好的办法是,能够在一定的时间内,给出程序运行的进度,如果很难判断出进度情况,至少给出还在运行的提示。一个常用的控制提示时间间隔的的办法就是采用模运算,如程序6-8 中第2~5 行所示。
本章的内容不多,C 语言控制结构本身不是什么难点。不过,有些人还是会经常在这上面犯错误。所以我还是推荐你阅读《C 陷阱与缺陷》相关内容,把本章6.1 节中介绍的三种常见错误了解清楚,并养成良好的风格习惯,以便在实际的编程中避免这些错误。
我希望你能明白什么是语句块,并能明白语句块的作用域。这对理解很多C 语言的相关问题都有帮助。例如,你应该知道用语句块清晰地定义出一个循环的循环体,或者一个判断的判断分支。
本章还介绍了goto 语句,以前goto 语句经常被用到错误处理等场合,但是在本书的第13章中,我们会介绍目前主流面向对象语言都支持的异常处理机制。这种机制完全可以非常优雅地替代goto 语句在错误处理中的作用。所以结论就是,goto 语句应该从历史上谢幕了。
本章并没有讨论break 和continue 的区别,也没有讨论多路分支的if else if 语句。我在前言中也说过,本书并不是教材,不想把篇幅浪费在教材已经有的内容上。这些有关控制结构的基础知识,本身一点都不难。你一定能通过阅读相关教材了解并掌握的。