一段批处理程序收到四类任务:普通任务要执行,暂停任务要跳过,非法任务要报告错误,终止任务要立刻结束。代码即使能编译,也可能把暂停任务算进结果,可能在 switch 中意外贯穿,还可能因为一次错误丢掉后续任务。
这些问题都指向同一个问题:下一条会执行哪条语句?
读控制流时,可以一直问三件事:当前是哪条语句,执行完会去哪里,离开当前位置时哪些名字会离开作用域。顺序执行只是默认路线;分支会选择入口,循环会形成回边,跳转与异常会提前改道。
文中的完整程序按 C++17 编写,可以用下面的命令编译:
g++ -std=c++17 -Wall -Wextra -Wpedantic control_flow.cpp -o control_flow也可以把 g++ 换成 clang++。警告不能证明程序正确,但空循环体、可疑缩进和意外贯穿常常会先被它发现。

C++ 函数体由语句组成。没有分支、循环或跳转时,语句按出现顺序执行。表达式负责算值,语句负责让计算发生,并把控制交给下一条语句。
常见语句包括声明、表达式语句、复合语句、选择语句、迭代语句、跳转语句和 try 语句。先认清边界,再判断它属于哪一类。
表达式后加分号,就得到表达式语句:
count += 1;
std::cout << count << '\n';
run_task();它们分别修改对象、输出字符、调用函数。下面这条也合法,但算出的值被直接丢弃,通常意味着遗漏了赋值:
count + 1;声明同样是一条语句。执行走到这里时,名字被引入当前作用域,对象完成初始化:
int count{0};单独的分号则是空语句。它什么也不做,却能占据“这里需要一条语句”的位置:
if (ready);
{
start_job();
}这里的 if 控制分号,后面的块无条件执行。缩进不会改变语法。
看到 if (...) ;、while (...) ; 或 for (...) ; 时,先检查它是不是手误。那个分号已经构成完整分支体或循环体。
一对大括号包围的语句序列叫复合语句,也叫块。块本身是一条语句,所以能放进 if、循环或其他只接收一条语句的位置。
块还建立局部作用域:
#include <iostream>
int main() {
int result{4};
{
int result{10};
result += 2;
std::cout << "inner: " << result << '\n';
}
std::cout << "outer: " << result << '\n';
输出是:
inner: 12
outer: 4内层 result 从声明点起可见,到内层块末尾离开作用域。它遮蔽外层名字,却没有修改外层对象。遮蔽虽然合法,但会增加状态追踪难度;实际代码应尽量使用不同名字,并把变量放在最小作用域。
if 适合范围、关系和组合条件。它只按你写出的条件选路,不会自动检查分类是否完整。
假设状态码只允许 1、0 和 -1。下面的写法会把 99 也当成“停止”:
if (status == 1) {
std::cout << "running\n";
} else {
std::cout << "stopped\n";
}完整分类应给非法值留出路径:
if (status == 1) {
std::cout << "running\n";
} else if (status == 0) {
std::cout << "paused\n";
} else if (status == -1) {
std::cout << "stopped\n";
} else {
else if 本质上是在前一个 else 中再放一个 if。条件从上到下检查,第一条命中后,其余条件不会再求值。
#include <iostream>
#include <string_view>
std::string_view priority_name(int score) {
if (score < 0 || score > 100) {
return "invalid";
}
if (score >= 90) {
return "urgent";
}
if (score >= 60) {
return
输出是:
95: urgent
72: normal
40: deferred
120: invalid如果先判断 score >= 60,95 会过早进入 normal,后面的严格条件将不可达。这里用早返回先处理边界,也避免主路径不断增加缩进。
C++17 允许在 if 中先初始化,再判断条件:
if (const auto colon = text.find(':'); colon != std::string::npos) {
const auto key = text.substr(0, colon);
use(key);
} else {
report_missing_separator(colon);
}
// colon 在这里不可见初始化只执行一次。colon 在条件和两个分支中都可见,整个 if 结束后离开作用域。
没有大括号时,else 匹配最近且尚未配对的 if:
if (authenticated)
if (is_admin)
open_admin_panel();
else
open_user_panel();这里的 else 属于 if (is_admin)。始终写大括号,能让结构直接可见,也能避免后来新增语句时改变路径。

当一个整数、字符或枚举值要与一组离散常量比较时,switch 往往比很长的相等比较链更直接。
#include <iostream>
int main() {
const char command{'r'};
switch (command) {
case 's':
std::cout << "start\n";
break;
case 'r':
std::cout << "retry\n";
break;
case 'q':
std
输出是:
retry控制先跳到匹配标签,再顺序向下执行。break 结束最近的 switch。没有匹配项时从 default 进入;若没有 default,整个结构不执行分支动作。
选择值必须是整数、字符或枚举类型,不能直接用 std::string。每个 case 必须是互不重复的常量表达式。多个标签可以共享一组动作:
case 'y':
case 'Y':
confirm();
break;case 不会自动停止。若没有 break、return 或 throw,程序会继续执行后面的语句,后续标签也不会重新比较。
switch (level) {
case 3:
std::cout << "admin\n";
[[fallthrough]];
case 2:
std::cout << "write\n";
[[fallthrough]];
case 1:
std::cout << "read\n";
break;
level == 2 时会依次输出 write 和 read。[[fallthrough]]; 是 C++17 属性,表示省略 break 是明确意图。
整个 switch 主体是一个块,case 只是标签。分支需要局部对象时,应给它单独加块:
case 'p': {
const int pending{pending_count()};
std::cout << pending << '\n';
break;
}这既限制名字的可见范围,也避免控制跳到后续标签时绕过前面对象的初始化。
隐式贯穿是常见错误。除非路径确实需要共享后续动作,否则让每个分支明确以 break、return 或 throw 结束。
下面的模拟器把分支判断过程逐步展开。可以修改分数、命令和 break 设置,观察 if 命中的第一个区间、switch 的入口及贯穿路径;悬挂 else 实验则用相同布尔输入对比大括号前后的归属。
读任何循环,都先找四个位置:初始化、继续条件、循环体、状态推进。缺少其中任何一项,都可能造成少一次、多一次或无法终止。
while 每轮先检查条件,循环体可能一次也不执行:
int attempts{0};
while (attempts < 3 && !connected()) {
connect_once();
++attempts;
}它适合“次数由运行状态决定”的任务。控制变量要先初始化,每条继续路径也要让状态朝终止方向变化。
读取本身也可以放进条件:
int value{0};
while (std::cin >> value) {
use(value);
}读取成功才进入循环;输入失败或到达末尾时退出。
for (int i = 0; i < 4; ++i) {
std::cout << i << ' ';
}执行顺序是:初始化一次,检查条件,执行循环体,执行更新,再回到条件。i 的作用域覆盖整个 for,循环结束后不可见。
不含 continue 的简单 for 可以这样理解:
{
int i = 0;
while (i < 4) {
std::cout << i << ' ';
++i;
}
}序列下标通常使用半开区间 [0, size),因此条件是 i < size。写成 i <= size 会多走一次并访问末尾之后的位置。
for (const int value : values) {
std::cout << value << '\n';
}这里的 value 是副本。要修改原元素才写引用:
for (int& value : values) {
value *= 2;
}空序列会让范围 for 执行零次。它减少手写下标错误,却没有取消边界测试的必要。
int choice{0};
do {
show_menu();
std::cin >> choice;
} while (choice < 1 || choice > 3);do-while 先执行循环体,再决定是否继续,适合“菜单至少显示一次”之类的场景。末尾分号属于语法,不能漏掉。

在 for 中,continue 先抵达更新表达式,再检查条件。在 while 中,它直接回到条件;如果更新写在循环体末尾,就可能被跳过:
int i{0};
while (i < 5) {
if (i == 2) {
continue; // i 不再变化
}
++i;
}这段代码会停在 i == 2。修复时要保证每条继续路径都会推进状态,而不是只把 ++i 放在“正常路径”末尾。
测试循环时至少覆盖零次、一次、多次、空序列、输入失败,以及第一轮和中间轮触发 break / continue 的情况。
下面的追踪器可以切换 while、传统 for、范围 for 和 do-while,再分别设置 continue 与 break 的触发值。逐步运行时要特别观察“循环体之后”究竟先抵达更新表达式还是条件检查;若 while 的推进语句被 continue 跳过,工具会标出无法终止的路径。
跳转语句都会改变默认的“下一条”,差别在于跳出多大的结构。
嵌套循环中的 break 只结束最近一层:
for (int row = 0; row < 2; ++row) {
for (int column = 0; column < 3; ++column) {
if (column == 1) {
break;
}
std::cout << row << ',' << column << '\n'
输出 0,0 和 1,0。内层循环提前结束,外层仍进入下一轮。
continue 只跳过本轮剩余语句:
for (const int value : values) {
if (value < 0) {
continue;
}
sum += value;
}负数不参与累加,后续元素仍会处理。continue 只控制循环,不控制 switch。
bool submit(int task_id) {
if (task_id <= 0) {
return false;
}
if (!service_available()) {
return false;
}
send(task_id);
return true;
}每个 return 都结束 submit,不是只结束最近的 if。这种守卫式早返回适合先排除不能继续的情况。无返回值函数可以写 return;。
若要从多层循环中找到目标后立即结束搜索,把搜索拆成函数并 return,通常比维护额外标志更清楚。
goto 跳到同一函数中的普通标签:
retry:
if (!try_once()) {
goto retry;
}它不能随意跳入一个需要先完成局部对象初始化的区域。循环、函数拆分、break、continue 和 return 已能表达绝大多数路线,并把入口、出口限制在可见结构中。goto 会迫使读者跨越嵌套寻找下一位置,所以常规代码应避免它。
有些函数能发现“当前操作无法完成”,却不知道调用者希望重试、跳过还是终止。异常把检测点和处理点分开,并让失败沿调用关系抵达合适的处理边界。
#include <limits>
#include <stdexcept>
int checked_divide(int dividend, int divisor) {
if (divisor == 0) {
throw std::invalid_argument{"division by zero"};
}
if (dividend == std::numeric_limits<int>::min() && divisor == -1) {
整数除法需要在执行 / 之前排除两种非法路径:除数为 0;被除数是 int 的最小值且除数为 -1。后一种运算的数学结果比 int 的最大值大 1,会发生有符号整数溢出;不能先计算再检查结果。这里分别抛出 std::invalid_argument 和 std::overflow_error,让调用者能从异常类型和消息中区分失败原因。任一检查命中时,return 都不会执行,运行时会向外寻找匹配的 catch。
#include <iostream>
#include <limits>
#include <stdexcept>
#include <utility>
for (const auto [dividend, divisor] : {
std::pair{12, 3},
std::pair{12, 0},
std::pair{std::numeric_limits<int>::min(), -1},
std::pair{12
这段循环会得到:
12 / 3 = 4
skip 12 / 0: division by zero
skip -2147483648 / -1: integer division overflow
12 / 4 = 3try 位于循环内部,因此每轮都有自己的错误边界。第二轮和第三轮分别进入处理器后,循环仍能开始下一轮。如果 try 包住整个循环,一次异常就会离开整个循环。这里用 std::exception 的引用同时接住两种标准异常;需要采用不同恢复策略时,可以改为两个按具体类型排列的处理器。
处理器结束后,控制从整组 try / catch 之后继续,不会回到 throw 的下一条语句。一直找不到匹配处理器时,程序会终止。

try {
run_job();
} catch (const std::invalid_argument& error) {
std::cerr << "bad request: " << error.what() << '\n';
} catch (const std::exception& error) {
std::cerr << "job failed: " << error.what() << '
更具体的类型应放在更一般的 std::exception 前面。catch (...) 能兜底未知类型,却没有具体错误信息。
抛出异常会离开沿途作用域;局部对象的清理与异常安全需要更完整的资源管理知识。这里先掌握语句路线:检测、抛出、向外查找、匹配、处理、决定后续位置。
普通且高频的业务选择通常用 if 或返回状态更清楚;当前层发现操作无法履行约定、又不能决定如何恢复时,异常更合适。
控制结构一多,就不要只从第一行扫到最后一行。用固定顺序标记路径,能让遗漏更快暴露。
if 有真、假路径,switch 还有每个标签与未匹配路径。break、continue、return 和 throw,写明它们各自抵达的位置。#include <iostream>
#include <stdexcept>
#include <vector>
enum class Action { run, skip, fail, stop };
int execute(int task_id) {
if (task_id <= 0) {
throw std::invalid_argument{"task id must be positive"};
}
return task_id * 10;
输出是:
task 0: result=10
task 1: skipped
task 2: error=task id must be positive
task 3: result=40
task 4: stop batch
completed=2最后一个 run 没有执行,因为前一项用 break 结束了循环。失败项没有增加 completed,因为 execute 在返回前抛出,控制直接进入处理器。
还应补两个边界用例:空序列让循环执行零次;第一项就是 stop 时,计数应保持 0。编译器警告无法证明这些业务路径正确,所以必须把输入、预期路径和可观察结果一起写进测试。
动手练习:把失败策略改成“遇到 fail 就结束整个函数”,分别尝试在处理器中 return,以及把 try 放到循环外。前者直接结束函数;后者离开循环后仍会执行整组处理器之后的语句。两种路径的后续位置不同,测试也应不同。