27-3 异常、函数和堆栈展开
在上一课“27.2 基本异常处理”中,我们解释了 throw、try 和 catch 如何协同工作以实现异常处理。本节课,我们将讨论异常处理如何与函数交互。
从被调用函数抛出异常
在上一课中,我们提到“try 代码块会检测 try 代码块内部语句抛出的所有异常”。在相应的示例中,我们的 throw 语句被放在 try 代码块内,并由关联的 catch 代码块捕获,所有这些都在同一个函数中。在同一个函数中同时抛出和捕获异常的意义有限。
更值得关注的是,如果 try 代码块内的语句是函数调用,而被调用的函数抛出异常,会发生什么情况?try 代码块能否检测到由其自身调用的函数抛出的异常?
幸运的是,答案是肯定的!
异常处理最有用的特性之一是,throw 语句不必直接放在 try 代码块内。相反,异常可以从函数中的任何位置抛出,并且这些异常可以被调用者(或调用者的调用者,以此类推)的 try 代码块捕获。当异常以这种方式被捕获时,执行会从抛出异常的位置跳转到处理该异常的 catch 代码块。
关键见解:
try 代码块不仅可以捕获 try 代码块内部语句的异常,还可以捕获 try 代码块内部调用的函数的异常。
这使我们能够以更加模块化的方式使用异常处理。我们将通过重写上一课中的平方根程序来演示这一点,使其使用模块化函数。
#include <cmath> // for sqrt() function
#include <iostream>
// A modular square root function
double mySqrt(double x)
{
// If the user entered a negative number, this is an error condition
if (x < 0.0)
throw "Can not take sqrt of negative number"; // throw exception of type const char*
return std::sqrt(x);
}
int main()
{
std::cout << "Enter a number: ";
double x {};
std::cin >> x;
try // Look for exceptions that occur within try block and route to attached catch block(s)
{
double d = mySqrt(x);
std::cout << "The sqrt of " << x << " is " << d << '\n';
}
catch (const char* exception) // catch exceptions of type const char*
{
std::cerr << "Error: " << exception << std::endl;
}
return 0;
}
在这个程序中,我们将检查异常和计算平方根的代码放到了一个名为 mySqrt() 的模块化函数中。然后,我们在一个 try 代码块中调用了这个 mySqrt() 函数。让我们验证一下它是否仍然按预期工作:

确实如此!当 mySqrt() 函数内部抛出异常时,mySqrt() 函数本身并没有异常处理程序。然而,对 mySqrt() 的调用(在 main() 函数中)位于一个带有匹配异常处理程序的 try 代码块内。因此,程序会从 mySqrt() 函数中的 throw 语句跳转到 main() 函数中 catch 代码块的顶部,然后继续执行。
上述程序最有趣的地方在于,mySqrt() 函数可以抛出异常,但它自身并不处理这个异常!这实际上意味着 mySqrt() 愿意发出“嘿,出问题了!”的警告,但却不愿意自己解决问题。本质上,它是将处理异常的责任委托给了调用者(这相当于使用返回码将处理错误的责任交还给了函数的调用者)。
此时,你们中的一些人可能想知道,为什么要把错误传递回调用者?为什么不让 MySqrt() 函数自己处理错误呢?问题在于,不同的应用程序可能需要以不同的方式处理错误。控制台应用程序可能需要打印一条文本消息,而 Windows 应用程序可能需要弹出一个错误对话框。在一个应用程序中,这可能是一个致命错误,而在另一个应用程序中则可能并非如此。通过将错误传递到函数之外,每个应用程序都可以根据自身情况以最合适的方式处理 mySqrt() 函数返回的错误!最终,这使得 mySqrt() 函数尽可能地模块化,而错误处理则可以放在代码中模块化程度较低的部分。
异常处理和堆栈展开
在本节中,我们将了解当涉及多个函数时,异常处理是如何实际工作的。
相关内容
在继续之前,如果您需要复习调用栈和栈展开的相关知识,请参阅第20.2 课——栈和堆。
当抛出异常时,程序首先会检查该异常是否可以在当前函数内部立即处理(即异常是在当前函数的 try 代码块中抛出的,并且存在相应的 catch 代码块)。如果当前函数可以处理该异常,则会立即进行处理。
否则,程序接下来会检查函数的调用者(调用栈中上一级的函数)是否可以处理该异常。为了使函数的调用者能够处理该异常,对当前函数的调用必须位于 try 代码块内,并且必须关联一个匹配的 catch 代码块。如果找不到匹配项,则检查调用者的上一级调用者(调用栈中上一级的两个函数)。类似地,为了使调用者的上一级调用者能够处理该异常,对该调用者的调用必须位于 try 代码块内,并且必须关联一个匹配的 catch 代码块。
检查调用堆栈中每个函数的过程会一直持续,直到找到处理程序,或者检查完调用堆栈中的所有函数但找不到处理程序为止。
如果找到匹配的异常处理程序,则执行将从抛出异常的位置跳转到匹配的 catch 代码块的顶部。这需要多次回溯调用栈(从调用栈中移除当前函数),直到处理异常的函数位于调用栈的顶部。
如果找不到匹配的异常处理程序,堆栈可能会也可能不会被展开。我们将在下一课(27.4——未捕获的异常和捕获所有异常的处理程序)中详细讨论这种情况。
当当前函数从调用栈中移除时,所有局部变量都会像往常一样被销毁,但不会返回任何值。
关键见解
展开堆栈会销毁被展开函数中的局部变量(这是好事,因为它可以确保它们的析构函数执行)。
另一个堆栈展开示例
为了说明以上内容,我们来看一个更复杂的例子,使用更大的栈。虽然这个程序很长,但其实很简单:main() 调用 A(),A() 调用 B(),B() 调用 C(),C() 调用 D(),D() 抛出一个异常。
#include <iostream>
void D() // called by C()
{
std::cout << "Start D\n";
std::cout << "D throwing int exception\n";
throw - 1;
std::cout << "End D\n"; // skipped over
}
void C() // called by B()
{
std::cout << "Start C\n";
D();
std::cout << "End C\n";
}
void B() // called by A()
{
std::cout << "Start B\n";
try
{
C();
}
catch (double) // not caught: exception type mismatch
{
std::cerr << "B caught double exception\n";
}
try
{
}
catch (int) // not caught: exception not thrown within try
{
std::cerr << "B caught int exception\n";
}
std::cout << "End B\n";
}
void A() // called by main()
{
std::cout << "Start A\n";
try
{
B();
}
catch (int) // exception caught here and handled
{
std::cerr << "A caught int exception\n";
}
catch (double) // not called because exception was handled by prior catch block
{
std::cerr << "A caught double exception\n";
}
// execution continues here after the exception is handled
std::cout << "End A\n";
}
int main()
{
std::cout << "Start main\n";
try
{
A();
}
catch (int) // not called because exception was handled by A
{
std::cerr << "main caught int exception\n";
}
std::cout << "End main\n";
return 0;
}
请仔细查看这个程序,看看你能不能找出运行后哪些内容会被打印出来,哪些内容不会被打印出来。答案如下:

我们来分析一下这种情况。所有“Start”语句的打印过程很简单,无需赘述。函数D()打印“D抛出int异常”,然后抛出一个int异常。有趣的地方就在这里。
由于函数 D() 本身不处理异常,因此会检查其调用者(调用栈中向上移动的函数)是否能够处理该异常。函数 C() 不处理任何异常,因此没有找到匹配项。
函数 B() 包含两个独立的 try 代码块。调用 C() 的 try 代码块有一个针对 double 类型异常的处理程序,但这与我们 int 类型的异常不匹配(异常不会进行类型转换),因此找不到匹配项。空的 try 代码块确实有一个针对 int 类型异常的处理程序,但由于对 C() 的调用不在关联的 try 代码块内,因此该 catch 代码块也不被视为匹配项。
A() 函数也包含一个 try 代码块,并且对 B() 函数的调用就位于其中,因此程序会检查是否存在用于捕获 int 异常的 catch 代码块。结果发现确实存在!因此,A() 函数会处理该异常,并打印“A 捕获到 int 异常”。
由于异常已被处理,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() 甚至根本不知道有异常发生!
如您所见,栈展开为我们提供了非常有用的特性——如果一个函数不想处理异常,它完全可以不处理。异常会沿着栈向上传播,直到找到其他函数来处理为止!这使我们能够决定在调用栈的哪个位置最适合处理可能发生的任何错误。
在下一课中,我们将了解如果不捕获异常会发生什么,以及如何防止这种情况发生。

浙公网安备 33010602011771号