本章导读

好的语言应提供三种程序流程:顺序执行重复执行(循环 looping)按测试选择路径(分支 branching)。本章深入三种循环结构——whilefordo while,学习用关系运算符(relational operator)构造测试条件,顺带认识数组(array)和带返回值的函数。分支语句留到第 7 章。

6.1 再探 while 循环

程序清单 6.1 summing.c 把用户输入的整数累加,直到输入非数字(如 q)为止:

/* summing.c -- 交互式求整数和 */
#include <stdio.h>
int main(void)
{
    long num;
    long sum = 0L;    /* 和初始化为 0 */
    int status;
 
    printf("Please enter an integer to be summed ");
    printf("(q to quit): ");
    status = scanf("%ld", &num);
    while (status == 1) /* == 表示"是否相等" */
    {
    sum = sum + num;
    printf("Please enter next integer (q to quit): ");
    status = scanf("%ld", &num);
    }
    printf("Those integers sum to %ld.\n", sum);
 
    return 0;
}

示例运行:输入 44、33、88、121 后敲 q,输出 Those integers sum to 286.

程序怎么知道何时停止? 关键在 scanf()返回值:它返回成功读取的项目数。读到整数返回 1,赋给 status;读到 q 返回 0——循环随之终止(读取前就出错则返回 EOF,通常为 -1,同样终止)。用返回值控制循环,比”用特定数值结束”或”每轮询问是否继续”都优雅。程序逻辑可用**伪代码(pseudocode)**总结成标准格式:sum 初始化为 0 → 提示用户、读入第一个值 → while 读入的是整数:累加、提示、读下一个值 → 循环结束后打印 sum。

C 风格的读入循环

C 有个捷径,把”读取 + 测试”合并进循环条件:

while (scanf("%ld", &num) == 1)
{
    /* 循环体 */
}

这里 scanf() 一举两得:既把值放进 num,又用返回值(1 或 0,不是 num 的值)控制循环。由于每轮都要检查条件,scanf() 每轮都被调用——提供新的 num 和新的测试。

记住这个惯用法

while (scanf("%lf%d", &x, &exp) == 2) 是 C 风格读入循环的标准形态:读到几个数就与几比较。输入合法继续循环,遇到非数字输入自然终止,不需要额外的”是否继续”询问。

6.2 while 语句

一般形式

while (expression)
    statement

statement 可以是带分号的简单语句,也可以是花括号包起来的复合语句。expression 为真(非零)时执行语句,然后再次测试——每执行一轮叫一次迭代(iteration)

/图:while 循环的结构

终止 while 循环

循环体内必须有东西能改变测试表达式的值,让它最终变假,否则就是死循环:index = 1; while (index < 5) printf("Good morning!\n"); 会无限打印——循环体里没有任何东西改变 index。若改成 while (--index < 5),index 往反方向跑,也未必是好事。

循环何时退出? 只在测试条件被求值时决定。清单 6.2(when.c)演示:n 在第二轮循环体内变成 7,但程序并不立刻退出,而是完成本轮循环,直到第三次求值 n < 7 才退出。

while:入口条件循环

while 是入口条件循环(entry-condition loop):必须先满足条件才进入循环体。条件一开始就为假,循环体一次都不执行index = 10;while (index++ < 5) 的循环体一次也不会跑;改成 index = 3; 就会执行。

语法要点:只有一条语句属于循环

程序清单 6.3 while1.c 是个反面教材:

/* while1.c -- 小心花括号 */
/* 糟糕的写法制造了无限循环 */
#include <stdio.h>
int main(void)
{
    int n = 0;
 
    while (n < 3)
        printf("n is %d\n", n);
    n++;
    printf("That's all this program does\n");
    return 0;
}

虽然 n++; 有缩进,但它没被花括号包住——while 只包含测试条件后的第一条语句。n 永远是 0,条件永远为真,程序无限打印 n is 0 直到被手动杀掉。

分号放错位置是另一个坑(清单 6.4 while2.c):while (n++ < 3); 里测试条件后紧跟的分号构成空语句(null statement)——它就是循环体!循环空转 3 次把 n 加到 4,其后的 printf() 根本不在循环里,输出只有 n is 4

空语句与缩进骗局

