后浪笔记一零二四

C程序设计语言-第3章控制流

第3章 控制流

程序语言中的控制流语句用于控制各计算操作执行的次序。在前面的例子中,我们曾经使用了一些最常用的控制流结构。本章将更详细地讲述控制流语句。

3.1 语句与程序块

x=0i++printf(...)这样的表达式之后加上一个分号;,它们就变成了语句。例如:

1
2
3
x = 0;
i++;
printf(...);

在C语言中,分号是语句结束符,而Pascal等语言却把分号作为语句之间的分隔符。

用一对花括号{}把一组声明和语句括在一起就构成了一个复合语句(也叫作程序块),复合语句在语法上等价于单条语句。函数体中被花括号括起来的语句便是明显一例。if、else、while与for之后被花括号括住的多条语句也是类似的例子。(在任何程序块中都可以声明变量,第4章将对此进行讨论。)右花括号用于结束程序块,其后不需要分号。

3.2 if-else语句

if-else语句用于条件判定。其语法如下所示:

1
2
3
4
if(表达式)
    语句1
else
    语句2

其中else部分是可选的。该语句执行时,先计算表达式的值,如果其值为真(即表达式的值为非0),则执行语句1;如果其值为假(即表达式的值为0),并且该语句包含else部分,则执行语句2

由于if语句只是简单测试表达式的数值,因此可以对某些代码的编写进行简化。最明显的例子是用如下写法:

1
if(表达式)

来替代

1
if(表达式!=0)

某些情况下这种形式是自然清晰的,但也有些情况下可能会含义不清。

因为if-else语句的else部分是可选的,所以在嵌套的if语句中省略它的else部分将导致歧义。解决的方法是将每个else与最近的前一个没有else配对的if进行匹配。例如,在下列语句中:

1
2
3
4
5
if (n > 0)
    if (a > b)
        z = a;
    else
        z = b;

else部分与内层的if匹配,我们通过程序的缩进结构也可以看出来。如果这不符合我们的意图,则必须使用花括号强制实现正确的匹配关系:

1
2
3
4
5
6
if (n > 0) {
    if (a > b)
        z = a;
}
else
    z = b;

歧义性在下面这种情况下尤为有害:

1
2
3
4
5
6
7
8
if (n >= 0)
    for (i = 0; i < n; i++)
        if (s[i] > 0) {
            printf("...");
            return i;
        }
else      /* 错 */
    printf("error -- n is negative\n");

程序的缩进结构明确地表明了设计意图,但编译器无法获得这一信息,它会将else部分与内层的if配对。这种错误很难发现,因此我们建议在有if语句嵌套的情况下使用花括号。

顺便提醒读者注意,在语句

1
2
3
4
if (a > b)
    z = a;
else
    z = b;

中,z=a后有一个分号。这是因为,从语法上讲,跟在if后面的应该是一条语句,而像z=a;这类的表达式语句总是以分号结束的。

3.3 else-if语句

在C语言中我们会经常用到下列结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
if(表达式)
    语句
else if (表达式)
    语句
else if (表达式)
    语句
else if (表达式)
    语句
else
    语句

因此我们在这里单独说明一下。这种if语句序列是编写多路判定最常用的方法。其中的各表达式将被依次求值,一旦某个表达式结果为真,则执行与之相关的语句,并终止整个语句序列的执行。同样,其中各语句既可以是单条语句,也可以是用花括号括住的复合语句。

最后一个else部分用于处理“上述条件均不成立”的情况或默认情况,也就是当上面各条件都不满足时的情形。有时候并不需要针对默认情况执行显式的操作,这种情况下,可以把该结构末尾的

1
2
else
语句

部分省略掉;该部分也可以用来检查错误,以捕获“不可能”的条件。

这里通过一个折半查找函数说明三路判定程序的用法。该函数用于判定已排序的数组v中是否存在某个特定的值x。数组v的元素必须以升序排列。如果v中包含x,则该函数返回x在v中的位置(介于0~n-1之间的一个整数);否则,该函数返回-1。

