8 控制流与错误处理
8.1 控制流语句的类别
| 类别 | 样例 | c++中的实现方式 |
|---|---|---|
| 条件 | 条件满足时,才执行对应语句 | if, else, switch |
| 跳转 | cpu,从其它地方开始执行 | goto, break, continue |
| 函数调用 | 跳到其它地方执行,处理完成后返回 | function calls, return |
| 循环 | 满足条件的情况下,重复执行一段代码0次或多次 | while, do-while, for, ranged-for |
| 停止 | 停止程序执行 | std::exit(), std::abort() |
| 异常 | 错误处理的一种特殊形式 | try, throw, catch |
8.2 If语句与代码块
第一类控制流语句是条件语句。条件语句是指定,根据一个判定条件,来确定是否应执行某些关联语句的语句。
8.2.1 If语句
示例:
#include <iostream>
namespace constants
{
constexpr int minRideHeightCM { 140 };
}
int main()
{
std::cout << "Enter your height (in cm): ";
int x{};
std::cin >> x;
if (x >= constants::minRideHeightCM)
std::cout << "You are tall enough to ride.\n";
else
{ // 这里使用代码块
std::cout << "You are not tall enough to ride.\n";
std::cout << "Too bad!\n";
}
return 0;
}
8.2.2 If语句的隐式块
如果没有在if else语句里使用代码块,编译器将隐式声明块。因此:
if (条件表达式)
true_对应的语句;
else
false_对应的语句;
实际上相当于:
if (条件表达式)
{
true_对应的语句;
}
else
{
false_对应的语句;
}
8.3 常见的if语句问题
8.3.1 嵌套的if语句和悬空的else问题
else语句与同一块中最后一个未匹配的if语句成对。
为了在嵌套if语句时避免歧义,最好将内部if语句显式封装在代码块中。这允许我们在if语句中附加else,而不会产生歧义。
8.3.2 展平嵌套if语句
嵌套的if语句通常可以通过重新构造逻辑展平。嵌套较少的代码不太容易出错。
8.3.3 空语句
空语句是仅由分号组成的表达式语句。空语句不执行任何操作。它们通常在语言需要存在语句但程序员不需要时使用。为了可读性,空语句通常放在自己单独的行中。
8.4 Constexpr if语句
通常,if语句的条件在运行时求值。
然而,考虑条件是常量表达式的情况,例如在下面的示例中:
#include <iostream>
int main()
{
constexpr double gravity{ 9.8 };
// 提醒: 低精度的浮点数常量可以直接比较是否相等
if (gravity == 9.8) // 常量表达式,结果永远为true
std::cout << "Gravity is normal.\n"; // 永远会执行
else
std::cout << "We are not on Earth.\n"; // 不会执行到
return 0;
}
由于gravity是constexpr,并用值9.8初始化,因此条件gravity==9.8的计算结果必须为true。因此,else语句将永远不会执行。
在运行时计算constexpr条件表达式是浪费的(因为结果永远不会变化)。将代码编译为永远无法执行的可执行文件也是浪费的。
8.4.1 Constexpr if语句(C++17)
C++17引入了constexpr if语句,该语句要求条件是常量表达式。将在编译时计算constexpr if语句的条件表达式的结果。
如果constexpr条件的计算结果为true,则整个If-else将替换为true语句。如果constexpr条件的计算结果为false,则整个If-else将替换为false语句(如果存在else)或直接删除(如果没有else)。
要使用if constexpr语句,如下:
#include <iostream>
int main()
{
constexpr double gravity{ 9.8 };
if constexpr (gravity == 9.8) // 现在使用 constexpr if
std::cout << "Gravity is normal.\n";
else
std::cout << "We are not on Earth.\n";
return 0;
}
编译上述代码时,编译器将在编译时计算条件表达式,确认它始终为true,并仅保留单个语句std::cout « “Gravity is normal.\n”;。
换句话说,它将编译以下内容:
int main()
{
std::cout << "Gravity is normal.\n";
return 0;
}
当条件表达式是常量表达式时,优先使用if constexpr语句。
出于优化目的,现代编译器通常将具有常量条件的非constexpr-if语句视为constexpr-if语句。然而,这并不意味着一定会这样做。遇到具有常量条件的非constexpr-if语句,编译器可能会发出警告,建议您改用constexpr-if语句。这将确保发生编译时计算(即使禁用编译优化)。
8.5 Switch语句基础
尽管可以将许多if-else语句链接在一起,但这既难以阅读,又效率低下。
根据一组不同的值测试变量或表达式很常见的,因此C++提供了另一种称为switch语句的条件语句,该语句专门用于此目的:
#include <iostream>
void printDigitName(int x)
{
switch (x)
{
case 1:
std::cout << "One";
return;
case 2:
std::cout << "Two";
return;
case 3:
std::cout << "Three";
return;
default:
std::cout << "Unknown";
return;
}
}
int main()
{
printDigitName(2);
std::cout << '\n';
return 0;
}
switch语句背后的思想很简单:计算表达式(有时称为条件)以产生值。如果表达式的值等于任何case标签之后的值,则执行匹配的case标签后面的语句。如果找不到匹配的值并且存在默认标签,则改为执行默认标签之后的语句。
与原始的if语句相比,switch语句的优点是只对表达式求值一次(使其更有效率),switch语句还使读者更清楚地看到,在每个情况下,都有哪些对应的处理语句。
8.5.1 使用switch语句
我们通过使用switch关键字来启动switch语句,后跟括号,其中包含要在其中求值的条件表达式。表达式通常只是单个变量,但它可以是任何有效的表达式。
一个限制是,条件必须求值为整数类型或枚举类型,或者可以转换为一个整数或枚举值。此处不能使用计算为浮点类型、字符串和大多数其他非整型的表达式。
在条件表达式之后,声明了一个代码块。在块内部,我们使用标签来定义要测试相等性的所有值。有两种标签。
8.5.2 case标签
第一种标签是case标签,它使用case关键字声明,后跟常量表达式。常量表达式必须与条件表达式的类型匹配,或者必须可转换为该类型。
如果条件表达式的值等于case标签后的表达式,则从该case标签之后的第一条语句开始执行,然后按顺序继续。
对于可以具有的case标签的数量没有实际限制,但switch中的所有case标签都必须是唯一的。
8.5.3 default标签
第二种标签是default标签(通常称为默认情况),它是使用default关键字声明的。如果条件表达式与任何case标签都不匹配,并且存在default标签,则从default标签之后的第一条语句开始执行。
default标签是可选的,每个switch语句只能有一个default标签。按照惯例,default情况放在switch块中的最后一个。
8.5.4 没有匹配的case标签,也没有default标签
如果条件表达式的值与任何case标签都不匹配,并且没有default标签,则不会执行switch内的任何语句。
8.5.5 使用break关键字
break语句(使用break关键字声明)告诉编译器,我们已经完成了在switch中执行语句的操作,需要执行switch后的下一条语句。这允许我们在不退出整个函数的情况下退出switch语句。
switch语句中标签下的每一组语句都应该以break语句或return语句结尾。
8.6 switch fallthrough机制与作用域
8.6.1 Fallthrough机制
当switch表达式与case标签或可选的默认标签匹配时,执行从匹配标签之后的第一条语句开始。然后继续按顺序执行,直到发生以下终止条件之一:
- switch代码块结束
- 另一个控制流语句(通常是break或return)导致退出代码块或函数。
- 其它打断程序正常控制流的事情(操作系统杀死了对应的进程等其它原因)
请注意,另一个case标签的存在不是这些终止条件之一——因此,如果没有中断或返回,执行将溢出到后续的case情况中。
一旦case或默认标签下的语句开始执行,它们将溢出(fallthrough)到后续的case中。Break或return语句通常用于防止这种情况。
8.6.2 [[fallthrough]]属性
可以通过注释,来告诉其它开发人员,switch的fallthrough行为是有意设计的。虽然这对其他开发人员有效,但编译器和代码分析工具不知道如何解释注释,因此不会消除警告。
为了帮助解决这个问题,C++17添加了一个名为[[fallthrough]]的新属性。
属性是一种现代C++功能,它允许程序员向编译器提供有关代码的一些附加数据。要指定属性,请将属性名称放在双括号之间。属性不是语句——相反,它们几乎可以在上下文相关的任何地方使用。
下面的例子中,[[fallthrough]]属性修改null语句,以指示fallthrough是有意的(不应触发任何警告):
#include <iostream>
int main()
{
switch (2)
{
case 1:
std::cout << 1 << '\n';
break;
case 2:
std::cout << 2 << '\n'; // 这里开始执行
[[fallthrough]]; // 有意的进行 fallthrough -- 注意这里的分号代表空语句
case 3:
std::cout << 3 << '\n'; // 这里也会执行到
break;
}
return 0;
}
8.6.3 连续case标签
您可以使用逻辑OR运算符将多个测试组合到单个语句中:
bool isVowel(char c)
{
return (c=='a' || c=='e' || c=='i' || c=='o' || c=='u' ||
c=='A' || c=='E' || c=='I' || c=='O' || c=='U');
}
这里c被多次求值。
通过按顺序放置多个case标签,可以使用switch语句执行类似的操作:
bool isVowel(char c)
{
switch (c)
{
case 'a': // if c is 'a'
case 'e': // or if c is 'e'
case 'i': // or if c is 'i'
case 'o': // or if c is 'o'
case 'u': // or if c is 'u'
case 'A': // or if c is 'A'
case 'E': // or if c is 'E'
case 'I': // or if c is 'I'
case 'O': // or if c is 'O'
case 'U': // or if c is 'U'
return true;
default:
return false;
}
}
请记住,执行从匹配的case标签之后的第一条语句开始。case标签不是语句(它们是标签),因此它们不算数。
上面程序中所有case语句之后的第一个语句都返回true,因此如果任何case标签匹配,函数将返回true。
因此,我们可以“堆叠”case标签,以使所有这些case标签在之后共享相同的语句集。这不被认为是fallthrough行为,因此这里不需要使用注释或[[fallthrough]]。
8.6.4 switch语句中case的作用域
使用if语句,在if条件之后只能有一条语句,并且该语句被认为隐式地位于代码块内。然而,对于switch语句,标签后的语句都作用于switch块。不会创建隐式块。
switch (1)
{
case 1: // 不会创建隐式块
foo(); // 在switch的作用域内,而不是case 1内
break; // 在switch的作用域内,而不是case 1内
default:
std::cout << "default case\n";
break;
}
在上面的示例中,case 1 标签和default标签之间的2条语句的作用域是switch块的一部分,而不是case 1 隐含的代码块。
8.6.5 case语句中的变量声明和初始化
您可以在case标签之前和之后声明或定义(但不能初始化)switch语句内的变量:
switch (1)
{
int a; // okay: case标签之前可以声明变量
int b{ 5 }; // 不合法: case 标签之前,不可以初始化变量
case 1:
int y; // okay 但不推荐
y = 4; // okay: 赋值语句可以
break;
case 2:
int z{ 4 }; // 不合法: 后面还有case标签,不允许初始化变量
y = 5; // okay: y 在上面声明,所以这里可以赋值
break;
case 3:
break;
}
在 case 1 中定义了变量y,在 case 2 中使用了它。switch内的所有语句都被视为同一作用域的一部分。因此,在 case 1 内声明或定义的变量可以在以后使用。
然而,变量的初始化,需要在运行时执行(因为需要将初始值设置给变量)。如果后续还有case标签,则不允许初始化变量(因为初始化可能被跳过,这将使变量未初始化)。在第一个case标签之前不允许初始化,因为这些语句永远不会执行,switch语句无法指定到它们。
如果case标签内需要定义和初始化新变量,最佳实践是在case语句下的显式块内进行定义和初始化:
switch (1)
{
case 1:
{ // 这里有一个显示的代码块
int x{ 4 }; // okay, 变量在一个新的代码块内初始化
std::cout << x;
break;
}
default:
std::cout << "default case\n";
break;
}
8.7 goto语句
下一种控制流语句是无条件跳转。无条件跳转会导致执行跳转到代码中的另一个位置。术语“无条件”意味着跳转总是发生(不像if语句或switch语句,跳转仅根据表达式的结果有条件地发生)。
在C++中,无条件跳转是通过goto语句实现的,跳转到的位置是通过使用语句标签来标识的。下面是goto语句和语句标签的示例:
#include <iostream>
#include <cmath> // 引用 sqrt() 函数
int main()
{
double x{};
tryAgain: // 这是标签语句
std::cout << "Enter a non-negative number: ";
std::cin >> x;
if (x < 0.0)
goto tryAgain; // 这是goto语句
std::cout << "The square root of " << x << " is " << std::sqrt(x) << '\n';
return 0;
}
在此程序中,要求用户输入一个非负数。如果输入负数,程序将使用goto语句跳回tryAgain标签。然后再次要求用户输入新数字。这样,我们可以不断地要求用户输入,直到输入有效的内容。
8.7.1 标签语句具有函数作用域
在前面,我们讨论了两种作用域:局部(代码块)作用域和文件(全局)作用域。标签语句使用第三种作用域:函数作用域,这意味着标签在整个函数中都是可见的,甚至在其声明点之前也是可见的。goto语句及其对应的标签语句必须出现在同一函数中。
goto有两个主要限制:只能在单个函数的边界内跳转(不能跳出一个函数跳入另一个函数),如果向前跳转,则不能跳过变量的显式初始化。例如:
int main()
{
goto skip; // error: 这个跳转是非法的...
int x { 5 }; // 因为这里的显式初始化会被跳过
skip:
x += 3; // 这里的x的初始值设置的是多少?
return 0;
}
请注意,可以向后跳转,当执行接下来的初始化语句时,变量将被重新初始化。
8.7.2 避免使用goto
几乎任何使用goto语句编写的代码都可以使用C++中的其它控制流(如if语句和循环)更清楚地编写。一个值得注意的例外是,当您需要退出嵌套循环而不是整个函数时——在这种情况下,使用goto转到循环后面可能是最干净的解决方案。
避免goto语句(除非替代方案对代码可读性的影响明显)。
8.8 循环和while语句
8.8.1 循环
循环允许一段代码重复执行,直到满足某个条件退出。循环为编程添加了大量的灵活性。
8.8.2 While语句
while语句(也称为while循环)是C++提供的三种循环类型中最简单的一种,它的定义与if语句的定义非常相似:
while (条件表达式)
语句;
使用while关键字声明while语句。执行while语句时,将计算条件表达式。如果条件的计算结果为true,则执行关联的语句。
然而,与if语句不同,一旦语句完成执行,控制权就会返回到while语句的顶部,并且重复该过程。这意味着只要条件继续计算为true,while语句就会保持循环。
8.8.3 初始条件计算为false的语句
如果条件最初的计算结果为false,则关联的语句将根本不会执行。
8.8.4 死循环(无限循环)
如果表达式的计算结果总是为true,则while循环将永远执行。这称为死循环。
8.8.5 有意的死循环
可以这样声明有意的死循环:
while (true)
{
// 这个循环将无限执行
}
退出无限循环的唯一方法是通过return语句、break语句、exit语句、goto语句、引发异常或用户终止程序。
8.8.6 循环变量的命名
循环变量是用于控制循环执行次数的变量。例如,给定while ( count <= 10 ),count是一个循环变量。虽然大多数循环变量的类型都是int,但您偶尔会看到其他类型(例如char)。
循环变量通常被赋予简单的名称,其中i、j和k是最常见的。
然而,如果您想知道在程序中的何处使用循环变量,并且在i、j或k上使用搜索函数,则搜索函数将返回程序中大半的行!由于这个原因,一些开发人员更喜欢独特的循环变量名,如iii、jjj或kkk。由于这些名称更独特,这使得搜索循环变量变得更容易,并帮助它们作为循环变量脱颖而出。一个更好的想法是使用“真实”的变量名,例如count、index,或者提供有关计数内容的更多细节的名称(例如userCount)。
最常见的循环变量类型称为计数器,它是一个循环变量,用于统计循环已执行的次数。
8.8.7 整形循环变量应该有符号
整形循环变量几乎总是有符号的,因为无符号整数可能会导致意外的问题。
8.8.8 每N次迭代执行一次操作
每次执行循环时,它被称为一次迭代。
通常,我们希望在第2、3或4次迭代中执行某些操作,例如打印换行。通过在计数器上使用余数运算符,可以轻松完成此操作。
8.8.9 嵌套循环
还可以将循环嵌套在其他循环的内部。
8.9 Do while语句
C++提供了do-while语句:
do
语句; // 可以是单条语句,或代码块
while (条件表达式);
do-while语句是一个循环语句,其工作方式类似于while循环,但该语句至少执行一次。执行语句后,do-while循环检查条件。如果条件的计算结果为true,则执行路径跳回到do-while循环的顶部,并再次执行它。
下面是使用do-while的示例:
#include <iostream>
int main()
{
// 选项需要在循环外定义, 以便稍后使用
int selection{};
do
{
std::cout << "Please make a selection: \n";
std::cout << "1) Addition\n";
std::cout << "2) Subtraction\n";
std::cout << "3) Multiplication\n";
std::cout << "4) Division\n";
std::cin >> selection;
}
while (selection != 1 && selection != 2 &&
selection != 3 && selection != 4);
// 处理用户的选择
// 例如可以使用switch语句
std::cout << "You selected option #" << selection << '\n';
return 0;
}
通过这种方式,我们避免了魔数和额外的变量。
8.10 for语句
for语句在定义上看起来相当简单:
for (初始化语句; 条件表达式; 递增/递减表达式)
语句;
8.10.1 for语句的计算
for语句分为3阶段执行:
首先,执行初始化语句。这仅在循环启动时执行一次。初始化语句通常用于变量定义和初始化。定义的变量具有“循环作用域”,这实际上只是块作用域的一种形式,这些变量从定义点一直存在到循环语句的末尾。在等价的while循环中,可以看到初始化语句位于等价的代码块内,当整体结束时,初始化语句中定义的变量超出作用域。
其次,对于每次循环迭代,先计算条件表达式的结果。如果结果为true,则执行对应的语句。如果为false,则循环终止,并执行循环之外的下一条语句。
最后,计算递增/递减表达式。通常,该表达式用于递增或递减初始化语句中定义的循环变量。然后,会返回到第二步(并再次判断条件表达式)。
8.10.2 省略的表达式
for语句中的任何部分都可省略。值得注意的是,下面的示例生成了无限循环:
for (;;)
语句;
8.11 break与continue
8.11.1 break
break语句会导致while循环、do-while环、for循环或switch语句结束,并执行循环或者switch后的下一个语句。
8.11.2 continue
continue语句提供了一种方法,可以在不终止整个循环的情况下结束循环的当前迭代。
8.11.3 提前返回
不是函数中最后一个语句的return语句称为提前返回(early return)。许多程序员认为应该避免提前返回。return语句只在函数的最底部,这具有简单性——函数将接受参数,执行任何逻辑,并返回结果。有额外的返回会使逻辑复杂化。
但是,使用提前返回允许函数在完成后立即退出,这减少了对不必要逻辑的判断执行,并最大限度地减少了对条件嵌套的需要,这使得代码更具可读性。
一些开发人员采取中间立场,只在函数顶部使用提前返回来进行参数验证(捕获传入的错误参数)。
我们的立场是,提前返回更有帮助,而不是有害。
8.12 提前退出程序
退出,代表程序终止执行。在C++中,这被实现为函数(而不是关键字),因此退出程序需要函数调用。
当main() 函数返回时(到达函数末尾或通过return语句返回),会发生许多事情。
首先,离开main函数,所以所有局部变量和函数参数都会被销毁(通常情况)。
接下来,调用一个名为std::exit()的特殊函数,并将main()函数的返回值作为参数传入。那么什么是std::exit()?
8.12.1 std::exit()函数
exit()是一个导致程序正常终止的函数。正常终止意味着程序以预期的方式退出。请注意,术语正常终止并不意味着程序执行符合预期。例如,假设您正在编写一个程序,希望用户输入要处理的文件名。如果用户键入了无效的文件名,则程序可能会返回非零的状态码来指示故障状态,但它仍然是正常的终止。
exit()执行许多清理函数。首先,销毁具有静态存储期的对象。然后,如果使用了任何文件,则执行一些文件清理动作。最后,用传递给std::exit()的参数用作状态码,将控制返回给操作系统。
8.12.2 显式调用std::exit()
尽管在函数main()结束时隐式调用std::exit(),但也可以显式调用std::exit。当以这种方式调用std::exit()时,需要引用cstdlib头文件。
下面是显式使用std::exit()的示例:
#include <cstdlib> // 引入 std::exit()
#include <iostream>
void cleanup()
{
// 这里来做一些清理工作
std::cout << "cleanup!\n";
}
int main()
{
std::cout << 1 << '\n';
cleanup();
std::exit(0); // 结束程序执行,并将0返回给操作系统
// 下面的代码不会执行
std::cout << 2 << '\n';
return 0;
}
请注意,std::exit()后续的语句将不会执行,因为程序已经终止。
在上面的程序中,从函数main()中调用std::exit(),但可以从任何函数中调用std::exit()来终止程序。
显式调用std::exit(),不会清理任何局部变量(当前函数中,以及在调用堆栈上的函数中)。因此,通常最好避免调用std::exit()。
std::exit()函数不清理当前函数以及调用堆栈中的局部变量。
8.12.3 std::atexit
由于std::exit()立即终止程序,因此您可能希望在终止之前手动进行一些清理。在这种情况下,清理意味着关闭数据库或网络连接、释放分配的任何内存、将信息写入日志文件等…
上面的示例调用了函数cleanup()来处理清理任务。然而,在每次调用exit()之前手动调用清理函数会给我们增加大量的负担。
为了帮助实现这一点,C++提供了std::atexit()函数,它允许您指定一个函数,该函数将在程序终止时通过std::exit()自动调用。
在多线程程序中,调用std::exit()可能会导致程序崩溃(因为调用std::exit()的线程将清理可能仍然被其他线程访问的静态对象)。由于这个原因,C++引入了另一对函数,它们的工作方式类似于std::exit()和std::atexit(),称为std::quick_exit()和std:∶at_quick_exit()。std::quick_exit()会终止程序,但不会清理静态对象,并且不一定执行其他类型的清理。对于以std::quick_exit()终止的程序,std::at_quick_exit()扮演与std:∶atexit()相同的角色。
8.12.4 std::abort和std::terminate
C++包含另外两个与退出程序相关的函数。
std::abort()导致程序异常终止。异常终止意味着程序出现某种异常运行时错误,程序无法继续运行。例如,尝试除以0将导致异常终止。std::abort()不执行任何清理。
std::terminate()函数通常与异常一起使用。尽管可以显式调用std::terminate,但在处理异常时(以及在其他一些与异常相关的情况下),它经常被隐式调用。默认情况下,std::terminate()会调用std::abort()。
8.12.5 什么时候需要手动退出程序
只有在没有安全的方法从main函数正常返回时才手动退出程序。
尽管应该尽量减少手动的退出程序,但程序可以通过许多其他方式意外关闭。例如:
- 应用程序可能由于错误而崩溃(在这种情况下,操作系统将关闭它)。
- 用户可能会以各种方式终止应用程序。
- 用户可能会关闭其计算机的电源。
- 太阳可能会变成超新星,并在一个巨大的火球中吞噬地球。
一个设计良好的程序应该能够在任何时候被关闭,并且影响很小。
一个常见例子,现代游戏通常定期自动保存游戏状态和用户设置,以便如果游戏意外关闭而未保存,则用户可以稍后继续(使用先前的自动保存的设置),而不会损失太多进度。
C++控制流与错误处理学习笔记
1726

被折叠的 条评论
为什么被折叠?



