章节目录

异常、函数和堆栈展开

本节阅读量:

在上一课中,我们解释了如何使用throw、try和catch来启用异常处理。在本课中,我们将讨论异常处理如何与函数交互。


被调函数抛出异常

在上一课中,我们注意到,“try块检测由try块内的语句抛出的任何异常”。在相应的示例中,我们的throw语句被放在try块中,并被关联的catch块捕获,所有这些都在同一函数中。在单个函数中同时抛出和捕获异常的价值有限。

更有趣的是,如果try块中的语句是函数调用,而被调用的函数抛出异常,会发生什么?try块是否会检测从try块内调用的函数所引发的异常?

幸运的是,答案是肯定的!

异常处理最有用的特性之一是throw语句不必直接放在try块中。相反,可以从被调函数中的任何位置引发异常,而调用方(或调用方的调用方,等等…)的try块可以捕获这些异常。当以这种方式捕获异常时,代码的执行流会从引发异常的点跳到处理异常的catch块。

这允许我们以更模块化的方式使用异常处理。我们将通过重写上一课中的平方根程序来演示这一点。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <cmath> // for sqrt()
#include <iostream>

// 独立的平方根函数
double mySqrt(double x)
{
    // 如果输入小于0,那么肯定是异常情况
    if (x < 0.0)
        throw "Can not take sqrt of negative number"; // 抛出类型为 const char* 的异常

    return std::sqrt(x);
}

int main()
{
    std::cout << "Enter a number: ";
    double x {};
    std::cin >> x;

    try // try块中的异常,会由catch块进行捕获
    {
        double d = mySqrt(x);
        std::cout << "The sqrt of " << x << " is " << d << '\n';
    }
    catch (const char* exception) // 捕获类型为 const char* 的异常
    {
        std::cerr << "Error: " << exception << std::endl;
    }

    return 0;
}

在这个程序中,我们将检查异常并计算平方根的代码放在名为mySqrt()的函数中。然后,我们从try块内部调用这个mySqrt()函数。让我们验证它是否仍按预期工作:

1
2
Enter a number: -4
Error: Can not take sqrt of negative number

是的!当mySqrt()中引发异常时,mySqrt()本身没有处理该异常。然而,对mySqrt()的调用(在main()中)位于带有匹配异常处理程序的try/catch中。因此,执行流会从mySqrt()中的throw语句跳到main()中catch块的开头,然后继续执行。

上述程序最有趣的地方在于,mySqrt()函数可以引发异常,但它本身不处理该异常!这本质上意味着mySqrt()愿意报告“这里有个问题!”,但不负责自己处理问题。它会将处理异常的责任委托给调用者(这类似于使用返回码,将处理错误的责任传回函数调用者)。

此时,您可能想知道为什么将错误传递回调用方是一个好主意。为什么不让mySqrt()处理自己的错误?问题是,不同的应用程序可能希望以不同方式处理错误。控制台应用程序可能希望打印文本消息。Windows应用程序可能希望弹出错误对话框。在一个应用程序中,出现的问题可能是致命错误,而在另一个应用程序中,可能不是。通过将错误传出函数,每个应用程序都可以按最适合自身上下文的方式处理来自mySqrt()的错误!最终,这会让mySqrt()尽可能保持模块化,并将错误处理放在代码中模块化程度较低的部分。


异常处理和堆栈展开

在这一节中,我们将了解涉及多个函数时,异常处理实际上是如何工作的。

当抛出异常时,程序首先查看该异常是否可以立即在当前函数内处理(这意味着该异常是在当前函数的try块内抛出的,并且有一个相关联的对应catch块)。如果当前函数可以处理异常,则会这样做。

如果不能,程序接下来会检查函数的调用方(调用栈上的上一个函数)是否可以处理异常。为了让函数的调用方处理异常,对当前函数的调用必须位于try块内,并且必须关联匹配的catch块。如果没有找到匹配项,则检查调用者的调用者(调用栈上再上一层的函数)。类似地,为了让调用方的调用方处理异常,对调用方的调用也必须位于try块内,并且必须关联匹配的catch块。

在调用栈上逐层检查函数的过程会持续进行,直到找到处理程序,或者检查完调用栈上的所有函数仍找不到处理程序。

如果找到匹配的异常处理程序,执行流会从引发异常的点跳到匹配catch块的开头。这需要按需多次展开堆栈(从调用栈中移除当前函数),直到处理异常的函数成为调用栈上当前最底层的函数。

如果找不到匹配的异常处理程序,则堆栈可能会展开,也可能不会展开。我们将在下一课中详细讨论这种情况。