while 的循环体是”测试条件后到第一个分号(或配对右花括号)为止”的一条语句。缩进只给人看,编译器只认花括号和分号。偶尔空语句是有意为之(如 while (scanf("%d", &num) == 1)\n ; /* 跳过整数输入 */),此时应把分号单独放一行并加注释,表明这是故意的。

6.3 哪个更大:关系运算符和表达式

while 循环常用**关系表达式(relational expression)**做测试。完整的关系运算符如下:

表 6.1 关系运算符

运算符含义
<小于
<=小于或等于
==等于
>=大于或等于
>大于
!=不等于
  • 关系运算符也能比较字符(按机器字符码比较,如 ch != '$'),但不能比较字符串;
  • 浮点数比较要小心:舍入误差会让逻辑相等的两数不相等(3 × (1/3) 理应为 1.0,用 6 位小数表示 1/3 时乘积只有 0.999999)。浮点比较尽量只用 <>;判断”足够接近”用 math.hfabs()(取绝对值)——清单 6.5 cmpflt.c 用 while (fabs(response - ANSWER) > 0.0001) 让用户反复猜 π 直到误差小于万分之一。

什么是真?

关系表达式的值也是数值:真为 1,假为 0。清单 6.6(t_and_f.c)验证:true_val = (10 > 2); 得 1,false_val = (10 == 2); 得 0。所以 while (1) 就是”永远循环”。

非零即为真。清单 6.7(truth.c)实验表明:n 为 3、2、1、-3、-2、-1 时 while (n) 都继续,n 为 0 才终止。因此 while (goats != 0) 可简写成 while (goats)——C 程序员最常用的惯用法,值得练到一眼看懂。

真值的麻烦

把清单 6.1 的测试从 status == 1 改成 status = 1(清单 6.8 trouble.c),程序立刻变死循环:= 是赋值,status 被赋成 1,而赋值表达式的值就是左边的值 1——永远为真。输入 q 后 status 被置 0,下一轮测试又把它赋回 1;更糟的是 scanf() 失败后 q 留在输入队列,每轮都读它、每轮都失败。

千万别把 = 写成 ==

canoes = 5 是把 5 赋给 canoes;canoes == 5检查 canoes 是否等于 5。两者是完全不同的运算。防错技巧:比较常量时把常量写在左边——5 == canoes 若手滑写成 5 = canoes 会直接报语法错(不能给常量赋值),错误当场暴露。

/图:关系运算符 == 与赋值运算符 = 的区别

新的 _Bool 类型

C99 新增专存真/假值的 _Bool 类型(得名于数学家 George Boole):变量只能取 1 或 0,赋任何非零值都会存成 1。清单 6.9 boolean.c 用 _Bool input_is_good; 改写求和程序:input_is_good = (scanf("%ld", &num) == 1); 把比较结果存进布尔变量,while (input_is_good) 一读就懂(括号里的 == 优先级高于 =,括号只是增强可读性)。C99 还提供头文件 stdbool.h:让 bool 成为 _Bool 的别名,定义 truefalse 符号常量,写法与 C++ 兼容。

关系运算符的优先级

  • 关系运算符优先级低于算术运算符、高于赋值运算符:x > y + 2x > (y + 2)x_bigger = x > y;x_bigger = (x > y);
  • 关系运算符内部分两档:< <= > >= 高于 == !=
  • 结合律从左到右:ex != wye == zee(ex != wye) == zee(不推荐这种写法)。

表 6.2 目前所学运算符的优先级(从高到低)

运算符结合律
()从左往右
- + ++ -- sizeof (type)(均为一元)从右往左
* / %从左往右
+ -(二元)从左往右
< > <= >=从左往右
== !=从左往右
= += -=从右往左

6.4 不确定循环和计数循环

不确定循环(indefinite loop):事先不知道循环多少次(如 summing.c)。计数循环(counting loop):执行预先确定的次数,需要三要素——①计数器初始化;②计数器与限值比较;③每轮更新计数器。清单 6.10 sweetie1.c 用 while 实现了三要素,但它们散落各处:初始化在循环外、更新在循环尾,都可能被漏写。需要把它们集中到一处的控制语句。

6.5 for 循环

