Skip to content

4. C++ 的流程控制

By FunnyAWM

如果我们要编写一个能够适配大部分场合的程序,就需要对这些不同的场合做不同的适配。我们不可能为每个场合都单独编写一个程序,恰好相反,我们希望程序有决策能力,根据用户的不同选择来决定执行不同的操作。例如我们之前说过的可选配置文件,我们希望在这个配置文件存在时才读取,不存在则不读取也不使用。

程序内部难免需要执行各种重复操作。例如我们需要计算一个数的平方根,怎么做呢?我们可以使用牛顿法。牛顿法需要多次迭代才能得到相对精准的平方根结果,用人力计算是相当麻烦的。能否用计算机实现这些重复操作呢?

接下来,我们来讲解在 C++ 中如何实现上述的操作。

1. 判断

1. if 语句

在 C++ 中,我们可以使用 if 来选择执行代码。if 语句的语法如下:

cpp
if (condition) {
    // 条件满足时执行这些代码
}

在上面的代码中,condition 表示我们希望程序验证的条件。如果条件满足,那么 if 块中的代码便会被执行,否则就会跳过这些代码。例如,我们编写下面的程序,让用户输入两个数,如果第一个数大于第二个数,则输出第二个数:

cpp
#include <iostream>
using std::cin;
using std::cout;
using std::endl;

int main() {
    int a;
    int b;
    cin >> a;
    cin >> b;
    if (a > b) {
        cout << b << endl;
    }
}

这里的 a > b 是一个条件表达式,代表如果 a 的值大于 b 则执行 if 下被大括号括起来的语句。

条件表达式:描述了一个命题,其计算结果只能为 true 或 false。

2. else if 与 else

else if 在 if 后面附加,表示如果某个条件不满足,则继续向下对比,直到满足条件或遇到 else 后仍不满足条件为止。

else 一般用于判断语句的最后,表示当与其关联的所有 if 语句条件不满足时,判断该块执行的条件是否满足。

if 与 else if 块可以形成判断链,方法是将这些块连起来编写。if 与 else if 块的链接规则如下:

  • 一个 if 后可以有多个 else if 语句,且不强制用 else 结尾,这些 else if 与 else(如果有)会 自动链接;
  • 所有的 else if 语句都要有与其链接的 if 语句,且不强制用 else 结尾;
  • 所有的 else 语句都要有与其链接的 if 语句,且这个 else 语句要保证在所有的 if 与 else if 后;
  • 如果在 if else if 串中有某个部分满足了这个块的条件,那么将直接执行这个块,并跳过该串其余的条件判断;
  • 两个 if 语句永远不会链接,它们独立存在。

我们用下面的几个代码片段进行说明:

cpp
x = 90;
if (x > 90) {
    cout << x; // 不会执行,开始判断下一个else if
} else if (x > 80) {
    cout << "x > 80"; // 条件满足,会输出x > 80
} else {
    cout << "x < 80"; // 由于上一个else if块条件已满足,
    // 并且这个else与上面的if关联,所以也不会执行
    // 如果这里的语句是else if,也不会执行,原因同上
    // 如果else if块的条件也不满足,那么这个块一定会被执行
}
cpp
y = 100;
if (y > 90) {
    cout << "y > 90"; // 条件满足,会输出y > 90
}
if (y > 80) {
    cout
        << "y > 80"; // 这个if语句和上一个if无关,并且条件满足,所以也会执行
}

if、else if 与 else 也支持嵌套。

3. switch—— 不是任天堂那个掌机啊

多个 if else 的值匹配逻辑可以用 switch 来简化。例如:

cpp
int i = 5;
if (i == 1) {
    cout << "Hello!\n";
} else if (i == 2) {
    cout << "Bye!\n";
} else {
    cout << "How are you?\n";
}