当当前函数从调用栈中移除时,所有局部变量都会像往常一样销毁,但不会返回任何值。


一个堆栈展开示例

为了说明上面的内容,让我们看一个更复杂的例子,使用一个更大的堆栈。尽管这个程序很长,但它非常简单:main()调用A(),A()调用B(),B()调用C(),C()调用D(),D()抛出异常。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#include <iostream>

void D() // 被 C() 调用
{
    std::cout << "Start D\n";
    std::cout << "D throwing int exception\n";

    throw - 1;

    std::cout << "End D\n"; // 这一行不会执行到
}

void C() // 被 B() 调用
{
    std::cout << "Start C\n";
    D();
    std::cout << "End C\n";
}

void B() // 被 A() 调用
{
    std::cout << "Start B\n";

    try
    {
        C();
    }
    catch (double) // 未捕获: exception 类型不匹配
    {
        std::cerr << "B caught double exception\n";
    }

    try
    {
    }
    catch (int) // 未捕获: try 中未抛出异常
    {
        std::cerr << "B caught int exception\n";
    }

    std::cout << "End B\n";
}

void A() // 被 main() 调用
{
    std::cout << "Start A\n";

    try
    {
        B();
    }
    catch (int) // exception 这里被捕并处理
    {
        std::cerr << "A caught int exception\n";
    }
    catch (double) // 不会被调用,因为前一个catch已经捕获成功
    {
        std::cerr << "A caught double exception\n";
    }

    // 异常处理结束,继续执行这里的代码
    std::cout << "End A\n";
}

int main()
{
    std::cout << "Start main\n";

    try
    {
        A();
    }
    catch (int) // 不会被调用,因为异常被 A 处理了
    {
        std::cerr << "main caught int exception\n";
    }
    std::cout << "End main\n";

    return 0;
}

请更详细地看一看这个程序,尝试找出运行时会打印哪些内容、不会打印哪些内容。答案如下:

1
2
3
4
5
6
7
8
9
Start main
Start A
Start B
Start C
Start D
D throwing int exception
A caught int exception
End A
End main

让我们看看在这种情况下会发生什么。所有“Start”语句的打印都很直接,不需要进一步解释。函数D()打印“D throwing int exception”,然后引发int异常。事情从这里开始变得有趣。

由于D()本身不处理异常,因此程序会检查其调用方(调用栈上的函数),查看是否有函数可以处理该异常。函数C()不处理任何异常,因此在那里找不到匹配项。

函数B()有两个单独的try块。包含对C()调用的try块具有用于double类型异常的处理程序,但它与int类型异常不匹配(并且异常不会进行类型转换),因此未找到匹配项。下面的空try块不会抛出异常。

A()也有一个try块,对B()的调用位于其中,因此程序会查看是否存在int异常的catch处理程序。存在!因此,A()会处理异常,并打印“A caught int exception”。

由于现在已经处理了异常,因此控制流在A()中的catch块之后正常继续。这意味着A()打印“End A”,然后正常终止。

控制流返回main()。尽管main()有一个int异常处理程序,但我们的异常已经由A()处理,因此main()中的catch块不会执行。main()只会打印“End main”,然后正常终止。

该程序说明了许多有趣的原则:

首先,如果抛出异常的函数的直接调用方不想处理异常,它就不必处理。在这种情况下,C()不处理D()抛出的异常,而是将该职责委托给调用栈上的上一层调用方。

其次,如果try块没有与被抛出异常类型对应的catch处理程序,则会发生堆栈展开,就像根本没有try块一样。在这种情况下,B()也没有处理异常,因为它没有正确类型的catch块。

第三,如果函数具有匹配的catch块,但对当前函数的调用没有在关联的try块中发生,则不会使用该catch块。我们在B()中也看到了这一点。

最后,一旦匹配的catch块执行完毕,控制流就会照常继续,从最后一个catch块之后的第一条语句开始。这一点通过A()处理错误、继续执行“End A”、然后返回调用方得到体现。当程序返回到main()时,异常已经被抛出并处理——main()完全不知道发生过异常!

正如您所看到的,堆栈展开为我们提供了一些非常有用的行为——如果函数不想处理异常,就不必处理。异常会沿调用栈向上传播,直到找到愿意处理它的地方!这允许我们决定调用栈中哪个位置最适合处理错误。

在下一课中,我们将看一看当您不捕获异常时会发生什么,以及防止这种情况发生的方法。


27.1 基本异常处理

上一节

27.3 未捕获的异常和捕获所有异常

下一节