在折半查找时,首先将输入值x与数组v的中间元素进行比较。如果x小于中间元素的值,则在该数组的前半部分查找;否则,在该数组的后半部分查找。在这两种情况下,下一步都是将x与所选部分的中间元素进行比较。这个过程一直进行下去,直到找到指定的值或查找范围为空。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/* binsearch函数:在v[0]<=v[1]<=v[2]<=...<=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小于、大于还是等于中间元素v[mid]。使用else-if结构执行这种判定很自然。

练习3-1 在上面有关折半查找的例子中,while循环语句内共执行了两次测试,其实只要一次就足够(代价是将更多的测试在循环外执行)。重写该函数,使得在循环内部只执行一次测试。比较两种版本函数的运行时间。

3.4 switch语句

switch语句是一种多路判定语句,它测试表达式是否与一种常量整数值中的某一个值匹配,并执行相应的分支动作。

1
2
3
4
5
switch(表达式) {
    case 常量表达式: 语句序列
    case 常量表达式: 语句序列
    default: 语句序列
}

每一个分支都由一个或多个整数值常量或常量表达式标记。如果某个分支与表达式的值匹配,则从该分支开始执行。各分支表达式必须互不相同。如果没有哪一分支能匹配表达式,则执行标记为default的分支。default分支是可选的。如果没有default分支也没有其他分支与表达式的值匹配,则该switch语句不执行任何动作。各分支及default分支的排列次序是任意的。

我们在第1章中曾用if…else if…else结构编写过一个程序以统计各个数字、空白符及其他所有字符出现的次数。下面我们用switch语句改写该程序如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#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(" %d", ndigit[i]);
    printf(", white space = %d, other = %d\n",
        nwhite, nother);
    return 0;
}

break语句将导致程序的执行立即从switch语句中退出。在switch语句中,case的作用只是一个标号,因此,某个分支中的代码执行完后,程序将进入下一分支继续执行,除非在程序中显式地跳转。跳出switch语句最常用的方法是使用break语句与return语句。break语句还可强制控制从while、for与do循环语句中立即退出,对于这一点,我们稍后还将做进一步介绍。

依次执行各分支的做法有优点也有缺点。好的一面是它可以把若干个分支组合在一起完成一个任务,如上例中对数字的处理。但是,正常情况下为了防止直接进入下一个分支执行,每个分支后必须以一个break语句结束。从一个分支直接进入下一个分支执行的做法并不健全,这样做在程序修改时很容易出错。除了一个计算需要多个标号的情况外,应尽量减少从一个分支直接进入下一个分支执行这种用法,在不得不使用的情况下应该加上适当的程序注释。

作为一种良好的程序设计风格,在switch语句最后一个分支(即default分支)的后面也加上一个break语句。这样做在逻辑上没有必要,但当我们需要向该switch语句后添加其他分支时,这种防范措施会降低犯错误的可能性。

练习3-2 编写一个函数escape(s,t),将字符串t复制到字符串s中,并在复制过程中将换行符、制表符等不可见字符分别转换为\n\t等相应的可见的转义字符序列。要求使用switch语句。再编写一个具有相反功能的函数,在复制过程中将转义字符序列转换为实际字符。

3.5 while循环与for循环

我们在前面已经使用过while与for循环语句。在while循环语句

1
2
while(表达式)
    语句

中,首先求表达式的值。如果其值为真非0,则执行语句,并再次求该表达式的值。这一循环过程一直进行下去,直到该表达式的值为假(0)为止,随后继续执行语句后面的部分。

for循环语句:

1
2
for(表达式1; 表达式2; 表达式3)
    语句

它等价于下列while语句:

1
2
3
4
5
表达式1;
while(表达式2) {
    语句
    表达式3;
}

但当while或for循环语句中包含continue语句时,上述二者之间就不一定等价了。我们将在3.7节中介绍continue语句。

从语法角度看,for循环语句的3个组成部分都是表达式。最常见的情况是,表达式1表达式3是赋值表达式或函数调用,表达式2是关系表达式。这3个组成部分中的任何部分都可以省略,但分号必须保留。如果在for语句中省略表达式1表达式3,它就退化成了while循环语句。如果省略测试条件,即表达式2,则认为其值永远是真值,因此,下列for循环语句:

1
2
3
for (;;) {
    ...
}

是一个“无限”循环语句,这种语句需要借助其他手段(如break语句或return语句)才能终止执行。

在设计程序时到底选用while循环语句还是for循环语句,主要取决于程序设计人员的个人偏好。例如,在下列语句中:

1
2
while((c = getchar()) == ' ' || c == '\n' || c == '\t')
    ;   /* 跳过空白符 */

因为其中没有初始化或重新初始化的操作,所以使用while循环语句更自然一些。

如果语句中需要执行简单的初始化和变量递增,使用for语句更合适一些,它将循环控制语句集中放在循环的开头,结构更紧凑、更清晰。通过下列语句可以很明显地看出这一点:

1
2
for (i=0; i < n; i++)
    ...

