C++学习笔记8 控制流与错误处理

C++控制流与错误处理学习笔记

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标签或可选的默认标签匹配时,执行从匹配标签之后的第一条语句开始。然后继续按顺序执行,直到发生以下终止条件之一:

  1. switch代码块结束
  2. 另一个控制流语句(通常是break或return)导致退出代码块或函数。
  3. 其它打断程序正常控制流的事情(操作系统杀死了对应的进程等其它原因)

请注意,另一个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函数正常返回时才手动退出程序。

尽管应该尽量减少手动的退出程序,但程序可以通过许多其他方式意外关闭。例如:

  1. 应用程序可能由于错误而崩溃(在这种情况下,操作系统将关闭它)。
  2. 用户可能会以各种方式终止应用程序。
  3. 用户可能会关闭其计算机的电源。
  4. 太阳可能会变成超新星,并在一个巨大的火球中吞噬地球。

一个设计良好的程序应该能够在任何时候被关闭,并且影响很小。

一个常见例子,现代游戏通常定期自动保存游戏状态和用户设置,以便如果游戏意外关闭而未保存,则用户可以稍后继续(使用先前的自动保存的设置),而不会损失太多进度。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值