2016.07.29 – 08.08
个人英文阅读练习笔记(极低水准)。
第三章:控制流
07.29
语言的控制流语句指定程序在电脑中的执行顺序。在之前的例子中我们已经见过最常见的控制流结构;这里,我们将完成对这些内容的详细介绍。
1 语句和模块
一个表达式,如x = 0,i++或printf,在它们后面加一个分号就变成了语句,如下这样
x = 0;
i++;
printf(…);
在C中,分号是一个语句的终止符号,而不是像Pascal语言中那样是一个分隔符。括号 { 和 } 用来将声明和语句组织在一起形成符合语句,或称为模块,在语法上等同于单个语句。函数的括号就是一个明显的例子;在if,else,while或for语句后面的括号是另外一个例子。(变量可以声明在任何模块内;我们将会在第4章讨论这个话题)在模块的终止符即右大括号的末尾没有分号。
2 if-else
if-else用来表达判断。通常,语法为
if (expression)
statement1
else
statement2
else部分可选的。expression会被求值;如果为真(也就是说,如果expression的值非0),statement1就会被执行。如果为假(expression为0)且有else部分,statement2会被执行。
因为if会简单的测试表达式的值,短的编码方式是可能的。最明显的编写方式为
if (expression)
而可以不写成
if (expression != 0)
有时候,这种形式(第一种)会更加自然和清晰;但有时候也会变得神秘。
因为if-else中的else部分是可选的,当在嵌套的if序列中省略else时会造成歧义。else与前面最近的其它的if匹配。例如
if (n > 0)
if (a > b)
z = a;
else
z = b;
else与内部的if匹配,跟我们欲想的效果一样。如果这并非是你所想,必须要使用括号来联系特定的if和else:
if (n > 0) {
if (a > b)
z = a;
else
z = b;
}
else
z = b;
像以下模糊情形尤其有害:
if (n >= 0)
for (i = 0; i < n; i++)
if (s[i] > 0) {
printf(“…”);
return i;
}
else /* wrong */
printf(“error – n is negative\n”);
缩进能够显示意图的明确性,但是编译器获取不到这种缩进信息,会将else跟内部的if关联起来。这种bug很难被查找;当有许多if嵌套时用大括号匹配if-else对是一个不错的主意。
随便提一下,主意在以下语句中的z = a的后面有一个分号
if (a > b)
z = a;
else
z = b;
因为这是语法,在if后面的语句,像“z = a;”这样的表达式语句常以一个分号结束。
3 else-if
07.30
结构
if (expression)
statement
else if (expression)
statement
else if (expression)
statement
else if (expression)
statement
else
statement
常会在程序中出现,故而将其单独列出来简单地讲解以下。if序列是用来编写多选择分支最常用的方式。expression会被依次求值;一旦有expression为真,该表达式后语句就会被执行,然后这段程序就结束了。一如往常,每个statement可以是单个语句也可以是在大括号内的符合语句。
最后一个else部分处理之前所有的expression都不为真的情况。有时候,对于这种情况并没有实际的操作;在这种情况下末尾的
else
statement
就可以省略,或用它来捕捉不可能发生某条件的错误。
这里有一个判断x是否在数组v中的二分查找函数,来演示三分支的使用。v中的元素必须按照增序排列。该函数返回x在v中的位置([0, n - 1]),如果x不在v中则返回-1。
二分查找函数首先将x与v中的中间元素作比较。如果x小于中间元素,将在v中前一半元素中查找x,反之在后一半元素中查找x。不管是哪一种情况,x接下来都会跟所选择的元素序列的中间元素比较。反复进行这样的操作直到找到元素或将序列分为空时。
/* 二分查找: 在v[0] <= v[1] <= … <= v[n – 1]的序列中查找x */
int binsearch(int x, int v[], int n)
{
int low, high, mid;
low = 0;
high = n - 1;
while (low <= high) {
mid = (low + high) / 2;
if (x < v[mid])
high = mid - 1;
else if (x > v[mid])
low = mid + 1;
else /* 找到 */
return mid;
}
return -1; /* 无x */
}
最基本的判断是,x在每步中是否小于、大于或等于序列的中间元素v[mid];这中描述对else-if来说很自然。
练习 3-1。以上的二分查找在循环内做了两次测试,其实使用一次就足够了(代价是在循环外部多做一次测试)。编写一个只在循环中测试一次的二分查找版本并测量二者的运行时间差异。
4 switch
07.31
switch语句是测试表达式是否和多个常量中的某个常整数值匹配的多路判断,并在匹配时产生分支。
switch (expression) {
case const-expr: statements
case const-expr: statements
default: statements
}
每个case由一个或多个整数常量或常量表达式对应。如果case匹配了switch内表达式的值,那么代码就从该case处开始执行。所有case后的const-expr值必须不同。若没有case匹配switch中的expression,default内的statements会被执行。default是可选的;如果省略了default语句且没有匹配的case,就不会有任何操作会被执行。case和default项的顺序可以是任意的。
在第一章中,我们编写了一个统计数字、空白符以及其它字符出现的次数的程序,使用的是if… else if… else序列。以下是一个用switch语句实现的版本:
#include <stdio.h>
main() /* 统计数字、空白符以及其它字符 */
{
int c, I, nwhite, nother, ndigit[10];
nwhite = nother = 0;
for (i = 0; i < 10; i++)
ndigit[i] = 0;
while ((c = getchar()) != EOF) {
switch (c) {
case ‘0’: case ’1’: case ‘2’: case ‘3’: case ‘4’:
case ‘5’: case ‘6’: case ‘7’: case ‘8’: case ;9’:
ndigit[c – ‘0’]++;
break;
case ‘ ‘:
case ‘\n’:
case ‘\t’:
nwhite++;
break;
default:
nother++;
break;
}
}
printf(“digits =”);
for (i = 0; i < 10; i++)
printf(“, white space = %d, other = %d\n”,
nwhite, nother);
return 0;
}
break语句若被执行,则程序会立即退出switch。因为case只是被当作标签使用,在某个case后面的代码被执行后,除非有确切的语句退出,否则程序还继续执行下去。break和return是用来退出switch的最常用的方法。break语句也可以用来从while、for以及do-while(本章稍后会讨论到这些循环语句)中强制退出。
一旦switch中的表达式和某个case后的常量匹配时,默认执行该case及后续case后的所有语句这种情况亦好亦坏。从积极的一面来讲,它允许几种情况后都跟同一个操作,如本例中判断是否为数字的情况。但同时,这也意味着,如果不想执行下一个case之后的语句的话,就必须在每个case的语句后面加一个break语句。从一个case语句执行到另一个case语句不是程序稳健的一种表现,因为修改这样的程序时容易让程序变得面目全非。除了多个标签(case)对应同一个操作外,应该节制使用case默认的接连执行,若使用了则应加上相应的注释。
作为一种良好的编程形式,在最后一个case(default处)也放置一个break语句,尽管从逻辑上讲并不需要该break语句。若某天在末尾加了一个case语句后,这个break语句将会起作用。
练习 3-2。编写函数escape(s, t),将字符串t拷贝到s中,并将t中诸如换行、制表符用相应的可见的转义字符形式\n和\t代替。使用switch。再编写一个函数来做相反的操作,即将转义字符序列转换成相应的真实的字符。
5 while和for循环
08.01
我们已经见过while和for循环了。在
while (expression)
statement
中,表达式会被求值。如果该值非0,statement就会被执行且expression会被再次求值。在statement执行后会再次对expression求值,如此反复进行下去直到expression变为0。
08.02
for语句
for (expr1; expr2; expr3)
statement
等价于
expr1;
while (expr2) {
statement
expr3;
}
以上等价要除去循环中包含continue的情况,该语句在3.7节被讲解。
从语法上讲,for循环中有三部分都是表达式。最常见的情况是,expr1和expr3都是赋值或者函数调用,expr2是一个关系表达式。这三部分的任何一部分可以被省略,但分号必须得保留。如果expr1或expr3被省略了,它相当于while语句。如果测试expr2不存在,for循环的条件就相当于永远真了,所以
for (; ;) {
…
}
是一个无限循环,这种情况可以用其它的方式来打断无限循环,如break或return语句。
用while还是for取决于个人偏好。例如,在
while ((c = getchar()) == ' ' || c == '\n' || c == '\t')
; /* 跳过空白字符 */
这里没有初始化或重置的操作,所以使用while更为自然。
当有简单的初始化和增值时,使用for更为合适,因为这样能够在循环顶部让for控制语句集中亦可见。此点在以下语句中最显见
for (i = 0; i < n; i++)
…
这种for语句在处理数组的前n个元素时很常见,跟Fortran的do循环或Pascal的for原语相似。然而,原语并不完美,因为索引和for循环的限制(上下限)可以在循环内部修改,但当循环因某种原因退出时索引变量i会保存着其值。因为在for的三部分中,可以是任意的表达式,for循环不是严格的算术级数。然而,对for的初始化做强制的不相关计算和增值是一种坏的编程习惯,最好不要对for做这样的操作。
这里有将字符串转换为字符串等效的数值的另外的一个版本的atoi函数。这个atoi比第二章中的更为通用一些;它能够处理开头的空白符且还能够处理 + 或 - 符号。(第四章展示atof,该函数将字符串转换为相应的浮点数)
对应于一定格式的输入,该程序结构如下:
如果有空白符,则跳过
如果有符号,则分析符号
获取整数字符部分并将其转换
每一步都是该函数的一部分,也是下一步的一个清晰的状态。当出现一个不是数字的字符时整个程序终止。
#include <ctype.h>
/* atoi:转换s为整数;版本2 */
int atoi(char s[])
{
int i, n, sign;
for (i = 0; isspace(s[i]); i++) /* 跳过空白符 */
;
sign = (s[i] == ‘-‘) ? -1 : 1;
if (s[i] == ‘+’ || s[i] == ‘-‘) /* 跳过符号 */
i++;
for (n = 0; isdigit(s[i]); i++)
n = 10 * n + (s[i] – ‘0’);
return sign *n;
}
标准库提供了更为精巧的函数strlol来将字符串转换为长整型;见附录B的第五节。
当有几层嵌套循环时,将循环控制集中的好处将会更明显。这段后面有一个函数Shell排序,该函数用来排序整型数组中的元素。该排序算法最基本的思想是由D. L. Shell在1959年发明的,在最开始的阶段时,相距较远的元素相比较,而不是像简单的交换排序那样比较相邻的元素。这将会快速消除大量无序的情况,所以后续阶段将会有更少的工作需要做。相比较的元素之间相隔的元素逐渐减到只有一个元素,直到最后阶段才会出现相邻的元素相比较。
/* shellsort:按照递增关系对v[0]…v[n-1]排序 */
void shellsort(int v[], int n)
{
int gap, i, j, temp;
for (gap = n / 2; gap > 0; gap /= 2)
for (i = gap; i < n; i++)
for (j = i - gap; j >= 0 && v[j] && v[j] > v[j + gap]; j -= gap) {
temp = v[j];
v[j] = v[j + gap];
v[j + gap] = temp;
}
}
08.04
程序中有三层嵌套的循环。最外层循环控制比较元素之间的间距,并每次以2为因子从n / 2缩短该间距直到该间距为0。中间一层循环首次从间隔gap个元素起索引后续每一个元素。最里的一层循环以间隔gap比较两个元素并交换不满足排序要求的两个元素。因为gap最终会被缩减到1,所有的元素都会被渐渐的排好序。注意for循环的通用性是怎么让最外层循环和其余的循环保持相同的格式的,即使不是等差级数。
C还剩余的最后一个运算符是逗号“,”运算符,它在for语句中能够发挥作用。由逗号分开的表达式的求值顺序是从左到右。所以,在for语句中,是可以将多个表达式放在for的每个模块中的,如同时处理两个指标。这在reverse(s)函数中有体现,该函数在空间上逆转字符串s。
#include <string.h>
/* reverse: 在空间上逆转字符串s */
void reverse(char s[])
{
int c, i, j;
for (i = 0, j = strlen(s) – 1; i < j; i++, j--) {
c = s[i];
s[i] = s[j];
s[j] = c;
}
}
用于分离函数参数、变量声明等的逗号,都不是逗号运算符,且并不保证从左到右的求值顺序。
应该节制逗号运算符的使用。它最常用于for结构某模块中强烈相关联的式子,如在reverse的for循环中,以及在宏中分多步来计算单个表达式时。逗号表达式也可以用于reverse中元素的交换(该交换就成为了一个单步操作):
for (i = 0, j = strlen(s) – 1; i < j; i++, j--)
c = s[i], s[i] = s[j], s[j] = c;
练习 3-3。编写函数expand(s1, s2),该函数展开在s1中的简写字符串如a-z到s2中即abc…xyz。字符可以是字母或数字,且还能够处理像a-b-c、a-z0-9以及-a-z的情形。从字面上理解开头或末尾的-。
6 do-while循环
正如第一章中的讨论,while和for循环在顶部测试循环终止条件。与之相反的是C中第三个循环,即do-while,将在执行完循环体后再在底部做循环终止条件的测试;循环体至少会被执行一次。
do循环的用法如下
do
statement
while (expression);
statement首先会被执行,然后expression会被求值。如果该值为真,statement会被再次执行,如此反复。当expression变为假时,整个循环终止。除了测试部分,do-while相当于Pascal的repeat-until语句。
根据经验,do-while要比while和for用得少得多。然而,它是有价值的,如在将数字转换为相应字符串的函数itoa中。该过程可能要比初想要稍显复杂一些,因为用简单的方法产生数字的方法来产生该数字的方式是错误的顺序。我们将先产生逆序的字符串,然后再逆转它。
/* itoa:将n转换为字符串并保存在s中 */
void itoa(int n, char s[])
{
int i, sign;
if ((sign = n) < 0) /* 记录符号 */
n = -n;
i = 0;
do { /* 以逆序产生数字 */
s[i++] = n % 10 + ‘0’; /* 得到下一个数字 */
while ((n /= 10) > 0); /* 删除it */
if (sign < 0)
s[i++] = ‘-‘;
s[i] = ‘\0’;
reverse(s);
}
do-while循环是必要的,至少是方便的,因为至少会有一个字符会被保存到数组s中,即使n为0。即使是单个运距,也可以对do-while的循环体加上大括号,即使非必需,这样读者就不会草率的将do-while的末尾当成一个while循环的开始。
练习 3-4。在用补码表示数的机器上,我们的itoa版本不能处理最大负数,即n为 −(2wordsize−1) 时。解释这个原因,并修改该程序使其能够正确打印最大负数的情况,忽略具体运行的机器。
练习 3-5。编写函数itob(n, s, b),该函数将整数n转换为b进制字符并存储在字符串s中。如,itob(n, s, 16)将n以16进制形式转换为字符串并存在s中。
练习 3-6。编写另一个版本的itoa,使其接收三个参数。第三个是最小字段宽度;若有必要让其跟设定宽度(第三字段)一样的宽度则应该在转换的字符串的左边补空白字符。
7 break 和continent
为了方便有时从循环中退出而不再测试循环顶部或底部的条件,break语句提供了提前从for、while以及do-while中退出的机制,就像退出switch那样。break将导致从最内层的循环或switch中退出。
以下函数trim,功能为移除字符串末尾的空格、制表符以及换行符,当最右端为非空格、非制表符以及换行符时,该函数使用break语句退出。
/* trim:移除末尾的空格、制表符、换行符 */
int trim(char s[])
{
int n;
for (n = strlen(s) – 1; n >= 0; n--)
if (s[n] != ‘ ‘ && s[n] != ‘\t’ && s[n] != ‘\n’)
break;
s[n + 1] = ‘\0’;
return n;
}
strlen返回字符串的长度。for循环从字符串的末尾开始并逆序浏览字符串以查找第一个不为空格、制表符或换行符的字符。当找到了这样的一个字符或n变为负数(也就是说,整个字符串都被扫描完了)时就退出循环。应该保证即使字符串为空或者只包含空白符时该函数也能够正确执行。
continue是跟break相关的语句,不过更少被用到;它将引起最近一层for、while以及do循环的下一次开始。在while和do中,就意味着条件测试部分会被立即执行;在for中,控制将会转到增值操作部分。continue语句只适用于循环,而不适用于switch**。若一个switch语句包含在循环中,而switch中包含一个continue语句,那么continue语句将会引起下一次循环迭代。
举例,以下代码片段只会处理数组a中的非负数元素;负数会被跳过。
for (i = 0; i < n; i++) {
if (a[i] < 0) /* skip negative elements */
continue;
… /* 处理正数 */
}
continue在循环部分比较复杂时会被经常用到,这样就不会让其它内容在循环中被嵌套得过深。
8 goto和标签
08.08
C提供了可无限滥用的goto语句以及goto对应着可分支的标签。从正规上讲,goto语句根本没有存在的必要性,且在大多数的实际中尽管没有goto语句也能够很容易用其它代码实现功能。在本书中就没有使用过goto语句。
然而在某些情况下,goto也有它的一席之地。最常见的情形是抛弃一个深度嵌套结构的执行,如从两个或多个循环中跳出来。像这样的情况就不能直接使用break语句,因为它只能跳出最内层循环。因此:
for (…)
for (…) {
…
if (disaster)
goto error;
}
}
…
error:
celan up the mess
如果错误处理代码具有整体性且在多个地方都有可能发生这个错误,那么该结构就显得很方便。
标签跟变量名是一样的格式,且其后跟一个冒号。它可以附在跟goto相同函数中的任何语句后。标签的使用范围仅限于标签所在的函数。
再举一例,试判断数组a和数组b中是否共有一个元素。一种可能的写法是:
for (i = 0; i < n; i++)
for (j = 0; j < m; j++)
if (a[i] == b[j])
goto found;
/* 没有找到任何共有的元素 */
…
found:
/* 有a[i] == b[j]的情况 */
…
凡是包含goto语句的程序都可以用其它方式来代替goto语句的功能,尽管这样或许会带来一些重复的测试或额外的变量。例如,数组搜索可变为:
found = 0;
for (i = 0; i < n && !found; i++)
for (j = 0; j < m && !found; j++)
if (a[i] == b[j]))
found = 1;
if (found)
/* 有a[i – 1] == b[j – 1] */
…
else
/* 没有找到任何共有的元素 */
…
除了这里所提到的几个例外情形外,使用goto的程序通常要比没有使用goto语句的程序更难以理解和维护。虽然我们并未对该问题执固执意见,就算真的使用goto,也要尽量少的使用。
总结
翻译求快,已低于极低水准之下。[2016.07.31]
[2016.09.01 - 00:45]