这是C语言处理数组前n个元素的一种习惯性用法,它类似于Fortran语言中的DO循环或Pascal语言中的for循环。但是,这种类比并不完全准确,因为在C语言中,for循环语句的循环变量和上限在循环体内可以修改,并且当循环因某种原因终止后循环变量i的值仍然保留。因为for语句的各组成部分可以是任何表达式,所以for语句并不限于通过算术级数进行循环控制。尽管如此,牵强地把一些无关的计算放到for语句的初始化和变量递增部分是一种不好的程序设计风格,该部分放置循环控制运算更合适。

作为一个较大的例子,我们来重新编写将字符串转换为对应数值的函数atoi。这里编写的函数比第2章中的atoi函数更通用,它可以处理可选的前导空白符以及一个可选的加(+)或减(-)号。(第4章将介绍函数atof,它用于对浮点数执行同样的转换。)

下面是程序的结构,从中可以看出输入的格式:

1
2
3
如果有空白符的话,则跳过
如果有符号的话,则读取符号
取整数部分,并执行转换

其中的每一步都对输入数据进行相应的处理,并为下一步的执行做好准备。当遇到第一个不能转换为数字的字符时,整个处理过程终止。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#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;
}

标准库中提供了一个更完善的函数strtol,它将字符串转换为长整型数。有关函数strtol的详细信息,请参见附录B.5节。

把循环控制部分集中在一起,对于多重嵌套循环,优势更为明显。下面的函数是对整型数组进行排序的Shell排序算法。Shell排序算法是D.L.Shell于1959年发明的,其基本思想是:先比较距离远的元素,而不是像简单交换排序算法那样先比较相邻的元素。这样可以快速减少大量的无序情况,从而减轻后续的工作。被比较的元素之前的距离逐步减少,直到减少为1,这时排序变成了相邻元素的互换。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* 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+gap]; j-=gap) {
                temp = v[j];
                v[j] = v[j+gap];
                v[j+gap] = temp;
            }
}

该函数中包含一个三重嵌套的for循环语句。最外层的for语句控制两个被比较元素之间的距离,从n/2开始,逐步开始对折,直到距离为0。中间层的for循环语句用于在元素间移动位置。最内层的for语句用于比较各对相距gap个位置的元素,当这两个元素逆序时把它们互换过来。由于gap的值最终要递减到1,因此所有元素最终都会位于正确的排序位置上。注意,即使最外层for循环的控制变量不是算术级数,for语句的书写形式仍然没有变,这就说明for语句具有很强的通用性。

逗号运算符“,”也是C语言优先级最低的运算符,在for语句中经常会用到它。被逗号分隔的一对表达式将按照从左到右的顺序进行求值,各表达式右边的操作数的类型和值即为其结果的类型和值。这样,在for循环语句中,可以将多个表达式放在各个语句成分中,比如同时处理两个循环控制变量。我们可以通过下面的函数reverse(s)来举例。该函数用于倒置字符串s中各个字符的位置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#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;
    }
}

某些情况下的逗号并不是逗号运算符,比如分隔函数参数的逗号,分隔声明中变量的逗号等,这些逗号并不保证各表达式按从左至右的顺序求值

应该慎用逗号运算符。逗号运算符最适用于关系紧密的结构中,比如上面的reverse函数内的for语句,对于需要在单个表达式中进行多步计算的宏来说也很合适。逗号运算符还适用于reverse函数中元素的交换,这样,元素的交换过程便可以看成是一个单步操作。

1
2
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-ca-z0-9-a-z等类似的情况。作为前导和尾随的-字符按原样排印。

3.6 do-while循环

我们在第1章中曾经讲过,while与for这两种循环在循环体执行前对终止条件进行测试。与此相反,C语言中的第三种循环————do-while循环则在循环体执行后测试终止条件,这样循环体至少被执行一次。

do-while循环的语法形式如下:

1
2
3
do
    语句
while(表达式);

在这一结构中,先执行循环体中的语句部分,然后再求表达式的值。如果表达式的值为真,则再次执行语句,依次类推。当表达式的值变为假,则循环终止。除了条件测试的语义不同外,do-while循环与Pascal语言的repeat-until语句等价。

经验表明,do-while循环比while循环和for循环用得少得多。尽管如此,do-while循环语句有时还是很有用的,下面我们通过函数itoa来说明这一点。itoa函数是atoi函数的逆函数,它把数字转换为字符串。这个工作比最初想象的要复杂一些。如果按照atoi函数中生成数字的方法将数字转换为字符串,则生成的字符串的次序正好是颠倒的,因此,我们首先要生成反序的字符串,然后再把该字符串倒置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/* itoa函数:将数字n转换为字符串并保存到s中 */
void itoa(int n, char s[]) {
    int i, sign;

    if ((sign = n) < 0)   /* 记录符号 */
        n = -n;           /* 将n成为正数 */
    i = 0;
    do {            /* 以反序生成数字 */
        s[i++] = n % 10 + '0';  /* 取下一个数字 */
    } while((n /= 10) > 0);     /* 删除该数字 */
    if (sign < 0)
        s[i++] = '-';
    s[i] = '\0';
    reverse(s);
}

