第7章:C 控制语句:分支和跳转
循环决定“是否继续重复”,分支决定“当前执行哪条路径”,跳转语句则能提前结束或略过某段处理。
本章可以沿着一条主线学习:先写清条件,再组合条件,最后明确每种控制语句影响的范围。
1. if:满足条件才执行
if (temperature < 0.0) {
++cold_days;
}
条件为非零时执行花括号里的操作,为零时跳过。执行完后,程序继续往后走,不会像循环一样自动返回条件处。
if 后面控制的是一条语句。多个操作要用花括号组成复合语句;缩进不能代替花括号。
if (count > 0) {
double average = total / count;
printf("average: %.2f\n", average);
}
这里先确认分母非零,再进入计算。if 条件不成立时,整个块都不执行。
注意不要在条件后误加分号。if (condition); 控制的是空语句,后面的块可能变成无条件执行。
2. if else 与 else if
2.1 两条路径选择一条
if (count > 0) {
printf("average: %.2f\n", total / count);
} else {
printf("No data.\n");
}
条件为真走第一条路径,为假走第二条路径。对这个结构,每次执行只会选择其中一个分支。
这也比先写 if (count > 0),后面再独立写一个相反条件,更直接地表达互斥关系。
2.2 从多个条件中选择第一个满足的
if (score >= 90) {
printf("A\n");
} else if (score >= 80) {
printf("B\n");
} else if (score >= 60) {
printf("C\n");
} else {
printf("D\n");
}
假定 score 已是合法分数,分支从上往下检查,第一个成立的分支执行后,后面各项不再检查。
到达 score >= 80 时,前面的 score >= 90 已为假,因此这条路径实际覆盖 [80, 90)。
条件排列会影响结果。如果把 score >= 60 放在最前面,高分也会先进入这一分支。
2.3 多个独立 if 可以同时成立
if (score >= 60) {
printf("Passed.\n");
}
if (score >= 90) {
printf("Excellent.\n");
}
分数为 95 时,两条消息都会输出。这适合多个独立条件,不能与只选一条路径的 else if 链混为一谈。
2.4 分段计算要累计此前各段
对于阶梯费用一类纯算法示例,先用阈值选择区间,再计算“已跨过各段的总量 + 当前区间的增量”。
不能一进入较高区间,就把所有用量都乘上这个区间的单价,除非需求明确采用这种规则。
可以把各段阈值、系数与累计基数命名,集中表达规则。边界处还应分别检查“恰好等于阈值”和“略高于阈值”的路径。
3. 嵌套分支与 else 配对
没有明确花括号改变结构时,else 与最近的、尚未配对的 if 结合。
if (enabled) {
if (ready) {
printf("Run.\n");
} else {
printf("Wait.\n");
}
}
这里的 else 属于 if (ready)。若 enabled 为假,整个外层块都跳过,既不输出 Run.,也不输出 Wait.。
如果希望禁用时输出另一条消息,应把分支写在外层:
if (enabled) {
if (ready) {
printf("Run.\n");
}
} else {
printf("Disabled.\n");
}
用花括号明确嵌套结构,能避免缩进表达的意图与实际语法不同。
4. 字符输入输出:getchar 与 putchar
4.1 一个读取,一个输出
int ch = getchar();
if (ch != EOF) {
putchar(ch);
}
getchar 从标准输入读取下一个字符,成功时以 int 返回相应的无符号字符值,输入结束或读取失败时返回 EOF。
所以,保存其返回值应使用 int,先检查 EOF,不要先存入 char 再判断,否则可能丢失区分信息。
putchar 把实参转换为相应的无符号字符值后写向标准输出。两者都由 <stdio.h> 提供接口。
4.2 常见字符读取循环
int ch;
while ((ch = getchar()) != EOF && ch != '\n') {
putchar(ch);
}
赋值两侧的括号不可省略。否则 ch = getchar() != EOF 会按比较后赋值解释,使 ch 得到 0 或 1,而不是读取到的字符。
这里也同时检查输入结束,避免仅等待换行、却在文件结束后永远等不到换行的问题。
5. <ctype.h>:按字符类别处理
5.1 测试函数
| 函数 | 测试的类别 |
|---|---|
isalpha |
字母 |
isdigit |
十进制数字字符 |
isalnum |
字母或数字字符 |
islower、isupper |
小写、大写字母 |
isspace |
空白,包括空格、换行、水平制表等 |
isblank |
空白分隔字符,在 "C" locale 中为空格和水平制表 |
ispunct |
标点类字符 |
isprint |
可打印字符,包含空格 |
isgraph |
可打印的非空格字符 |
iscntrl |
控制字符 |
isxdigit |
十六进制数字字符 |
测试成立返回非零,不成立返回零。不要假定真值一定等于 1,应直接使用 if (isalpha(ch)) 这样的条件。
字符类别可能受 locale 影响。与手写 ASCII 区间相比,这些函数更明确地表达了分类意图,也减少对连续编码的依赖。
5.2 大小写映射返回新值
tolower 返回相应小写形式,toupper 返回相应大写形式;不能映射的值保持原样。
ch = toupper(ch);
只是调用 toupper(ch); 而不使用返回值,不会修改原来的变量。
5.3 参数必须处于允许范围
这些函数的实参必须是 EOF,或能由 unsigned char 表示的值。
如果 ch 直接来自尚未缩窄的 getchar 返回值,按前述方式使用即可。若数据来自一个可能为负的普通 char 对象,应先转换:
if (isalpha((unsigned char)text[index])) {
/* 处理字符 */
}
这类按字节工作的函数不等于完整的 Unicode 文本处理工具,不能直接承担 UTF-8 中文分词等任务。
5.4 一个完整的大小写转换程序
#include <stdio.h>
#include <ctype.h>
int main(void)
{
int ch;
while ((ch = getchar()) != EOF && ch != '#') {
if (islower(ch)) {
ch = toupper(ch);
}
putchar(ch);
}
return 0;
}
输入 Hello, C!#,输出 HELLO, C!。空格和标点保持原样,结束标记 # 不输出。
6. 逻辑运算符:组合完整条件
6.1 与、或、非
| 运算符 | 含义 | 结果为真的条件 |
|---|---|---|
&& |
逻辑与 | 两边都为真 |
|| |
逻辑或 | 至少一边为真 |
! |
逻辑非 | 操作数为假 |
这三个运算符的结果都是 int 类型的 0 或 1。操作数本身则按零与非零解释真假。
if (score >= 0 && score <= 100) {
printf("In range.\n");
}
&& 与 || 不能用按位运算符 &、| 替代;按位运算既有不同的数据含义,也不提供相同的短路规则。
6.2 逻辑真值对照
| A | B | A 与 B | A 或 B |
|---|---|---|---|
| 假 | 假 | 假 | 假 |
| 假 | 真 | 假 | 真 |
| 真 | 假 | 假 | 真 |
| 真 | 真 | 真 | 真 |
!A 则把真假反转。通过逻辑非,可以把“在单词内”写成 in_word,把“不在单词内”写成 !in_word。
6.3 范围测试不能照搬数学连写
0 <= score <= 100 不表示分数在零到一百之间。它按 (0 <= score) <= 100 解释,前一项只有 0 或 1,因而这个例子总会成立。
正确形式是:
score >= 0 && score <= 100
判断不在范围内,则可写:
score < 0 || score > 100
6.4 每一项都要写完整
判断字符既不是小写 q,也不是大写 Q:
ch != 'q' && ch != 'Q'
不能写 ch != 'q' && != 'Q',因为后半项缺少操作数。
也不能把这里的 && 改为 ||。一个字符至少会不同于这两个字符中的一个,所以两个“不等于”用或连接会恒真。
判断“是其中任意一个”则使用 ch == 'q' || ch == 'Q'。
7. 短路求值与优先级
7.1 && 与 || 都先求值左边
| 形式 | 何时不求值右边 |
|---|---|
left && right |
左边为假,结果已确定为假 |
left || right |
左边为真,结果已确定为真 |
左侧完成后,如果还需要右侧,才会继续求值。这既是顺序保证,也是条件保护手段。
if (divisor != 0 && 10 / divisor > 1) {
printf("Matched.\n");
}
divisor 为零时,不执行右侧除法。
数组检查也可以先验证边界,再访问元素:
if (index >= 0 && index < count && values[index] > 0) {
printf("Positive value.\n");
}
这里假定 index、count 为相应的有符号整数,且 count 正确描述数组范围。检查必须放在访问之前。
7.2 短路会影响副作用是否发生
int count = 0;
int result = 0 && ++count;
result 为 0,count 仍为 0,因为右侧根本不执行。
所以,不应把每轮必须执行的更新操作随意放在可能被短路的分支里。简短的表达式也要能够清楚解释执行路径。
7.3 本章涉及的优先级关系
一元 !
高于 算术运算
高于 < <= > >=
高于 == !=
高于 &&
高于 ||
高于 条件运算符 ?:
高于 赋值运算符
这里仅展示相关层级,不是完整运算符表。对复杂逻辑,可以用括号明确分组,而不必强迫读者记住每个层级。
尤其要区分 !a == b 与 !(a == b):前者比较的是 !a 与 b,后者才是否定相等判断。
7.4 备选拼写
在本章的 C99/C11 环境中,可以包含 <iso646.h>,使用 and、or、not 对应 &&、||、!。
它们在 C 中由头文件提供宏形式的替代名称。常见代码仍以符号写法为主,阅读时知道这种对应即可。
8. 嵌套判断与标志变量:识别素数
对于大于一的整数,如果有一个非平凡因数,就一定能配成两个因数,其中至少一个不超过平方根。因此,无须一直试除到原数减一。
bool is_prime(unsigned long value)
{
if (value < 2) {
return false;
}
for (unsigned long divisor = 2;
divisor <= value / divisor;
++divisor) {
if (value % divisor == 0) {
return false;
}
}
return true;
}
使用时包含 <stdbool.h>,并在调用前提供原型或完整定义。
这个版本先排除 0、1,发现因数便直接返回。divisor <= value / divisor 避免在条件中计算 divisor * divisor,从而避免平方可能超出整数范围的问题。
如果任务是列出所有因数,而不是只判断素数,就不能在找到第一个因数后立即返回。可以用布尔标志记录是否找到,并继续检查剩余候选项。
列因数时还应处理完全平方数:两个配对因数相等时,只显示一次。
9. 条件运算符 ?:
9.1 根据条件产生一个值
int larger = (first > second) ? first : second;
基本形式:
条件 ? 条件为真时的表达式 : 条件为假时的表达式
条件求值后,只执行两个候选分支中的一个。它适合表达简短的值选择,不适合把复杂操作层层嵌进去。
候选分支的类型也参与结果类型的确定,尤其要避免无意混合有符号与无符号整数。
9.2 商与余数组合成向上取整
对非负 items 与正整数 capacity,所需组数可以写成:
int groups = items / capacity;
groups += (items % capacity != 0) ? 1 : 0;
例如,items 为 11、capacity 为 5,需要三组;恰好十件时,需要两组。
这个写法先求商和余数,不必先计算可能溢出的 items + capacity - 1。仍需保证输入满足约定,尤其不能把零作为除数。
还可以直接在输出中选择文本:
printf("%d %s\n", groups, groups == 1 ? "group" : "groups");
10. continue:略过本轮剩余步骤
for (int number = 1; number <= 5; ++number) {
if (number == 3) {
continue;
}
printf("%d\n", number);
}
输出 1、2、4、5。它没有结束循环,只是跳过第三轮剩余部分。
10.1 不同循环中的下一步
| 所在循环 | continue 后的执行位置 |
|---|---|
while |
回到循环条件 |
do while |
进入尾部条件判断 |
for |
先执行更新部分,再判断条件 |
如果 while 的更新写在循环体末尾,而 continue 提前跳过了更新,就可能反复遇到相同状态。这也是把 for 直接改写为 while 时必须检查的区别。
10.2 用来筛掉无效项目
while (scanf("%d", &score) == 1) {
if (score < 0 || score > 100) {
printf("Skipped.\n");
continue;
}
total += score;
++accepted;
}
合法处理留在较浅的缩进层级,非法输入则提前跳过。这里非法指已经读到整数、但不符合分数范围;不能把它当作解决所有文本解析错误的代码。
11. break:结束最近的循环或 switch
for (int number = 1; number <= 5; ++number) {
if (number == 3) {
break;
}
printf("%d\n", number);
}
输出 1、2,然后直接执行循环后面的语句。与 continue 不同,for 的更新部分也被跳过。
| 语句 | 影响 |
|---|---|
continue |
跳过本轮剩余部分,按循环规则继续 |
break |
结束最近一层循环或 switch |
return |
结束当前函数,必要时返回值 |
嵌套循环中的一个 break 只结束最近的内层循环。若想结束整个函数,可以用 return;如果外层仍有工作需要执行,则应选择标志变量等合适结构。
12. switch:按离散整数值选择入口
12.1 基本结构
switch (command) {
case 'a':
printf("Add.\n");
break;
case 'd':
printf("Delete.\n");
break;
default:
printf("Unknown.\n");
break;
}
程序对控制表达式求值,跳到匹配的标签,再从那里向后执行。无匹配时,存在 default 就进入它,否则直接离开。
关键限制:
- 控制表达式必须是整数类型,可包括字符和枚举值。
case必须使用整型常量表达式,不能依赖普通运行时变量。- 同一
switch中转换后相同的标签值不能重复。 default可省略,同一个switch最多一个。
const int choice = 1; 不会在 C 中自动使 choice 成为可用的 case 整型常量表达式。宏展开为合适整数常量、或使用枚举常量,是不同的情况。
12.2 标签不会自动形成互斥块
switch (level) {
case 2:
printf("Advanced.\n");
/* 有意继续执行下一个分支 */
case 1:
printf("Basic.\n");
break;
default:
printf("None.\n");
break;
}
level 为 2 时,两条消息都会输出。因为标签只是入口,程序不会在下一条 case 前自动停止。
如果只是漏写 break,就可能误执行后面的路径;有意贯穿时,应明确说明理由。
12.3 多个标签共用处理
switch (ch) {
case 'y':
case 'Y':
printf("Yes.\n");
break;
case 'n':
case 'N':
printf("No.\n");
break;
default:
printf("Unknown.\n");
break;
}
也可以在分类前使用 tolower 或 toupper,减少重复标签。
12.4 与循环嵌套时的范围
当 switch 放在循环里时,其中的 break 结束的是 switch,不是外层循环。
continue 不能独立用于没有循环包围的 switch。若外部确实有循环,它跳向最近外层循环的继续位置,而不是下一个 case。
12.5 如何选择 switch 与 if
| 场景 | 更直接的选择 |
|---|---|
| 按命令字符、整数编号或枚举值选择 | switch |
| 判断大小范围、浮点条件 | if else |
| 组合多个不同变量的逻辑条件 | if else |
| 从两种简单结果中选一个值 | ?: |
标准 C99/C11 的 case 不直接表达连续数值区间,也不能用字符串内容作为标签。不要把选择结构的用途差异简单等同于某一种一定运行更快。
13. 状态标志:统计单词与行
13.1 先定义“单词”
这里把单词定义为连续的非空白字节序列。多个空格、制表符或换行只是分隔;标点若没有被空白分开,仍属于同一个单词。
因此,这个程序是简单的空白分词器,不承担语言学意义的中文分词。
13.2 识别状态变化
当前不在单词内
├─ 空白 → 保持原状态
└─ 非空白 → 单词数加一,进入单词
当前在单词内
├─ 非空白 → 继续当前单词
└─ 空白 → 离开单词
计数发生在进入单词的时候,而不是每遇到一个非空白字符就增加。这样连续多个分隔符不会产生空单词。
13.3 完整程序
#include <stdio.h>
#include <ctype.h>
#include <stdbool.h>
int main(void)
{
int ch;
int previous = '\n';
size_t bytes = 0;
size_t words = 0;
size_t lines = 0;
bool in_word = false;
while ((ch = getchar()) != EOF && ch != '#') {
++bytes;
if (ch == '\n') {
++lines;
}
if (isspace(ch)) {
in_word = false;
} else if (!in_word) {
in_word = true;
++words;
}
previous = ch;
}
size_t partial_line = (previous != '\n') ? 1 : 0;
printf("bytes: %zu, words: %zu, lines: %zu, partial: %zu\n",
bytes, words, lines, partial_line);
return 0;
}
输入 one two 后换行,再输入 three#,结果为:
bytes: 14, words: 3, lines: 1, partial: 1
其中,两个空格和一个换行都计入字节总数;# 是停止标记,不计数。最后一段内容没有换行,因此记为一条不完整行。
初始 previous = '\n' 使空输入不会被误认为有不完整行。若最后一个有效字符就是换行,partial 为零。
14. 菜单输入:只处理每行首字符
#include <stdio.h>
#include <ctype.h>
int main(void)
{
int command;
printf("Commands: h=help, r=run, q=quit\n");
while ((command = getchar()) != EOF) {
if (command == '\n') {
continue;
}
int rest;
while ((rest = getchar()) != '\n' && rest != EOF) {
/* 丢弃本行首字符之后的内容 */
}
switch (tolower(command)) {
case 'h':
printf("Help shown.\n");
break;
case 'r':
printf("Run selected.\n");
break;
case 'q':
printf("Bye.\n");
return 0;
default:
printf("Unknown command.\n");
break;
}
printf("Next command:\n");
}
return 0;
}
每行第一个字符被当作命令,后面的内容被消费。输入 run 只会处理 r,不会把 u 和 n 留到下一轮。
这个界面忽略空行,但不自动跳过行首空格;需要怎样解释空白,应由交互约定决定。
内层丢弃循环同时检查换行和 EOF,所以最后一行没有换行也能结束。switch 中的 break 结束选择结构,之后仍会输出下一次提示;q 分支通过 return 结束整个程序。
15. goto:同一函数内跳到标签
goto finished;
/* 中间的其他处理 */
finished:
printf("Finished.\n");
标签由名称和冒号组成,必须位于同一个函数中。goto 可以跳到前面或后面的标签,但不能跨函数跳转。
大多数普通选择和重复任务,用 if、循环、break、continue 更容易看清结构。goto 不应替代这些基本结构,制造在各处来回跳转的路径。
某些需要一次离开多层循环、集中进入同一个结束处理位置的场景,可以考虑它。使用时要保证标签位置清楚,也不能跳过必要准备,之后却继续使用尚未获得有效值的数据。
如果任务只是结束当前函数,优先考虑直接 return;如果只结束最近一层循环,break 已能明确表达意图。

