第 6 章:控制流程——程序的'红绿灯'与'跑步机'
26 分钟阅读
第 6 章:控制流程——程序的"红绿灯"与"跑步机"
想象一下,你早上出门上班,脑子里其实一直在做各种决定:
- “如果下雨,我就打车;否则,我骑自行车。"(选择)
- “只要还没到公司,我就一直往前走。"(循环)
- “先做早饭,再刷牙,再出门。"(顺序执行)
恭喜你,你已经在日常生活中使用"控制流程"了!程序也是一样的,只不过它更死板——你得一条一条告诉它该怎么做。
控制流程(Control Flow),就是控制程序执行顺序的机制。没有它,程序就像一本按顺序印满文字的书,从第一页读到最后一页,一成不变。加上它之后,程序就能"见机行事”——下雨打伞,天晴晒被,根据不同情况走不同的路。
在 C 语言里,控制流程主要有三大类:
- 选择结构(Selection):遇到岔路口,该走哪条路?
- 循环结构(Loop):重复做某件事,直到满足条件
- 跳转语句(Jump):突然改变执行路线
下面我们就来逐一揭晓!
6.1 选择结构:if / if...else / if...else if...else
6.1.1 最简单的 if:有条件地执行
if 是最基础的选择结构,意思是"如果……就……"。语法如下:
| |
条件表达式是一个返回真(true,非零)或假(false,零)的表达式。括号里的条件成立,就执行花括号里的代码;不成立,就跳过,程序继续往下走。
举个例子——判断一个人是否成年:
| |
运行结果:
已成年,可以考驾照了!
程序继续往下走...
如果 age 改成 16,那么第一个 printf 就不会执行,屏幕上只有"程序继续往下走…"。
小贴士: 花括号
{}在只有一条语句时可以省略,但建议初学者始终加上花括号,这样代码更清晰,也不容易出错。就像你给自己定规矩"如果下雨就打伞”,但如果同时写了"如果下雨就打伞,然后买彩票”,那到底哪句该执行?花括号就是用来消除歧义的。
6.1.2 if...else:二者必选其一
有的时候,事情不是"做不做"的问题,而是"二选一"的问题——下雨就打车,不下雨就骑车。这时候就用 if...else:
| |
一个完整的例子:
| |
score = 75 >= 60,条件为真,执行上面的分支。如果把 score 改成 55,就会输出"不及格… 加油补考!"
6.1.3 if...else if...else:多岔路口
生活远比二元对立复杂。比如考试成绩:
- 90 分以上:优秀
- 70-89 分:良好
- 60-69 分:及格
- 60 分以下:不及格
这就需要 if...else if...else 了:
| |
流程图如下:
flowchart TD
A[开始判断成绩] --> B{score >= 90?}
B -->|是| C[输出: 优秀]
B -->|否| D{score >= 70?}
D -->|是| E[输出: 良好]
D -->|否| F{score >= 60?}
F -->|是| G[输出: 及格]
F -->|否| H[输出: 不及格]
C --> I[结束]
E --> I
G --> I
H --> I注意:
else if可以有任意多个,else放在最后(也可以没有)。条件判断是从上到下依次进行的,一旦遇到一个满足的条件,就执行对应代码块,然后跳出整个 if…else if…else 结构,不再继续判断后面的条件。所以写条件时要注意顺序——比如把score >= 60放在score >= 70前面,就会先匹配到"及格"而不是"良好"。
6.1.4 三目运算符:? : —— if…else 的精简版
对于简单的二选一赋值,C 语言还提供了一个语法糖——三目运算符(Ternary Operator),格式为:
| |
意思是:如果条件为真,整个表达式取值1;否则取值2。例如:
| |
const char* 是指向常量字符串的指针(后面讲指针时会详细介绍)。三目运算符可以嵌套,但不建议嵌套太深,否则代码会变得像"幽灵"一样难以理解。
6.1.5 常见的条件判断
回顾一下常用的比较运算符(Relational Operators):
| 运算符 | 含义 | 示例 |
|---|---|---|
== | 等于 | age == 18 |
!= | 不等于 | score != 0 |
> | 大于 | price > 100 |
< | 小于 | age < 60 |
>= | 大于等于 | height >= 180 |
<= | 小于等于 | weight <= 80 |
以及逻辑运算符(Logical Operators):
| 运算符 | 含义 | 示例 |
|---|---|---|
&& | 逻辑与(AND) | age > 18 && age < 30 |
|| | 逻辑或(OR) | grade == 'A' || grade == 'B' |
! | 逻辑非(NOT) | !is_empty |
一个综合示例——判断是否能够购买火车票(年龄在 14-70 岁之间):
| |
6.2 嵌套 if 的 else 匹配规则——“就近原则”
if 里面还可以放 if,这就叫嵌套 if(Nested if)。但 else 到底跟哪个 if 配对,是一个容易出错的地方。
先看一个"惊悚"的例子——判断两个数的大小关系:
| |
猜猜输出什么?程序判断 a == 10 为真,然后进入内层 if,a > b(10 > 20)为假,所以执行 else,输出"a < b or a == b"。这个程序的逻辑其实是:当 a == 10 时,进一步判断 a 和 b 的大小。
关键问题来了:else 到底属于外层的 if (a == 10) 还是内层的 if (a > b)?
答案是:else 永远匹配距离它最近的、尚未配对的 if。这就是传说中的**“最近邻原则”**(Closest Matching Rule),也叫"悬空 else"(Dangling else)问题。
用缩进(Indent)可以更直观地理解:
| |
但如果你想让 else 属于外层 if(即 a 不等于 10 时走 else),就必须用花括号显式地界定内层 if 的范围:
| |
血泪教训: 初学者经常因为省略花括号导致
else匹配错对象,程序逻辑完全变样。建议:始终使用花括号,不要偷懒。代码写的时候省事了,调试的时候就哭了。
再来看一个更复杂的例子——成绩等级判断,用嵌套 if 实现:
| |
这个嵌套结构完全可以用 if...else if...else 更优雅地写出来(如 6.1.3 节所示),也更不容易出错。嵌套 if 适合用在需要在某个外层条件满足的前提下再进行细分的场景。
6.3 switch 语句——多路分支的"选择器"
6.3.1 基本用法
if...else if...else 虽然好用,但当你需要比较一个变量与多个离散值时(比如根据菜单选项执行不同操作),switch 语句会更加清晰。
| |
switch 括号里的表达式必须是整数类型、字符类型或枚举类型(Enumeration,后面的章节会讲到)。case 后面跟的必须是常量表达式,不能是变量。
一个经典的例子——根据星期几输出不同的活动:
| |
运行结果:
今天是周三,加油!
6.3.2 break 的重要性——穿透效应
注意上面代码中,case 6 后面没有 break,直接接着 case 7。这意味着当 day == 6 时,程序匹配到 case 6:,然后"穿透"(Fall-through)到 case 7:,执行其中的代码。
这是一个有意为之的设计——周六和周日执行相同的代码,所以让它们"穿透"到一块儿。但如果不小心漏写了 break,就会引发"意外穿透"的 bug:
| |
运行结果:
良好
虽然 score = 85 应该匹配 default(因为没有 case 85),但由于 case 90 缺少 break,程序"穿透"到了 case 80,输出了"良好"——完全错误!
划重点: 每个
case末尾一般都要加break,除非你故意想利用穿透效应。养成习惯,忘记break是新手最常犯的错误之一。
6.3.3 default 分支
default 类似于 if...else 结构里的最终 else——当所有 case 都不匹配时执行。default 位置可以随意,可以放在开头、中间或结尾,不影响逻辑(但习惯上放在最后)。
| |
switch 只能比较整数、字符(char,本质上是整数)和枚举,不能比较浮点数(比如 double)或字符串。如果需要比较浮点数或进行范围判断,请使用 if...else。
6.3.4 switch 的流程图
flowchart TD
A[switch 开始] --> B{表达式 == case1?}
B -->|是| C[执行 case1 代码]
C --> F{有 break?}
B -->|否| G{表达式 == case2?}
G -->|是| H[执行 case2 代码]
H --> F
G -->|否| I{...}
I --> J{都不匹配?}
J -->|是| K[执行 default 代码]
J -->|否| L[不执行任何分支]
K --> M[switch 结束]
L --> M
F -->|是| M
F -->|否| M2[穿透到下一个 case]
M2 --> G6.4 [[fallthrough]] 属性(C17)——给"穿透"贴个标签
既然穿透效应既有用又危险,C17 标准特意引入了一个新属性 [[fallthrough]],放在 case 的最后(break 之前或直接替代它),用来显式声明"我这是故意的"。
| |
输出:
周六,想睡懒觉
周日,也是周末!
如果不写 [[fallthrough]],在支持 C17 的编译器里(比如 GCC 7+、Clang 5+),使用 -Wimplicit-fallthrough 警告选项时,编译器会报警告:“嗨,你这里可能漏写了 break!“加上 [[fallthrough]] 就是告诉编译器:“我知道我在干什么,别警告我。”
兼容性提示:
[[fallthrough]]是 C11 引入的属性语法([[...]]),C17 才正式支持。如果你的代码需要兼容旧编译器,可能需要用注释(如/* fall through \*/)或编译器特定的宏来达到类似效果。
6.5 while 循环——“只要……就一直……”
while 循环是最直观的循环结构,意思是”只要条件满足,就一直做下去"。
| |
每次循环开始前,先判断条件是否为真;如果为真,就执行循环体一遍;然后回到开头重新判断条件……如此反复,直到条件变为假,循环结束。
举个例子——打印 1 到 5:
| |
输出:
当前数字: 1
当前数字: 2
当前数字: 3
当前数字: 4
当前数字: 5
i++ 是自增运算符,相当于 i = i + 1,每轮循环把计数器加 1,迟早会达到退出条件。如果忘了写 i++(或任何能让条件最终变假的操作),程序就会死循环——一直跑,停不下来,电脑风扇狂转,直到你手动强制终止(Ctrl+C)。
while 循环的流程图
flowchart TD
A[开始] --> B{i <= 5?}
B -->|是| C[执行循环体<br/>打印 i]
C --> D[i++]
D --> B
B -->|否| E[退出循环]
E --> F[结束]一个实用的例子——计算 1+2+3+…+100
| |
6.6 do...while 循环——“先斩后奏”
while 循环的特点是:先判断条件,再执行循环体。这意味着如果一开始条件就不满足,循环体一次都不执行。
但有时候,你需要至少执行一次再判断条件——比如菜单程序,至少要让用户看到菜单,用户才能选择退出还是继续。这种场景就用 do...while:
| |
| |
输出:
当前数字: 1
当前数字: 2
当前数字: 3
当前数字: 4
当前数字: 5
while vs do...while 的区别:
| |
注意语法细节:
do...while末尾的分号不能省!写成了while (条件)而没有分号,编译器会报错。千万别忘了这个坑。
6.7 for 循环——循环中的"瑞士军刀”
6.7.1 标准 for 循环
for 循环是 C 语言中最强大、最灵活的循环结构,格式如下:
| |
三个部分用分号隔开:
- 初始化(Initialization):在循环开始前执行一次,通常用于声明和初始化计数器变量
- 条件判断(Condition):每次循环开始前判断,为真则继续,为假则退出
- 更新(Update):每次循环体执行完后执行,通常用于更新计数器
用 for 循环打印 1 到 5:
| |
输出:
当前数字: 1
当前数字: 2
当前数字: 3
当前数字: 4
当前数字: 5
执行顺序:
flowchart TD
A[初始化: i=1] --> B{条件: i<=5?}
B -->|是| C[执行循环体<br/>打印 i]
C --> D[更新: i++]
D --> B
B -->|否| E[退出循环]for 循环的三个部分都可以省略(甚至全部省略),但两个分号 ;; 必须保留:
| |
6.7.2 C99 特性:在 for 循环的初始化处声明变量
从 C99 标准开始,for 循环的初始化部分可以直接声明变量(之前只能在循环外部或用旧式 C 声明):
| |
编译器选项: GCC 和 Clang 默认支持 C11 及以上(
-std=c17可以指定 C17 标准)。如果你发现声明变量报错,可能需要确认编译时使用了正确的标准(如gcc -std=c99 main.c)。
6.7.3 复合字面量与 for 循环的妙用
for 循环的一个常见模式——累加求和:
| |
6.8 嵌套循环——循环里面有循环
循环体内可以再放一个循环,这就叫嵌套循环(Nested Loop)。外层循环每执行一次,内层循环就要从头到尾跑完一轮。
8.8.1 打印九九乘法表
嵌套循环最经典的例子——九九乘法表:
| |
输出:
1×1= 1
1×2= 2 2×2= 4
1×3= 3 2×3= 6 3×3= 9
1×4= 4 2×4= 8 3×4=12 4×4=16
1×5= 5 2×5=10 3×5=15 4×5=20 5×5=25
1×6= 6 2×6=12 3×6=18 4×6=24 5×6=30 6×6=36
1×7= 7 2×7=14 3×7=21 4×7=28 5×7=35 6×7=42 7×7=49
1×8= 8 2×8=16 3×8=24 4×8=32 5×8=40 6×8=48 7×8=56 8×8=64
1×9= 9 2×9=18 3×9=27 4×9=36 5×9=45 6×9=54 7×9=63 8×9=72 9×9=81
8.8.2 打印星号三角形
| |
输出(一个直角三角形):
*
***
*****
*******
*********
8.8.3 二维数组的遍历
嵌套循环在处理二维数组(Matrix)时特别有用——外层循环遍历行,内层循环遍历列:
| |
输出:
1 2 3 4
5 6 7 8
9 10 11 12
性能提示: 嵌套循环的复杂度是 O(n×m)。如果内外层循环的边界相关联(比如在内层用外层变量作为边界),特别容易出错。写代码时可以在草稿纸上先画个表格模拟一下执行过程,心里会更有底。
6.9 break / continue / goto——程序里的"紧急逃生通道"
6.9.1 break——直接逃出循环
break(中断)的作用是立即终止所在的循环(while、for、do...while 或 switch),程序跳到循环体之外继续执行。
| |
输出:
当前数字: 1
当前数字: 2
当前数字: 3
当前数字: 4
遇到5,不玩了,直接退出!
循环外的代码,继续执行...
当 i == 5 时,break 把整个 for 循环终止了,后面的 6-10 都没机会展示了。
在嵌套循环中,break 只会跳出最内层的循环,不会一次性跳出所有循环。如果需要从深层嵌套中一次性退出,通常要借助标签(Label)和 goto(后文会讲),或者在外层循环设置标志位。
| |
输出:
i=1, j=1
i=2, j=1
i=3, j=1
6.9.2 continue——跳过本轮,进入下一轮
continue(继续)的作用是跳过本次循环的剩余部分,直接进入下一次循环的判断。它不会终止整个循环,只是"这一次不想干了,下一个"。
| |
输出:
当前数字: 1
当前数字: 2
跳过3!
当前数字: 4
当前数字: 5
i == 3 时,continue 导致 printf("当前数字: %d\n", i) 被跳过,但循环本身没有终止,i 继续变成 4、5。
6.9.3 break vs continue 对比
| 语句 | 作用 | 比喻 |
|---|---|---|
break | 终止整个循环 | 老板说"今天就到这里,下班!" |
continue | 跳过本次,进入下次 | 老板说"这个客户不用跟了,下一个!" |
| |
6.9.4 goto——“传送门”⚠️
goto 是 C 语言中最"原始"的跳转语句,语法很简单:
| |
goto 会让程序无条件地跳转到指定标签处继续执行,像一个传送门一样无视一切顺序。
goto 的"正确用法"——错误处理
goto 在现代 C 代码中唯一被广泛接受的场景是错误处理(Error Handling)——当发生错误时,跳过后续清理工作,直接跳到统一的错误退出路径。这在资源管理场景下很有用:
| |
在这个例子里,malloc 或 fopen 失败时,不需要重复写两次 free 和 fclose——直接 goto cleanup 统一处理。
为什么要避免滥用
goto?想象一下,如果你的代码里到处都是
goto,你可以从任何一个地方跳到任何一个标签——就像在一栋大楼里,可以从厨房直接瞬移到地下室,再瞬移到屋顶。这种"随意跳转"会让代码的执行路径变得极其难以理解,一旦出问题,调试起来简直是噩梦。这就是为什么goto在 1968 年就被 Edsger Dijkstra(没错,就是那个发明最短路径算法的 Dijkstra)公开批判,标题就是《‘goto 语句有害’》(“Go To Statement Considered Harmful”)。总的原则:
goto只用于单一方向的向前跳转(跳到函数末尾的错误处理块),不要用goto创建循环或向后跳转。在嵌入式开发中(资源极度受限的场景),goto用于错误处理也是可以接受的。但如果不是在写底层系统代码或嵌入式程序,通常有更好的替代方案(设置标志位、外层循环判断等)。
6.10 死循环:while(1) vs for(;;)
死循环(Infinite Loop)是指循环条件永远为真,永远不会自动退出的循环。有时候这是故意的(比如游戏主循环、服务器事件循环),有时候是手滑写出来的 bug。
6.10.1 两种写法
| |
这两种写法在汇编层面几乎完全一致,都是"跳回循环起始位置"。while(1) 语义更清晰——“只要1为真(即永远为真)就继续”;for(;;) 更简洁——“初始化为空、条件为空、更新为空,那我就跑到天荒地老”。
6.10.2 有意的死循环——事件驱动
一个典型的死循环应用——模拟简单的交互菜单:
| |
6.10.3 死循环的危害
一旦死循环里没有 break/return/exit,程序就会卡死。对于命令行程序,可以通过 Ctrl+C(SIGINT 信号)强制终止;对于 GUI 程序,通常会无响应;对于嵌入式设备(没有操作系统干预),只能重启——所以在嵌入式开发中,死循环一定要确保有可靠的退出机制。
6.11 C23:[[ likely ]] / [[ unlikely ]]——分支预测提示
这是 C23 标准(C 语言的最新版本,于 2023 年发布)引入的一个新特性,[[likely]] 和 [[unlikely]] 是属性声明(Attribute),用来给编译器提供分支预测(Branch Prediction)的提示。
6.11.1 什么是分支预测?
现代 CPU 为了追求高性能,会投机执行(Speculative Execution)——在分支条件还没判断出来之前,CPU 就先"猜"一个分支提前执行。如果猜对了,皆大欢喜,性能提升;如果猜错了,就得"回滚",浪费一些时钟周期。
编译器也在做类似的优化——如果它知道某个 if 条件"通常为真"或"通常为假",就可以把"更可能执行的分支"的代码安排在更"热"(更容易被缓存命中)的位置。
6.11.2 怎么用?
在 if 或 switch 语句前加上 [[likely]] 或 [[unlikely]]:
| |
6.11.3 实际影响
[[likely]] 和 [[unlikely]] 是编译提示(Hint),不会改变程序的语义(逻辑结果永远不变),但编译器可以利用这些提示来优化生成的机器码:
[[likely]]:告诉编译器"这个分支大概率会被执行",编译器会把它的代码放在更"热"的位置,减少指令缓存(I-Cache)未命中的概率[[unlikely]]:告诉编译器"这个分支大概率不会被执行",编译器会把它的代码放在不太影响性能的位置
在高性能场景(如网络协议栈、操作系统内核、游戏引擎、物理引擎)下,这种优化可能带来显著的性能提升。但在普通应用代码里,这种优化带来的收益可能微乎其微。
兼容性提示:
[[likely]]和[[unlikely]]是 C23 标准的新特性。截至目前(2025年),只有最新版本的 GCC(14+)、Clang(16+)和 MSVC(19.35+)部分支持。如果你需要编写可移植的代码,应该用条件编译(#ifdef __STDC_VERSION__)包裹这些用法,或者干脆等主流编译器都稳定支持后再使用。
本章小结
本章我们学习了 C 语言中最核心的控制流程语句:
| 类别 | 语句 | 作用 |
|---|---|---|
| 选择结构 | if | 如果条件为真则执行 |
if...else | 二选一执行 | |
if...else if...else | 多选一执行 | |
switch | 基于整数/字符/枚举的多路分支 | |
? : | 三目运算符,二选一赋值 | |
| 循环结构 | while | 先判断条件再执行(可能一次都不执行) |
do...while | 先执行再判断条件(至少执行一次) | |
for | 最灵活的循环,C99 起可在初始化处声明变量 | |
| 跳转语句 | break | 跳出当前循环或 switch |
continue | 跳过本次循环,进入下一次 | |
goto | 无条件跳转(慎用,仅推荐用于错误处理) | |
| 特殊语法 | [[fallthrough]](C17) | 显式标记 switch 的有意穿透 |
[[likely]] / [[unlikely]](C23) | 分支预测提示,优化性能 |
核心要点:
if...else链按顺序从上到下匹配,一旦匹配成功就跳出else遵循"最近匹配"原则,嵌套if时务必用{}明确作用域switch中break不可忘,除非有意穿透;只能比较整数/字符/枚举while先判断后执行,do...while先执行后判断for循环的三个部分均可省略,但分号必须保留;C99 起支持在初始化处声明变量break跳出最内层循环,continue跳过本次循环goto慎用,只推荐用于错误处理的统一清理路径- 死循环
while(1)和for(;;)等价,需要确保有退出机制 [[likely]]/[[unlikely]]是 C23 新特性,用于分支预测优化
控制流程是程序的"交通系统"——选择结构是"十字路口",循环是"环形公路",跳转语句是"传送门和捷径"。掌握好这些,你就能写出"能思考、会判断、懂循环"的智能程序了!