这里有必要使用do-while语句,至少使用do-while语句会方便一些,因为即使n的值为0,也至少要把一个字符放到数组s中。其中的do-while语句体中只有一条语句,尽管没有必要,但我们仍然用花括号将该语句括起来了,这样做可以避免草率的读者将while部分误认为是另一个while循环的开始。

练习3-4 在数的对二的补码表示中,我们编写的itoa函数不能处理最大的负数,即n等于-(2^(字长-1))的情况。请解释其原因。修改该函数,使它在任何机器上运行时都能打印出正确的值。

练习3-5 编写函数itob(n,s,b),将整数n转换为以b为底的数,并将转换结果以字符的形式保存到字符串s中。例如,itob(n,s,16)把整数n格式化成十六进制整数保存在s中。

练习3-6 编写itoa函数,使得该函数可以接收三个参数。其中,第三个参数为最小字段宽度。为了保证转换后所得的结果至少具有第三个参数指定的最小宽度,在必要时应在所得结果的左边填充一定的空格。

3.7 break语句与continue语句

不通过循环头部或尾部的条件测试而跳出循环,有时是很方便的。break语句可用于从for、while与do-while等循环中提前退出,就如同从switch语句中提前退出一样。break语句能使程序从switch语句或最内层循环中立即跳出。

下面的函数trim用于删除字符串尾部的空格符、制表符与换位符。当发现最右边的字符为非空格符、非制表符、非换行符时,就使用break语句从循环中退出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* 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语句是相关联的,但它没有break语句常用。continue语句用于使for、while或do-while语句开始下一次循环的执行。在while与do-while语句中,continue语句的执行意味着立即执行测试部分;在for循环中,则意味着使控制转移到递增循环变量部分。continue语句只用于循环语句,不用于switch语句。某个循环包含的switch语句中的continue语句,将导致进入下一次循环。

例如,下面这段程序用于处理数组a中的非负元素。如果某个元素的值为负,则跳过不处理。

1
2
3
4
5
for (i=0; i<n; i++) {
    if (a[i] < 0)   /* 跳过负元素 */
        continue;
    ...  /* 处理正元素 */
}

当循环的后面部分比较复杂时,常常会用到continue语句。这种情况下,如果不使用continue语句,则可能需要把测试颠倒过来或者缩进另一层循环,这样做会使程序的嵌套更深。

3.8 goto语句与标号

C语言提供了可随意滥用的goto语句以及标记跳转位置的标号。从理论上讲,goto语句是没有必要的,实践中不使用goto语句也可以很容易地写出代码。至此,本书中还没有使用goto语句。

但是,在某些场合下goto语句还是用得着的。最常见的用法是终止程序在某些深度嵌套的结构中的处理过程,例如一次跳出两层或多层嵌套。这种情况下使用break语句是不能达到目的的,它只能从最内层循环退出到上一级的循环。下面是使用goto语句的一个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    for (...)
        for (...) {
            ...
            if (disaster)
                goto error;
        }
    ...

error:
    处理错误情况

在该例子中,如果错误处理代码很重要,并且错误可能出现在多个地方,使用goto语句将会比较方便。

标号的命名同变量命名的形式相同,标号的后面要紧跟一个冒号。标号可以位于对应的goto语句所在函数的任何语句的前面。标号的作用域是整个函数。

我们来看另外一个例子。考虑判定两个数组a与b中是否具有相同元素的问题。一种可能的解决方法是:

1
2
3
4
5
6
7
8
9
    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语句的程序,但可能会增加一些额外的重复测试或变量。例如,可将上面判定是否具有相同数组元素的程序段改写成下列形式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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语句。


专题:

本文发表于 2022-09-13,最后修改于 2022-09-13。

本站永久域名「 jiavvc.top 」,也可搜索「 后浪笔记一零二四 」找到我。


上一篇 « C程序设计语言-第4章函数与程序结构 下一篇 » C程序设计语言-第2章类型、运算符与表达式

赞赏支持

请我吃鸡腿 =^_^=

i ysf

云闪付

i wechat

微信

推荐阅读

Big Image