目录

从头学C(4): 控制流

控制流语句用于控制各种计算操作的执行次序,前面已经接触了一些诸如if-else之类的控制流语句,那么在这第3章我们可以更加清楚各种类型的控制流语句。

第三章 控制流

3.1 语句与程序块

我们知道,在表达式后面加一个分号;就变成了一条语句,这是因为在C语言中,分号是语句结束符。(当然在其他编程语言中,分号的作用可能不尽相同,比如汇编语言中可能是作为注释起始符)

而用一对花括号{}把一些表达式(包括声明和语句)括在一起就构成了一个程序块(也称为“复合语句”)。从语法上讲,一个复合语句等价于单条语句。比如函数体中被花括号括起来的多条语句,以及ifelsewhilefor之后被花括号括起来的语句等,这些复合语句其实都相当于单条语句。

注意:右花括号}用于结束程序块,它后面不需要再加分号!

3.2 if-else语句

它的语法格式是:

if (表达式)
    语句1  程序块1
else
    语句2  程序块2

其中else是可选部分。

执行过程是:先计算表达式的值,如果为真(非0),则执行语句1或程序块1;如果表达式的值为假(等于0),并且else部分存在,则执行语句2或程序块2。

由于else部分是可选的,因此在嵌套的if语句中,可能会出现混乱。C语言的规则是,每个else会与最近的前一个没有else配对的if进行匹配。

例如在如下代码中:

if (n > 0)
    if (a > b)
        z = a;
    else
        z = b;

else部分与上一个if(a > b)进行匹配,而且我们特意通过程序的缩进结构来明确这一点。但是要注意,缩进仅仅是使代码看起来结构更清晰一点,而并不能影响语句的执行次序(因为编译器不能根据缩进来判断编程者的设计意图)。因此,如果我们希望else部分与if(n > 0)进行匹配,像如下代码肯定是错误的:

if (n > 0)
    if (a > b)
        z = a;
else
    z = b;

我们必须用花括号来明确我们的意图,如下代码才是正确的:

if (n > 0) {
    if (a > b)
        z = a;
}
else
    z = b;

这种歧义在有if语句嵌套的情况下,可能导致意想不到的结果。因此,我们强烈建议在有if嵌套的程序中使用花括号。

3.3 else-if语句

除了上一节看到的if-else语句,我们还可能会经常用到类似如下结构的语句:

if (表达式)
    语句
else if (表达式)
    语句
else if (表达式)
    语句
else if (表达式)
    语句
else
    语句

这种if语句序列是多路判定的常见用法。其中的表达式从上到下依次被求值,一旦某个表达式的结果为真(非0),则执行与之相关的语句,然后跳出整个序列的执行。和if-else语句一样,上面的“语句”既可以是单条语句,也可以是一个复合语句(程序块)。

最后那个else部分是用于处理“以上表达式均为假”的情况,当然有时候可能并不需要这么一个默认的处理分支,那么else及其对应的“语句”可以省略掉。

利用下面这个折半查找函数来看下三路判定程序的用法。

#include <stdio.h>
/* binsearch:  find x in v[0] <= v[1] <= ... <= v[n-1] */
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    /* found match */
            return mid;
    }
    return -1;   /* no match */
}

void main()
{
    int a[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int ret;

    ret = binsearch(12, a, 10);
    if (ret == -1)
        printf("12 is not in a[]\n");
    else
        printf("12 is equal to a[%d]\n",ret);

    ret = binsearch(3, a, 10);
    if (ret == -1)
        printf("3 is not in a[]\n");
    else
        printf("3 is equal to a[%d]\n",ret);

    return;
}

该函数用于判定在一个升序排列的数组v[]中是否存在某个特定的值x。如果v[]中包含x,则函数返回xv中的位置(0 ~ n-1之间);否则函数返回-1。

打印结果是:

12 is not in a[]
3 is equal to a[2]

实际测试,你会发现这个函数并不严谨。比如当a[] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11}x=1时,该函数并不会返回-1,而是会陷入一个无限循环,所以该函数还是有待改进的。

3.4 switch语句

switch语句也是一个常用的多路判定语句,格式一般是:

switch (表达式) {
    case 常量表达式1:
        语句序列1;
    case 常量表达式2:
        语句序列2;
    ... ...
    default:
        语句序列n;
}

其执行的过程是:先计算表达式的值,如果某一个分支的常量表达式与表达式的值匹配,则执行对应分支的语句序列;如果没有哪个分支与表达式的值匹配,则执行default分支的语句序列。

要注意:

  1. 常量表达式1、常量表达式2……各代表一个分支,这些常量表达式必须互不相同;
  2. default分支是可选的。如果没有没有default分支,同时其他分支也都与表达式的值不匹配,则该switch语句不执行任何动作;
  3. 各分支(包括default分支)的排列顺序是任意的;

在第一章的数组章节中,有一个用if-else结构写的程序,用于统计各个数字、空白符及其他字符的出现次数。用switch语句也可以完成同样的程序设计:

#include <stdio.h>
/* count digits, white space, others */
int 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语句中退出来,而且从上面的例子我们可以看到,有的分支后面加了break,有的分支没有加break,而且有的分支后面是空的语句序列。

switch语句中,case的作用只是一个标号。所以,某个分支的代码执行完成后,如果没有breakreturn语句使程序退出switch结构,那么程序会进入紧邻的下一个分支继续执行,这也是上面程序的第13、14行以及第17、18行的设计意图。

像这种将多个分支组合在一起的做法,有利也有弊。但正常情况下,我们在每个分支后面都会以一个break语句结束,因为从一个分支直接进入下一个分支继续执行的这种方法,在实际编程中很容易由于粗心而导致异常结果,而使得程序调试起来有点困难。所以一般不推荐这种做法,但在不得不使用的情况下,也应该加上适当的注释。

当我们把这个.c文件的所有字符交给这个程序去统计时(linux下可以使用命令cat xxx.c | ./a.out),可以得到如下的结果:

digits = 10 4 1 1 1 1 1 1 1 1, white space = 158, other = 435

switch语句的最后一个分支后面也加上一个break语句,虽然逻辑上没有必要这么做,但这是一种良好的程序设计风格。当我们需要在后面补充更多的分支时,这种编程习惯可以降低程序修改出错的可能性。

3.5 while循环与for循环

【语法格式】

前面已经看过一些while循环和for循环的例子,这一节再好好总结一下。

while循环语句的格式:

while(表达式)
    语句

while循环的计算规则是:先计算表达式的值,如果为真(即值不为0),则执行语句,然后再判断表达式是否为真,以此循环…直到表达式为假(即值为0),则跳出while循环。

对于for循环语句,格式如下:

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

for循环的计算规则,先执行一次表达式1,然后判断表达式2的值,如果为真,则执行语句,接着执行表达式3;再判断表达式2的值,如果为真,执行语句,接着执行表达式3…循环进行“如果表达式2为真”->“执行语句”->“执行表达式3”。直到表达式2为假(即值为0),则跳出for循环。

可见,从计算规则来看,上述的for循环等价于:

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

通常,在for循环中的3个组成部分中,表达式1和表达式3是赋值表达式或函数调用,表达式2是关系表达式。

要注意,这3个表达式的任何一个都可以省略,但分号必须保留。如果省略测试条件(即表达式2),则认为其值永远为真,所以,

for (; ;)
(
        ... ...
)

这个for循环式一个“无限”循环语句,这种情况下就需要借助其他手段(比如breakreturngoto语句)才能跳出循环。

【while和for的选择】

在使用while循环还是使用for循环的选择上,尽管取决于程序设计人员的个人偏好,不过有些基本的规则我们还是可以参考的。

如果语句中需要执行简单的初始化或变量递增,建议使用for语句,反之则使用while语句。比如:

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

这是用于处理数组多个元素时的常见用法。

for语句的初始化和变量递增部分不应放置一些和循环控制运算无关的表达式。for语句的一大特点便是循环控制部分的结构清晰明了,如果在这里掺入一些无关的表达式,未免画蛇添足之嫌。

【实例一:将字符串转换为对应整型值】

这次来重写第二章的函数atoi()。

#include <stdio.h>
#include <ctype.h>

