为什么需要 for 循环
if 和 switch 选出一个分支并执行一次。而真实的程序需要重复做事:打印每个分数,对一组数字求和,绘制网格的十行,逐个检查字符串中的字符。for 循环是 C 语言中按已知次数重复执行代码的主力,计数器由你掌控。
for 循环需要的一切都放在一个紧凑的循环头里,所以“执行多少次、怎么执行”在一行中就能看清。
三段式循环头
for 循环头有三部分,用分号隔开:初始化、条件和更新。
for (initializer; condition; update) {
// body - runs while the condition is nonzero
}
它们按特定的顺序执行,理解了这个顺序,你今后读到的每个 for 循环都能看懂:
- 初始化最先执行,且只执行一次。
- 检查条件。如果为零,循环立即结束。
- 执行循环体。
- 执行更新。
- 回到第 2 步。
按这个顺序追踪上面的循环:int i = 0 执行一次。0 < 5 成立,于是循环体打印 i = 0,然后 i++ 让 i 变成 1。再次检查条件,依此类推。当 i 达到 5 时条件不成立,跳过循环体,打印 done。循环体恰好执行了五次,i 依次取 0 到 4。
由此直接得出两个结论。条件在第一轮之前检查,所以条件一开始就为假的循环会执行零次:for (int i = 10; i < 5; i++) 根本不会进入循环体。而更新在循环体之后执行,所以在整个循环体中,i 保持的都是旧值。
在循环头里声明计数器(int i = 0)从 C99 起就是合法的,也是正确的默认写法:这样 i 只存在于循环内部,不会和后面的任何东西冲突,一个函数中的每个循环都可以自由复用这个名字。
正数、倒数和按步长计数
更新部分不限于 i++。可以倒数,可以按任意步长前进,也可以每轮翻倍:
让条件和更新相匹配。正数搭配 < 或 <=;倒数搭配 > 或 >=。搭配错了,就会写出一个永远不会结束的循环。
注意第二个循环用的是 <= 10,因为 10 是我们要包含的值;而第一个例子用的是 < 5,因为不包含 5。差一错误正是在这里产生的,所以要有意识地做出选择:从 0 开始时,i < n 执行 n 次;i <= n 执行 n + 1 次。
遍历数组
计数循环最常见的用法是按下标遍历数组。计数器同时就是你要读取的位置。
这个例子有两点值得记住。
sizeof 技巧。 sizeof(scores) 是整个数组的字节数,sizeof(scores[0]) 是一个元素的字节数,两者相除就是元素个数。添加元素时它会自动适应,写死的 5 就做不到。问题在于(而且是个大问题),它只在数组本身可见的地方有效。把数组传给函数后,它会 退化为指针,在那里 sizeof 得到的是指针的大小。在函数内部,始终把长度作为单独的参数传入。
条件是 i < n,绝不是 i <= n。 一个有五个元素的数组,合法下标是 0 到 4。读取 scores[5] 是未定义行为:可能打印出垃圾值,可能崩溃,也可能看似正常却悄悄破坏了别的数据。C 语言完全不做边界检查,所以这全靠你自己。
倒序遍历数组是同样的思路,只是方向相反:
从 n - 1(最后一个合法下标)开始,到 0 为止(包含 0),所以用 >= 而不是 >。这里的 i 要保持为有符号的 int,下一节会解释原因。
嵌套循环
把一个 for 放进另一个 for 里,就可以处理网格、表格或所有两两组合。外层循环每走一步,内层循环都要完整执行一遍。
外层循环固定一个 row;内层循环为这一行遍历每个 col;内层循环之后的 printf("\n") 结束这一行。把这个换行移到内层循环里面,整张表就会变成一列。值得动手试一试,因为它能让嵌套结构变得具体。
给计数器起不同的名字。row/col 或 i/j 都可以;两层都用 i 会让内层遮蔽外层的计数器,产生令人费解的结果。还要留意开销:一个 n 次循环套在另一个 n 次循环里,循环体会执行 n * n 次,所以两层 1,000 次的循环就是一百万轮。
下面是一个做实事的嵌套循环:打印一个星号三角形,内层循环的边界取决于外层计数器:
break 和 continue
有两个关键字可以在循环中途改变流程。break 立即离开循环;continue 跳过本轮剩余部分,直接跳到更新。
第一个循环一找到 7 就停下,不会再检查剩下的 93 个值。第二个循环用 continue 跳过偶数的打印;循环头里的 i++ 依然会执行,所以循环会继续推进。关于这两个关键字的更多内容见 break 和 continue,包括如何跳出嵌套循环,这是单个 break 做不到的。
无限循环和省略部分
循环头的三个部分都是可选的。省略条件,它就被视为永远为真:
for (;;) {
/* runs forever - exit with break or return */
}
for (;;) 是 C 语言中惯用的无限循环写法,分号仍然必须写。只要循环内部有办法 break、return 或 exit,它就不是错误:事件循环和“一直询问直到输入合法”的循环正是这样写的。
其他部分也可以省略。如果计数器已经存在,就省略初始化;如果循环体负责推进计数器,就省略更新:
int i = 0;
for (; i < n; ) {
/* ... */
i += step;
}
这样写合法,但读起来不如它本质上所等价的 while 循环。当计数器、上限和步长适合放在一起写进循环头时,用 for;否则用 while 循环。
循环头中的逗号运算符
初始化和更新部分都可以包含多个用逗号隔开的表达式,这样就能同时使用两个计数器:
int i = 0, j = len - 1 声明了两个计数器,i++, j-- 同时推进它们。这就是逗号运算符,它按从前到后的顺序依次求值。只在计数器确实并行时使用它;把不相关的操作硬塞进循环头只会把它们藏起来。
常见陷阱
C 语言中大部分 for 循环 bug 都来自下面几个陷阱。
差一错误。 对下标从 0 开始的数组使用 i <= n,会多读一个末尾之外的元素。“从 0 开始执行 n 次”就用 i < n。
循环头后面多了分号。 这能通过编译,但几乎从来都不是你想要的:
/* BUG: the loop body is the empty statement; the printf runs once */
for (int i = 0; i < 5; i++);
{
printf("%d\n", i);
}
; 成了循环体,于是循环空转五次,下面的大括号作为普通代码块只执行一次。gcc -Wall 会对此发出警告。
用无符号计数器倒数。 这是一个无限循环:
/* BUG: an unsigned value is never < 0 */
for (size_t i = n - 1; i >= 0; i--) {
process(a[i]);
}
对于无符号类型,i >= 0 永远为真。当 i 到达 0 时,i-- 会回绕成一个巨大的正数,循环继续访问不属于它的内存。倒数时用有符号的 int,或者把条件写成 for (size_t i = n; i-- > 0; ),它在一步中完成递减和判断,并在零处正确停止。
在两处修改计数器。 既在循环体中又在循环头中修改 i,会让迭代次数变得不可预测。只选一处。
浮点数计数器。 for (double x = 0.0; x != 1.0; x += 0.1) 可能永远不会结束,因为 0.1 没有精确的二进制表示,累加的结果会越过 1.0 而不会恰好等于它。用整数计数循环,在内部计算这个值:
for (int i = 0; i <= 10; i++) {
double x = i / 10.0;
/* ... */
}
在遍历数组时改变它的长度。 条件 i < n 每轮都会重新读取 n,所以如果循环体让集合变小了,要有意识地调整下标,而不是任由两者逐渐对不上。
常见问题
C 语言中 for 循环怎么写?
在循环头里放三部分,用分号隔开:初始化、条件和更新。for (int i = 0; i < 5; i++) { printf("%d\n", i); } 会把循环体执行五次,i 依次取 0 到 4,条件一旦为假就停止。
C 语言中怎么遍历数组?
让下标从 0 开始计数,直到长度为止(不包括长度本身):for (int i = 0; i < n; i++) { sum += a[i]; }。如果数组是在同一个函数中声明的,可以用 sizeof(a) / sizeof(a[0]) 计算长度;一旦数组被传给函数,这个技巧就失效了,所以要把长度作为参数传进去。
为什么我的 C 语言 for 循环多执行了一次?
这就是差一错误(off-by-one)。对于大小为 n、下标从 0 开始的数组,合法的下标是 0 到 n - 1,所以 i <= n 会多执行一轮,读取到数组末尾之外,这是 未定义行为。安全的默认写法是 i < n。
可以在 C 语言 for 循环里声明循环变量吗?
可以,从 C99 开始支持:for (int i = 0; i < n; i++)。这样变量只存在于循环内部,这正是你想要的。用 gcc -std=c99 或更新的标准编译即可(现代 GCC 和 clang 默认使用 C17,直接就能用);旧的 C89 写法要在循环之前声明 i。