控制流

对照 K&R 第三章,以入口、状态转移、进展和出口审查语句块、if/else、switch、while/for/do-while、break/continue 与 goto,并写出可判定的 EOF 循环和集中清理路径。

学习目标

  • 能绘制语句块、分支与三种循环的控制流图,标明每条边的条件、状态变化和退出点
  • 能比较 break、三种循环中的 continuegoto 的精确目标,并判断它们是否破坏进展或清理协议
  • 能回答:为什么 while ((c = getchar()) != EOF) 中的赋值是有意设计,而 if (ready = 0) 通常是缺陷?

从“下一条会执行什么”开始

控制流不是“选一种看起来顺手的语法”,而是对程序计数器下一步去向的约束。阅读每个结构时写下四件事:从哪里进入、条件读取哪些状态、每次迭代如何取得进展、从哪些边退出。没有进展变量或退出契约的循环,即使语法正确也可能永不结束。

C 控制流结构 · 流程图对比菱形 = 判断,矩形 = 执行,箭头 = 流向if-elsecondTstmt1Fstmt2真走 stmt1,假走 stmt2switchexprcase 1case 2default值匹配决定走哪条whilecondTbodyFexit先判断,可能一次都不执行forinitcondTbodyupdateF→exitdo-whilebodycondTFexit先执行,至少跑一次关键区别• while / for:先判断 循环体可能 0 次执行• do-while:先执行 循环体至少 1 次• switch 需 break 防穿透
C 五种控制流结构对比。while/for 先判断后执行(可能 0 次),do-while 先执行后判断(至少 1 次),switch 需用 break 防止 case 穿透。

这张图给出形状直觉,下面的责任表进一步区分入口与转移。forwhile 可以互相改写,但 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 配对,这就是 。缩进不参与 C 语法,代码审查不能用“看起来属于外层”替代花括号。

条件中的副作用与短路

C 允许任意标量表达式作为条件,也允许赋值表达式产生值,所以“条件里有赋值”不自动等于缺陷。关键是赋值是否代表协议的一步:while ((c = getchar()) != EOF) 每轮必须先取得一个输入结果,再据此决定继续;额外括号既满足语法分组,也向告警工具表明赋值是有意的。if (ready = 0) 却把状态覆盖为零,通常没有合理协议含义。

逻辑与 &&、逻辑或 || 提供从左到右的短路保证。index < count && items[index] != 0 先验证边界,失败时绝不访问数组;颠倒顺序就可能在检查前越界。相反,位运算 &| 不短路,不能拿来替换逻辑运算符。测试组合条件时应覆盖“左侧已决定结果”和“必须求右侧”两类路径。

条件还应避免重复读取会变化的状态。若 queue_empty() 可能被其他执行单元改变,连续调用两次不代表同一快照;若 next_token() 会消耗输入,把它放在多个分支条件里会让分支顺序改变数据。先读取一次到局部变量,再基于稳定值分支,控制流与数据流才能一起审查。

长条件可拆成具名谓词,但名字必须表达业务判断而非重复语法。对范围、枚举状态和错误码,列出互斥性与穷尽性:是否可能两个分支同时真,是否存在没有处理的值,默认分支是回退还是错误。这样写测试时能从条件表直接生成边覆盖,而不是碰运气挑几个输入。

switch 是跳转后顺序执行

switch 的控制表达式经过整数提升,case 必须是不同的整数常量表达式;最多一个 default。执行先求控制表达式,再跳到匹配标签或 default,随后像普通语句序列一样向下运行,直到 breakreturngotoswitch 结束。

因此 不是“再次匹配下一个 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) 先执行一次初始化,然后在每轮前检查条件,执行循环体,再执行迭代表达式。省略条件视为真。它适合把循环控制变量的初始化、边界和进展集中在一处。

continuefor 中不是直接回条件,而是先到 iteration;在 while 中直接到头部条件;在 do-while 中到尾部条件。把带 continuefor 改成 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)in 都是无符号类型且 n == UINT_MAXi 到最大值后回绕为零,条件再次为真,循环永不结束。修法不是机械改成有符号类型,而是定义允许的范围、选择半开区间 i < count,并保证 count 可表示实际元素数量。

逆序遍历更容易出错:for (i = n - 1; i >= 0; --i) 对无符号 i 来说,i >= 0 永远为真;当 n == 0 时,初始化本身也先下溢。可写 for (i = n; i-- > 0; ),此时循环体使用递减后的合法索引;或者在正文中先处理空区间,再采用清楚的正向遍历。无论哪种写法,都要用 n == 0n == 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 只离开最内层循环或 switchcontinue 只作用于循环,不作用于 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 的每个标签与默认路径、循环零次/一次/多次、breakcontinue、EOF 与错误出口。先预测每条输入经过哪些边,再用覆盖工具核对;100% 行覆盖仍可能漏掉组合条件或循环边界。

对每个循环写三条断言:初始化建立不变量;一次迭代保持不变量;退出条件与不变量推出后置条件。对每个显式跳转确认资源所有权在目标处可判定,不允许某条路径重复释放或遗漏释放。

小结

  • 程序块同时承载多条语句和块作用域,花括号比缩进更可靠
  • switch 只匹配一次,随后从标签顺序执行;穿透必须有明确意图
  • while/for 可能零次,do-while 至少一次;每个循环都要有不变量与进展量
  • continue 在三种循环中的目标不同,break 只离开最内层循环或 switch
  • goto 应限制为清晰的向后清理等少数路径,并通过所有权检查证明出口完整

练习

问题 1 将含 continuefor (i = 0; i < n; ++i) 改写为 while。更新 i 应放在哪里,怎样避免 continue 路径死循环?

问题 2 switchcase 'a': case 'A': count++; break; 为什么是合法穿透?如果 case 'a' 先修改另一状态再落入 'A',还应做什么?

问题 3 为读取循环列出零次、一次、多次、EOF 与错误路径,并说明为什么只断言最终行数不能证明 ferror 已处理。

名词解释

本章出现的专业名词,用大白话再讲一遍。

程序块

花括号包围的复合语句,可在需要一条语句的位置承载多步操作,并为内部声明建立块作用域。

悬空 else

无花括号嵌套条件中,else 归属最近的未匹配 if。缩进不改变语法,应用花括号明确意图。

fall-through

switch 跳到某个标签后继续顺序执行后续标签下语句,直到显式转移或结构结束;不会重新匹配每个标签。

循环不变量

每轮开始和结束都成立的命题,用于证明已处理前缀正确,并与退出条件一起推出最终结果。

资料与写作方式声明

本章以C 程序设计语言(K&R)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…