/* atoi: convert s to integer; version 2 */
int atoi(char s[])
{
    int i, n, sign;

    for (i = 0; isspace(s[i]); i++) /* skip white space */
        ;
    sign = (s[i] == '-') ? -1 : 1;
    if (s[i] == '+' || s[i] == '-') /* skip sign */
        i++;
    for (n = 0; isdigit(s[i]); i++)
        n = 10 * n + (s[i] - '0');
    return sign * n;
}

int main()
{
    char *str1 = "+12345";
    char *str2 = "-12345";
    char *str3 = "  12345";

    printf("%s = %d\n", str1, atoi(str1));
    printf("%s = %d\n", str2, atoi(str2));
    printf("%s = %d\n", str3, atoi(str3));

    return 0;
}

程序的结构是:

  1. 如果有空白符,跳过
  2. 如果有符号(“+”或者“-”),读取符号
  3. 去整数部分,执行转换

【实例二:整型数组的Shell排序算法】

Shell排序算法由D.L. Shell与1959年发明,基本思想是:先比较距离远的元素,而不是像冒泡排序算法那样先比较相邻的元素,这样可以快速减少大量的无序情况,从而减轻后续的工作。被比较元素之间的举例逐步减小,直到减小到1,此时排序就变成了相邻元素的互换。

#include <stdio.h>

/* shellsort: sort v[0]...v[n-1] into increasing order */
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;
            }
}

int main()
{
    int i;
    int a[10] = {10, 5, 8, 3, 4, 9, 4, 7, 1, 1}; 

    printf("before sorting:\n");
    for (i = 0; i < 10; i++)
        printf("%d ",a[i]);
    printf("\n");

    shellsort(a, 10);

    printf("after sorting:\n");
    for (i = 0; i < 10; i++)
        printf("%d ",a[i]);
    printf("\n");

    return 0;
}

里面有个三重嵌套的for循环语句:

  1. 最外层的for语句:控制两个被比较元素之间的距离。从n/2开始依次对折,直到距离为0;
  2. 中间层的for语句:在元素间移动位置;
  3. 最内层的for语句:比较每一对距离为gap的元素,如果逆序则把它们的互换过来。

当最终gap的值递减到1时,进行相邻元素的比较/互换之后,所有的元素也就都处于正确的排序位置上了。

【实例三:字符串倒置】

倒置字符串s中的各个字符的位置。

#include <stdio.h>
#include <string.h>

/* reverse: reverse string a in place */
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;
    }
}

int main()
{
    char str[] = "0123456789";

    printf("befor: %s\n", str);
    reverse(str);
    printf("after: %s\n", str);

    return 0;
}

在上面的例子中,我们看到在for循环语句中使用了逗号运算符,用于同时处理循环控制的两个变量。

逗号运算符,是C语言中优先级最低的运算符。被逗号分隔的两个表达式按照从左到右的顺序进行求职,表达式右边的操作数的类型和值即为整个表达式结果的类型和值。

【不是所有逗号,都是逗号运算符】

比如分隔函数参数的逗号、分隔声明中变量的逗号等,这些并不是逗号运算符,当然也不能保证表达式按照从左至右的顺序求值。

逗号运算符应该慎重使用,比较适用于关系紧密的结构,比如上述实例三中的元素交换过程,可以通过如下的方式,将其看成一个单步操作:

for (i = 0, j = strlen(s)-1; i < j; i++, j--)
    c = s[i], s[i] = s[j], s[j] = c;

3.6 do-while循环

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

do
    语句
while(表达式);

执行规则是:先执行语句,再判断表达式,如果为真(即值不为0),则再次执行语句,以此类推;如果某一次判断表达式为假(即值为0),则跳出循环。

尽管实际使用过程中,do-while循环比while循环或for循环都要少得多,但不能否认有时候它还是非常有用的!

来看一个实例:把数字转换为对应的字符串。itoa函数是atoi函数的逆函数,按照atoi函数中的逻辑,数字转换成字符串后,该字符串的次序刚好是颠倒的,所以我们还要调用上一节的reverse()函数。

#include <stdio.h>
#include <string.h>