这样的逻辑虽然易于编写,而且看起来易于扩展,但是有一个性能缺陷 —— 如果 i 不属于 1 和 2 的任意一个值,那么每次对 i 判断都要运行 3 次这样的逻辑运算。如果只是 3 次的话不会特别影响程序性能,那么 10000 次呢?100000 次呢?大量这样的无意义运算会严重拖慢程序的运行速度(你猜你的 GTA 为什么加载要 10 多分钟我们可以用 switch 来简化逻辑,提高运行效率:

cpp
int i = 5;
switch (i) {
case 1:
    cout << "Hello!\n";
    break; // 用于跳出当前switch语句
case 2:
    cout << "Bye!\n";
    break; // 同上
default:
    cout << "How are you?\n";
}

这样的好处显而易见:程序只需要一次判断就能够确定到底应该执行哪个分支的代码。缺点是 switch 的应用场景有限。switch 几乎只能用于值匹配,这是因为 case 后只能是字面常量,无法指定表达式。例如我们不能在 case 后指定 i > 5:编译器会认为这是语法错误。

如果在 switch 的每个 case 后没有加 break,那么 switch 语句会继续执行下面所有 case 的代码,直到遇到 break 或 switch 语句结尾。这样的现象被称作 switch 语句的向下击穿。如果我们不希望 switch 语句一直向下执行,需要在每个 case 的结尾手动添加一个 break。在 C++17 及之后的标准中,可以通过 [[fallthrough]] 来告诉编译器向下击穿(不加入标识会导致警告

cpp
int i = 5;
switch (i) {
case 1:
    cout << "Hello!\n";
    [[fallthrough]]; // 这个标记会保证switch会向下击穿
case 2:
    cout << "Bye!\n";
default:
    cout << "How are you?\n";
}

4. 三元运算符 ——?:强强:?

C++ 中存在另一个运算符可以用于条件判断,这个运算符叫三元运算符,写作?:。我们可以通过下面的代码使用它:

cpp
int x = 20;
int y = 30;
int z = x > y ? x : y; // 返回x与y中的较大者。

?:的语义如下: 判断? 前的条件。如果条件满足,执行: 左边的操作,否则执行右边的操作。

5. 逻辑运算符

逻辑运算符通常用于进行逻辑运算,是实现逻辑判断的核心工具。我们不妨回想一下在程序中,我们通常要用到什么条件运算。常见的条件运算有以下几种:

  • 与(AND当用与连接的两个条件都成立时返回 true,在 C++ 中用 && 表示;
  • 或(OR当用或连接的两个条件有任意一个为 true 时返回 true,在 C++ 中用||表示。
  • 非(NOT反转当前条件的判断结果,在 C++ 中用! 表示。

其中非的优先级高于与,而与的优先级高于或。也就是说如果表达式中存在非时,除非使用括号改变,否则优先计算非运算结果,其次是与运算,最后是或运算。

这些运算符可以进行组合来实现复杂的判断逻辑。在组合这些运算符时,程序会从左到右进行逻辑计算。如果我们不希望使用默认顺序,可以用括号指定优先计算的表达式。

括号可以嵌套,优先级从内到外。例如下面的几个条件表达式:

cpp
int x = 100;
int y = 200;
int z = 300;
x > y || y < z; // 虽然x > y不成立,但y < z成立,返回true
x > y &&y < z;  // x > y不成立,返回false
!(x > y) // x > y返回false,但!运算符反转了结果,返回true
    x < y &&y < z &&x < z; // 三个条件同时满足,返回true
x > z ||
    (x < y &&
     (y <
      z)); // 先判断y <
           // z,然后判断外层括号的与条件,再与最前面的表达式做或运算,返回true
x > z || x < y &&y < z; // 先计算与条件,用与条件的结果计算或条件,返回true

需要注意的是,如果或运算左边的表达式成立,将不会计算右边的表达式而直接返回 true,反之亦然。在与运算中,与运算左边的表达式不成立也会导致与运算跳过右边的计算直接返回 false,称为逻辑运算符的短路现象。

在实际程序设计中,尽量不要使用 x < y < z 的形式,应该使用 x < y & y < z 的形式。这是因为判断运算符在没有优先级影响时也是从左到右计算的,x < y 的值会被转换成 int,只有 0 与 1 两种可能。判断 x < y < z 与判断 0 < z 或 1 < z 相同,故不推荐使用。

2. 循环 —— 妮可妮可妮可妮可…… 妮可妮可妮?

在计算机中,我们通常需要重复执行一些代码。例如,我们需要计算 1-1000 内 3 与 7 的公倍数,从 1 写到 1000 通常显得很蠢,而且如果我们需要修改其中的任何一个部分,就需要修改 1000 次,也十分不优雅。为了解决这种问题,C++ 提供了循环来容纳需要重复运行的代码。

循环有 3 种形式:for、while、do...while。接下来我们逐个说明。

1. for 循环

for 循环的语法如下:

cpp
for ([起始设定], [更新条件], [更新语句]) {
    [循环体];
}

我们可以在起始设定中设置一些循环开始时执行的操作。更新条件在执行完一次循环体后计算,如果条件满足,则不再执行循环体,直接退出循环;否则执行更新语句后继续执行循环体。下面的语句提供了计算 1-1000 内 3 与 7 的公倍数的方法:

cpp
for (int i = 1; i <= 1000; i++) {
    if (i % 21 == 0) {
        cout << i << " ";
    }
    // 由于3与7互质,3与7的公倍数一定是3*7的倍数
}

我们还可以这样优化程序:

cpp
int result;
for (int i = 1; result < 1000; i++) {
    result = 21 * i;
    if (result < 1000) { // 排除边缘值影响
        cout << result << " ";
    }
}

优化后的版本直接跳过了无效值(即确定为不是公倍数的值提高了运行效率。这样的优化在一些需要极限运行效率的场合(例如算法赛中)非常有用,在某些场景下,可以通过这样的推导直接算出目标值,在算法赛中称作 “筛选是数论问题解法的一种。

筛法:通过数字之间的一些规律筛除掉一定不符合条件的数字,进而减少无用操作的方式。这种方法在算法赛中的应用非常广泛。例如在埃拉托尼斯筛法(简称埃筛)中,我们通过筛选掉所有素数的倍数来缩小素数的范围(这些倍数至少有素数和乘数两个因子,并且小素数都相当易于计算同时通过筛选出偶数等方法进一步缩小范围(除 0 和 2 以外的正偶数一定是合数从而快速计算出指定范围的素数表。

2. while 与 do...while

while 语句的语法如下:

cpp
while (condition) {
    // 循环中要执行的操作
}

与 for 不同,while 在一次循环执行结束后只判断括号中的条件是否成立,成立则继续运行,否则退出。上面我们用 for 循环编写的代码能够改写成下面的代码:

cpp
int i = 0;
int result = 0;
while (result < 1000) {
    ++i;
    result = 21 * i;
    if (result < 1000) { // 排除边缘值影响
        cout << result << " ";
    }
}

do...while 会在最后检查条件,如果成立则继续运行。例如:

cpp
int i = 0;
int result = 0;
do {
    ++i;
    result = 21 * i;
    if (result < 1000) { // 排除边缘值影响
        cout << result << " ";
    }
} while (result < 1000);

while 和 do...while 有什么区别呢?区别在于 do...while 会保证至少执行一次循环。while 的逻辑是先判断条件,满足时运行;do...while 则是先运行,再判断条件决定是否再运行循环。

3. 循环控制符与死循环

在循环中,我们能够通过 break 与 continue 控制循环行为。

break 用于直接跳出当前循环,例如:

cpp
for (int i = 1; i <= 1000; i++) {
    if (i % 21 == 0) {
        cout << i << " ";
    }
    break; // 循环只会输出一个值
           // 由于3与7互质,3与7的公倍数一定是3*7的倍数
}

continue 用于直接跳过本次循环剩余代码,进入下一次迭代。例如:

cpp
for (int i = 1; i <= 1000; i++) {
    if (i % 21 == 0) {
        continue; // 下面的输出语句将永远不会被执行,因为continue直接让循环跳到了判断部分
        cout << i << " ";
    }
    // 由于3与7互质,3与7的公倍数一定是3*7的倍数
}

死循环是指那些没有外部干预的情况下永远不会结束的循环。在大部分场景下,程序因死循环占用全部 CPU 资源,导致界面冻结或服务超时。但是在特定场景下,我们需要依靠死循环来实现部分功能特性。例如:

  • 异步事件接收器中的事件循环;
  • 程序对外部数据或设备的轮询;
  • 嵌入式硬件中的主函数(例如在 STM32 与 Arduino 程序中,主函数必须是一个死循环,否则程序退出会导致硬件无人接管,必须给微控制器重新上电

4. 自增与自减 ——C++、C--

在上面循环的例子中,我们大量使用了 ++ 与 -- 运算符。这两个运算符的作用是将目前操作的值 + 1 或 - 1,但 ++ 与 -- 放在变量前与变量后时,执行 + 1 或 - 1 的时机不同。

如果自增或自减语句的下一语句使用了该变量,分为两种情况:

  • ++ 或 -- 在前(前置自增会先刷新,然后直接使用刷新后的值;
  • ++ 或 -- 在后(后置自增会先使用变量在刷新前的值,再刷新变量值。

假设我们有下面的代码:

cpp
int a = 5;
int b = ++a; // a=6, b=6

int x = 5;
int y = x++; // y=5, x=6

注释中说明了在执行自增或自减操作时值的变化情况。

如果自增或自减语句的下一语句没有使用变量,则两种写法没有任何差别。

出于代码可理解性考虑,强烈不建议在同一个地方多次使用自增或自减运算,并且这样的行为在一些组织或企业的代码规范中是被严格禁止的。

DLC:Java 中的 switch

从 Java15 开始,switch 支持直接从 case 分支中返回指定值。例如:

java
int i = 5;
String str = switch (i) {
 case 1 -> "Hello!";
 case 2 -> "Bye!";
 default -> "How are you?";
}
System.out.println(str);