控制流
对照 K&R 第三章,以入口、状态转移、进展和出口审查语句块、if/else、switch、while/for/do-while、break/continue 与 goto,并写出可判定的 EOF 循环和集中清理路径。
学习目标
- 能绘制语句块、分支与三种循环的控制流图,标明每条边的条件、状态变化和退出点
- 能比较
break、三种循环中的continue与goto的精确目标,并判断它们是否破坏进展或清理协议 - 能回答:为什么
while ((c = getchar()) != EOF)中的赋值是有意设计,而if (ready = 0)通常是缺陷?
从“下一条会执行什么”开始
控制流不是“选一种看起来顺手的语法”,而是对程序计数器下一步去向的约束。阅读每个结构时写下四件事:从哪里进入、条件读取哪些状态、每次迭代如何取得进展、从哪些边退出。没有进展变量或退出契约的循环,即使语法正确也可能永不结束。
这张图给出形状直觉,下面的责任表进一步区分入口与转移。for 与 while 可以互相改写,但 continue 的落点不同;switch 也不是一组彼此隔离的 if,它先跳到标签,再沿语句顺序继续执行。
语句与程序块决定作用域
表达式加分号形成表达式语句,花括号把零条或多条声明/语句组成 ↡由花括号包围的复合语句;它在需要单条语句的位置承载多步操作,并建立块作用域。。即使分支当前只有一条语句,也建议保留花括号,避免后来追加语句却仍只有第一条受条件控制。
空语句只是一个分号。while ((c = getchar()) != '\n' && c != EOF) ; 可以有意丢弃行尾前字符,但单独成行并注释目的;if (ready); work(); 中多余分号则让 work() 无条件执行,严格告警应将其暴露。
if、else-if 与悬空 else
if 接受标量条件:零为空假,非零为真。else if 不是独立关键字,而是 else 后紧跟另一条 if 语句。链条按顺序测试,第一个真分支执行后跳过其余分支;若需要覆盖所有输入,最后的 else 应处理默认或错误情况。
没有花括号时,else 与最近一个尚未匹配的 if 配对,这就是 ↡嵌套 if 未使用花括号时,else 按语法归属最近的未匹配 if,可能与缩进暗示的意图不同。。缩进不参与 C 语法,代码审查不能用“看起来属于外层”替代花括号。
条件中的副作用与短路
C 允许任意标量表达式作为条件,也允许赋值表达式产生值,所以“条件里有赋值”不自动等于缺陷。关键是赋值是否代表协议的一步:while ((c = getchar()) != EOF) 每轮必须先取得一个输入结果,再据此决定继续;额外括号既满足语法分组,也向告警工具表明赋值是有意的。if (ready = 0) 却把状态覆盖为零,通常没有合理协议含义。
逻辑与 &&、逻辑或 || 提供从左到右的短路保证。index < count && items[index] != 0 先验证边界,失败时绝不访问数组;颠倒顺序就可能在检查前越界。相反,位运算 &、| 不短路,不能拿来替换逻辑运算符。测试组合条件时应覆盖“左侧已决定结果”和“必须求右侧”两类路径。
条件还应避免重复读取会变化的状态。若 queue_empty() 可能被其他执行单元改变,连续调用两次不代表同一快照;若 next_token() 会消耗输入,把它放在多个分支条件里会让分支顺序改变数据。先读取一次到局部变量,再基于稳定值分支,控制流与数据流才能一起审查。
长条件可拆成具名谓词,但名字必须表达业务判断而非重复语法。对范围、枚举状态和错误码,列出互斥性与穷尽性:是否可能两个分支同时真,是否存在没有处理的值,默认分支是回退还是错误。这样写测试时能从条件表直接生成边覆盖,而不是碰运气挑几个输入。
switch 是跳转后顺序执行
switch 的控制表达式经过整数提升,case 必须是不同的整数常量表达式;最多一个 default。执行先求控制表达式,再跳到匹配标签或 default,随后像普通语句序列一样向下运行,直到 break、return、goto 或 switch 结束。
因此 ↡switch 跳到某个 case 后继续执行后续 case 标签下的语句,直到显式转移或结构结束。它可用于合并行为,但必须让意图清楚。 不是“再次匹配下一个 case”,而是根本不再做匹配。多个标签共享一段逻辑是常见合法用法:
#include <ctype.h>
int classify(int c)
{
switch (c) {
case ' ':
case '\t':
case '\n':
return 0; /* white space */
default:
return isdigit((unsigned char)c) ? 1 : 2;
}
}这里三个 case 有意落入同一个 return,没有遗漏 break。传给 ctype.h 函数的值必须是 EOF 或可表示为 unsigned char 的值,所以转换不能省略。
switch 适合对整数或枚举值做等值分派,不支持范围、字符串或任意谓词。若分支条件是“温度低于零”“名称相等”或需要按优先级执行带副作用的检查,使用清楚的 if-else 链或查表;不要把数据强行编码成一堆魔法整数只为使用 switch。
枚举分派要决定 default 的语义。面向外部不可信输入时,default 应拒绝未知值;面向封闭内部枚举时,省略 default 并开启完整性告警,有助于新增枚举项时得到编译诊断。空 default: break; 会吞掉未来状态,不能被视为天然稳健。无论选择哪种策略,都要在接口契约中说明未知值是可恢复输入还是程序错误。
while:先验证不变量再进入
while 在每次迭代前求条件,初始为假时循环体执行零次。它适合“持续到事件发生”的协议,例如读到文件结束。循环应维护一个 ↡在每次迭代开始和结束时都成立的命题,用来说明已处理部分正确,并与退出条件共同推出最终结果。,还要有能朝退出方向变化的量。
下面统计输入中的行数;c 必须是 int 才能同时表示所有字节值与 EOF:
#include <stdio.h>
int main(void)
{
int c;
unsigned long lines = 0;
while ((c = getchar()) != EOF) {
if (c == '\n')
++lines;
}
if (ferror(stdin))
return 1;
printf("%lu\n", lines);
return 0;
}条件中的赋值先更新 c,外层括号再把赋值结果与 EOF 比较,意图完整且编译器可告警检查。反之 if (ready = 0) 把零赋给状态并测试假值,通常是把 == 写错;正确修法是写 ready == 0 并开启 -Wparentheses,而不是依赖 Yoda 条件掩盖问题。
for:continue 先去迭代表达式
for (init; condition; iteration) 先执行一次初始化,然后在每轮前检查条件,执行循环体,再执行迭代表达式。省略条件视为真。它适合把循环控制变量的初始化、边界和进展集中在一处。
continue 在 for 中不是直接回条件,而是先到 iteration;在 while 中直接到头部条件;在 do-while 中到尾部条件。把带 continue 的 for 改成 while 时,若忘了在 continue 前完成更新,就可能制造死循环。
#include <stddef.h>
size_t copy_positive(const int source[], size_t n, int target[])
{
size_t i;
size_t used = 0;
for (i = 0; i < n; ++i) {
if (source[i] <= 0)
continue;
target[used++] = source[i];
}
return used;
}即使触发 continue,++i 仍执行,所以循环取得进展。不变量是 target[0..used) 恰好保存已扫描前缀中的正数,且 used <= i。
无符号边界也属于控制流
循环条件的类型会决定能否终止。for (i = 0; i <= n; ++i) 若 i 与 n 都是无符号类型且 n == UINT_MAX,i 到最大值后回绕为零,条件再次为真,循环永不结束。修法不是机械改成有符号类型,而是定义允许的范围、选择半开区间 i < count,并保证 count 可表示实际元素数量。
逆序遍历更容易出错:for (i = n - 1; i >= 0; --i) 对无符号 i 来说,i >= 0 永远为真;当 n == 0 时,初始化本身也先下溢。可写 for (i = n; i-- > 0; ),此时循环体使用递减后的合法索引;或者在正文中先处理空区间,再采用清楚的正向遍历。无论哪种写法,都要用 n == 0、n == 1 和最大允许长度测试。
计数循环还要区分“最后一个有效索引”和“元素个数”。长度为 n 的数组合法索引是 [0, n),不是 [0, n];把闭区间直觉带进 C 数组会多执行一次并越界。代码审查时把边界画成半开区间,并在每轮证明 i < n 后才访问 a[i]。
输入循环同样有两种退出:正常文件结束与读取错误。getchar() 都以 EOF 结束循环,随后必须检查 ferror(stdin);若只看循环结束,就会把磁盘或终端错误当成成功完成。控制流图应给这两个出口不同返回状态,测试也要能注入错误,而不只是提供空文件。
最后,终止证明必须考虑所有显式转移。某个 continue 是否绕过计数更新,错误重试是否有次数/截止时间,break 是否留下半更新状态,都是循环协议的一部分。只证明正常路径进展,不能证明整个循环会结束。
do-while:正文至少一次
do { body } while (condition); 先执行正文,再在尾部判断,因此至少执行一次;结尾分号属于语法。它适合先展示菜单再读取选择,或先执行一次尝试再决定是否重试。若“零次执行”是合法情况,强行用 do-while 反而需要在正文内部补偿。
设计重试循环时还要写出上限、退避或取消条件。仅凭“最后会成功”作为条件,会让永久错误变成无限循环;对 I/O 要区分暂时不可用、文件结束和不可恢复错误。
break、continue 与 goto 的精确目标
break 只离开最内层循环或 switch;continue 只作用于循环,不作用于 switch。嵌套结构需要离开多层时,可以重构成函数并 return,使用状态标志,或在资源清理场景使用局部 goto。
K&R 第三章明确包含 goto 与标号。它不应模拟普通循环,但在 C 中集中释放按阶段取得的资源可以减少重复错误路径:
#include <stdio.h>
#include <stdlib.h>
int process(const char *path)
{
int status = 1;
FILE *file = fopen(path, "r");
char *buffer = NULL;
if (file == NULL)
goto done;
buffer = (char *)malloc(4096);
if (buffer == NULL)
goto done;
status = fgets(buffer, 4096, file) == NULL && ferror(file);
done:
free(buffer);
if (file != NULL && fclose(file) == EOF)
status = 1;
return status;
}所有路径都落到同一个清理出口,且 free(NULL) 合法。真实函数还应区分空文件与读取成功,并传播更具体错误;示例重点是“只向后跳到清理标签”,不形成任意控制网。
用路径测试而不是只测语句
控制流测试要覆盖边而不只是行:if 的真/假、switch 的每个标签与默认路径、循环零次/一次/多次、break 和 continue、EOF 与错误出口。先预测每条输入经过哪些边,再用覆盖工具核对;100% 行覆盖仍可能漏掉组合条件或循环边界。
对每个循环写三条断言:初始化建立不变量;一次迭代保持不变量;退出条件与不变量推出后置条件。对每个显式跳转确认资源所有权在目标处可判定,不允许某条路径重复释放或遗漏释放。
小结
- 程序块同时承载多条语句和块作用域,花括号比缩进更可靠
switch只匹配一次,随后从标签顺序执行;穿透必须有明确意图while/for可能零次,do-while至少一次;每个循环都要有不变量与进展量continue在三种循环中的目标不同,break只离开最内层循环或switchgoto应限制为清晰的向后清理等少数路径,并通过所有权检查证明出口完整
练习
问题 1 将含 continue 的 for (i = 0; i < n; ++i) 改写为 while。更新 i 应放在哪里,怎样避免 continue 路径死循环?
问题 2 switch 中 case 'a': case 'A': count++; break; 为什么是合法穿透?如果 case 'a' 先修改另一状态再落入 'A',还应做什么?
问题 3 为读取循环列出零次、一次、多次、EOF 与错误路径,并说明为什么只断言最终行数不能证明 ferror 已处理。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 程序块
花括号包围的复合语句,可在需要一条语句的位置承载多步操作,并为内部声明建立块作用域。
- 悬空 else
无花括号嵌套条件中,
else归属最近的未匹配if。缩进不改变语法,应用花括号明确意图。- fall-through
switch跳到某个标签后继续顺序执行后续标签下语句,直到显式转移或结构结束;不会重新匹配每个标签。- 循环不变量
每轮开始和结束都成立的命题,用于证明已处理前缀正确,并与退出条件一起推出最终结果。