/* reverse: reverse string a in place */
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;
    }   
}
/* itoa: convert n to characters in s */
void itoa(int n, char s[])
{
    int i, sign;

    if ((sign = n) < 0) /* record sign */
        n = -n;     /* make n positive */

    i = 0;
    do {    /* generate digits in reverse order */
        s[i++] = n % 10 + '0'; /* get next digit */
    } while ((n /= 10) > 0); /* delete it */

    if (sign < 0)
        s[i++] = '-';
    s[i] ='\0';
    reverse(s);
}

int main()
{
    int num = 1234567890;
    char str[10];

    itoa(num, str);

    printf("num = %d, str = %s\n", num, str);

    return 0;
}

这里有必要使用do-while语句,是因为即使n为0,也需要把一个字符放到数组s中去。

尽管只有一条语句,但我们还是用花括号将语句括起来,避免引起不必要的歧义(被误认为是另一个while循环的起始)。

3.7 break语句与continue语句

【break:跳出循环语句 或 switch语句】

如同从switch语句中提前退出一样,break语句也可以从forwhiledo-while循环中立即跳出。

要注意的是,一个break语句只能从最内层的循环中跳出。也就是说,对于嵌套的循环语句或switch语句,break也只是仅仅从该语句所在的循环中跳出来,仍会处于上一级的循环控制下。

trim函数为例,该函数用于删除字符串尾部的空格符、制表符和换行符,如果最右边的字符不是这些字符,则使用break退出循环。

#include <stdio.h> 
#include <string.h>
        
/* trim: remove trailing blanks, tabs, newlines */
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;
}

int main()
{
    char str[] = {'1', '2', '3', ' ', '\t', '\n'};
    int len = 0;

    printf("before: %s#END\n", str);
    len = trim(str);
    printf("after: Len = %d\n", len);
    printf("after: %s#END\n", str);

    return 0;
}

【continue:执行下一次循环】

用于使forwhiledo-while语句开始下一次循环的执行。

whiledo-while语句中,continue语句的执行意味着立即执行条件测试部分;而在for循环中,则意味着立即执行递增循环变量部分。

而且continue只适用于循环语句,不适用于switch语句。当然你仍可以在switch语句中使用continue语句,不过这个continue是用于控制switch语句上一层的循环语句(forwhiledo-while)。

也来看一个continue语句的例子,下面这段程序用于处理数组a中的非负元素:

for (i = 0; i < n; i++)
{
    if (a[i] < 0)   /* skip negative elements */
        continue;
    ...     /* handle positive elements */
}

即如果某个元素为负值,则跳过(不作处理)。

3.8 goto语句与标号

顾名思义,goto语句用于控制程序跳转到指定位置去执行。尽管到目前为止,我们还没有使用它写过任何程序,但不能否认在一些特殊情况下,goto语句是非常有用的。

最常见的情况是:终止程序在某些深度嵌套的结构中运行,例如直接从两层或两层以上的循环中跳出。

for (...)
    for(...) {
        ...
        if (err)
            goto error;
    }
...
error:
    /* clean up the mess */

如果出现错误,程序会直接跳到error:标号所在位置。由于break语句只能跳出到上一级循环,因此这种情况下使用goto语句会更方便。

而且,如果上述例子中的错误处理代码很重要,并且错误可能出现在多个地方,那使用goto语句无疑是明智的选择。

标号的命名和变量命名的形式相同,标号后面要紧跟一个冒号:

标号可位于对应goto语句所在函数的内部任何位置,显然,标号的作用域便是整个函数

再来看一个使用goto语句的例子:判定两个数组ab中是否具有相同元素的问题。

for (i = 0; i < n; i++)
    for (j = 0; j < m; j++)
        if (a[i] == b[j])
            goto found;
/* didn't find any common element */
...
found:
    /* got one: a[i] == b[j] */
    ... ...

然后,我们来看一个实现相同功能,但不使用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)
    /* got one: a[i-1] == b[j-1] */
    ...
else
    /* didn't find any common element */
    ...

将这两个例子进行对比是为了说明:所有使用goto语句的程序代码都能改写成不带goto语句的形式,虽然可能会带来一些额外的重复测试或变量。

大多数情况下,如果在一个函数中出现过多的标号和goto语句,会导致程序难以理解和维护。所以,我们应该仅在必要的情况下,才使用goto语句和标号。