for 循环把初始化、测试、更新三件事聚齐。清单 6.11 sweetie2.c 用 for (count = 1; count <= NUMBER; count++) 打印 22 遍 “Be my Valentine!”,圆括号里有用两个分号隔开的三个表达式:①初始化只在循环开始时执行一次;②测试条件每轮执行前求值,为假即终止;③更新在每轮循环结束后求值。清单 6.12 for_cube.c 用 for (num = 1; num <= 6; num++) 打印 1~6 的立方表——第一行 for 语句就交代了循环的全部参数:从几开始、到几结束、每轮加多少。

/图:for 循环的结构

for 的灵活性

C 的 for 远比其他语言的 FOR 循环灵活:

  • 倒着数for (secs = 5; secs > 0; secs--)(for_down.c);按任意步长for (n = 2; n < 60; n = n + 13)(for_13s.c);用字符计数for (ch = 'a'; ch <= 'z'; ch++)(for_char.c)——字符按整数存储,本质还是数整数;
  • 测试其他条件for (num = 1; num*num*num <= 216; num++) 只关心立方值大小;几何增长for (debt = 100.0; debt < 150.0; debt = debt * 1.1) 每轮乘 1.1;
  • 第三个表达式可以是任意合法表达式(for_wild.c),甚至三个控制表达式用不同变量(合法但不是好风格);
  • 可以留空表达式,但分号不能省for (n = 3; ans <= 25; );中间表达式为空视为真,所以 for (;;) 是永动机;
  • 第一个表达式也可以干别的事(for_show.c):for (printf("Keep entering numbers!\n"); num != 6; )——printf() 只执行一次,然后反复读输入直到 6;循环内也可以修改循环参数(如调整步长 delta),但设成 0 就原地打转了。

把循环控制聚在一处

for 的最大优点是三要素集中在一行,一眼看清循环从哪开始、到哪结束、怎么更新,漏写更新导致死循环的概率大大降低。涉及计数时优先用 for。

6.6 更多赋值运算符:+=、-=、*=、/=、%=

这些组合赋值运算符把”旧值经运算后存回”压缩成一个符号:

scores += 20   等价于  scores = scores + 20
dimes  -= 2    等价于  dimes  = dimes - 2
bunnies *= 2   等价于  bunnies = bunnies * 2
time   /= 2.73 等价于  time   = time / 2.73
reduce %= 3    等价于  reduce = reduce % 3
x *= 3 * y + 12 等价于  x = x * (3 * y + 12)

它们与 = 一样优先级很低(低于算术运算符),所以最后一例是整个右边先算完再乘。写法更紧凑、可能生成更高效的机器码,在 for 控制表达式里尤其好用。

6.7 逗号运算符

**逗号运算符(comma operator)**让你在一个 for 里同时初始化或更新多个变量。清单 6.13 postage.c 打印平信邮资表(首盎司 37 美分,续盎司 23 美分),所有初始化与更新全靠逗号塞进 for 的控制表达式:

for (ounces=1, cost=FIRST_OZ; ounces <= 16; ounces++, cost += NEXT_OZ)
    printf("%5d $%4.2f\n", ounces, cost/100.0);

输出从 1 $0.37 开始,每盎司递增 0.23 美元:2 $0.603 $0.834 $1.06……

/图:逗号运算符与 for 循环

逗号运算符有两个性质:

  1. 保证左边的表达式先求值(逗号是序列点)——ounces++, cost = ounces * FIRST_OZ 一定先用新值;
  2. 整个逗号表达式的值等于右边成员的值——x = (y = 3, (z = ++y + 2) + 5); 最终 x 得 11。

注意别把分隔符误当逗号运算符:int ch, date;printf("%d %d\n", a, b); 里的逗号只是分隔符。经典笔误:houseprice = 249,500; 不是语法错误,而是把 249 赋给 houseprice,500 成了无所事事的空语句!

6.8 当芝诺遇上 for 循环

芝诺(Zeno)辩称飞矢永远到不了靶心:先飞一半,再飞剩下的一半的一半……无穷无尽。用时长表示就是无穷级数 1 + 1/2 + 1/4 + 1/8 + ...。清单 6.14 zeno.c 在一个 for 里用三个逗号同时初始化 time, x, t_ct、又用逗号同时更新 t_ct++, x *= 2.0,循环体只有两行:time += 1.0/x; 和打印。输入 15 项,部分和一路逼近 2.0(1.000000 → 1.500000 → 1.750000 → …… → 1.999969)。数学上可证级数收敛于 2:设 S 为和,S - S/2 = 1,故 S = 2。教训:动手算复杂级数前,先看看数学家有没有现成结论。

6.9 出口条件循环:do while

while 和 for 都是入口条件循环,条件不满足时循环体可能一次都不执行。C 还有出口条件循环(exit-condition loop):每轮执行之后才测试条件,保证至少执行一次——这就是 do while

程序清单 6.15 do_while.c

/* do_while.c -- 出口条件循环 */
#include <stdio.h>
int main(void)
{
    const int secret_code = 13;
    int code_entered;
 
    do
    {
        printf("To enter the triskaidekaphobia therapy club, \n");
        printf("please enter the secret code number: ");
        scanf("%d", &code_entered);
    } while (code_entered != secret_code);
    printf("Congratulations! You are cured!\n");
 
    return 0;
}

用户必须猜中暗号 13 才能退出”恐惧 13 治疗俱乐部”。若用 while 改写(清单 6.16 entry.c),就得在循环前先写一遍提示和 scanf(),代码更长。一般形式(结尾的分号不能少):

do
    statement
while ( expression );

/图:do while 循环的结构

使用建议:do while 只该用在”必须至少执行一次”的场合(比如密码程序——先提示输入才有得测)。千万别把”问用户是否继续”放进 do 里、“干正事”放进循环体——用户答”否”之后,正事还是会被干一遍,因为测试来得太迟了。

6.10 该用哪种循环?

  • 先问:需要入口条件还是出口条件?通常选入口条件——“先看再跳”更稳,测试在循环开头更好读,且很多场景下循环本就该允许一次都不执行;
  • 入口条件里再选 for 还是 while?两者能互换(for (;test;) 等价于 while (test)),主要看习惯:
    • 涉及初始化并更新一个计数变量时用 for:for (count = 1; count <= 100; count++)
    • 其他情况用 while 更自然:while (scanf("%ld", &num) == 1)

6.11 嵌套循环

**嵌套循环(nested loop)**是循环套循环,常用于按行列显示数据:外层管行,内层管列。清单 6.17 rows1.c 用 #define ROWS 6#define CHARS 10,外层 for (row = 0; row < ROWS; row++) 造 6 行,内层 for (ch = 'A'; ch < ('A' + CHARS); ch++) 在每行打 A 到 J 十个字母,内层结束后 printf("\n"); 换行——输出 6 行、每行 10 个字母。内层循环对外层的每一轮都跑满自己的全部迭代

程序清单 6.18 rows2.c 让内层循环依赖外层:起始字符随行号后移。

// rows2.c -- 使用依赖外层的嵌套循环
#include <stdio.h>
int main(void)
{
    const int ROWS = 6;
    const int CHARS = 6;
    int row;
    char ch;
 
    for (row = 0; row < ROWS; row++)
    {
        for (ch = ('A' + row); ch < ('A' + CHARS); ch++)
            printf("%c", ch);
        printf("\n");
    }
 
    return 0;
}

输出变成三角形:ABCDEF / BCDEF / CDEF / DEF / EF / F——row 加到 ‘A’ 上,每行起始字符后移一位,但测试条件不变,所以每行仍以 F 结尾、比上一行少一个字符。

6.12 数组简介

数组(array)是按顺序存储的同类型一系列值:整个数组一个名字,各个项目(元素 element)用整数下标访问。

float debts[20];    /* 20 个 float 元素的数组 */
debts[5] = 32.54;   /* 给第 6 个元素赋值 */
scanf("%f", &debts[4]);  /* 读值进第 5 个元素 */

下标从 0 开始:第一个元素是 debts[0],最后一个元素是 debts[19]。下标(subscript / index)必须是整数。数组元素用起来和同类型变量一模一样。

/图:数组元素在内存中按顺序连续存放

C 不检查数组下标越界

出于执行速度考虑,编译器不管你写 debts[20] = 88.32; 还是 debts[33] = 828.12;——这些位置根本没有这个数组的元素!数据会被写进内存里别人的地盘,可能破坏输出甚至导致崩溃。保证下标合法是你自己的责任。

数组可以是任何类型:int nannies[22];char actors[26];long big[500];。char 数组若以空字符 \0 结尾,内容就构成字符串(string);否则只是普通字符数组。

用 for 循环处理数组

程序清单 6.19 scores_in.c 读入 10 个高尔夫成绩,回显并求和、平均:

// scores_in.c -- 用循环处理数组
#include <stdio.h>
#define SIZE 10
#define PAR 72
int main(void)
{
    int index, score[SIZE];
    int sum = 0;
    float average;
 
    printf("Enter %d golf scores:\n", SIZE);
    for (index = 0; index < SIZE; index++)
        scanf("%d", &score[index]);    // 读入 10 个成绩
    printf("The scores read in are as follows:\n");
    for (index = 0; index < SIZE; index++)
        printf("%5d", score[index]);   // 回显输入
    printf("\n");
    for (index = 0; index < SIZE; index++)
        sum += score[index];           // 求和
    average = (float) sum / SIZE;      // 计算平均
    printf("Sum of scores = %d, average = %.2f\n", sum, average);
    printf("That's a handicap of %.0f.\n", average - PAR);
 
    return 0;
}

示例运行(11 个数只读前 10 个,scanf() 自动跳过空白):总分 1026,平均 102.60。风格要点:

  • #define SIZE 10 定义数组大小并在声明与循环上限处统一使用——以后扩容只需改一处;
  • 处理大小为 SIZE 的数组,标准写法 for (index = 0; index < SIZE; index++)——从 0 数起,最后一个下标恰好是 SIZE - 1;
  • 程序回显刚读入的数据,确认处理的正是你以为的数据;
  • 拆成三个 for(读、回显、求和)而非硬塞进一个循环,体现模块化(modularity):每个单元只做一件事,程序更易读、更易改。

6.13 使用带返回值的函数的循环示例

程序清单 6.20 power.c 编写计算整数次幂的函数,返回值通过 return 交回调用者:

// power.c -- 计算数的整数次幂
#include <stdio.h>
double power(double n, int p); // ANSI 原型
 
int main(void)
{
    double x, xpow;
    int exp;
 
    printf("Enter a number and the positive integer power");
    printf(" to which\nthe number will be raised. Enter q");
    printf(" to quit.\n");
    while (scanf("%lf%d", &x, &exp) == 2)
    {
        xpow = power(x, exp);    // 函数调用
        printf("%.3g to the power %d is %.5g\n", x, exp, xpow);
        printf("Enter next pair of numbers or q to quit.\n");
    }
    printf("Hope you enjoyed this power trip -- bye!\n");
 
    return 0;
}
 
double power(double n, int p)    // 函数定义
{
    double pow = 1;
    int i;
 
    for (i = 1; i <= p; i++)
        pow *= n;
 
    return pow;    // 返回 pow 的值
}

示例运行:输入 1.2 12 输出 1.2 to the power 12 is 8.9161;输入 2 16 输出 2 to the power 16 is 65536;输入 q 退出。要点解读:

  • 算法:pow 初始化为 1,循环 p 次每次 pow *= n,n 自乘 p 次即得 n 的 p 次方;
  • 函数三连double power(double n, int p);原型(声明返回 double、接受两参数,末尾有分号);xpow = power(x, exp);调用double power(double n, int p) { ... }定义(头部不带分号);
  • return 把表达式的值交回调用处,可赋给变量、用在表达式里、传给别的函数,也可弃之不用;
  • main() 里的 while 以 scanf(...) == 2 控制,main() 本身充当测试 power() 的驱动程序(driver)
  • 为什么要原型? 编译器走到 power(x, exp) 时还没见过函数定义,原型是”预告”,告诉它返回类型与参数类型;stdio.h 里就藏着 scanf()、printf() 的声明,所以你从没显式声明过它们。

6.14 关键概念与本章小结

设置循环时务必检查三件事:①明确定义让循环终止的条件;②确保测试所用的值在首次使用前已初始化;③确保循环每轮都更新测试所用的值

  • C 用数值处理条件:0 为假,非 0 为真;关系表达式真为 1 假为 0,与 _Bool 类型取值一致;
  • while 与 for 是入口条件循环(可能一次不执行),do while 是出口条件循环(至少执行一次);for 适合带初始化和更新的计数场景,逗号运算符让 for 能同时管多个变量;
  • 数组是同类型元素的连续存储,下标从 0 开始,最后一个元素下标是元素个数减 1;C 不检查越界;
  • 带返回值的函数三步走:原型声明调用定义,函数用 return 交回值。原型与定义分别对应”接口(interface)“与”实现(implementation)“的现代编程理念。