在上一节《基础异常处理》中,我们说明了 throw、try 与 catch 如何协同工作以实现异常处理。本节将讨论异常处理与函数之间的交互关系。
在被调函数中抛出异常
上一节曾指出:“try 块会检测其内部语句抛出的任何异常”。相应示例中,throw 语句与对应的 catch 块均位于同一函数内,这种做法价值有限。
更值得关注的是:若 try 块内的语句是一次函数调用,而被调函数抛出了异常,try 块是否能检测到?答案是肯定的!
异常处理最具实用性的特性之一在于:throw 语句不必直接位于 try 块内。异常可在函数任意位置抛出,并由调用者的 try 块(或更外层调用者)捕获。当异常以这种方式被捕获时,执行流将从抛出点直接跳转到处理该异常的 catch 块。
关键洞察
try 块不仅能捕获其内部语句抛出的异常,还能捕获这些语句所调用函数抛出的异常。
这使异常处理更具模块化。我们将上一节的平方根程序重构为模块化函数以说明这一点:
#include <cmath> // sqrt()
#include <iostream>
// 模块化的平方根函数
double mySqrt(double x)
{
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 块内异常
{
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;
}
程序运行结果与预期一致:
Enter a number: -4
Error: Can not take sqrt of negative number
当 mySqrt() 抛出异常时,该函数内部并无处理器。但 main() 中对 mySqrt() 的调用位于 try 块内,且存在匹配处理器,于是执行从 mySqrt() 内的 throw 语句直接跳转到 main() 的 catch 块,随后继续执行。
最值得注意的是:mySqrt() 可以抛出异常,却不自行处理,相当于声明“出现问题!”却把处理责任交给调用者(类似用返回码将错误处理责任回传)。
为何不把错误处理留在 mySqrt() 内?不同应用对错误处理的需求各异:控制台程序可能打印文本;窗口程序可能弹出对话框;某场景下该错误致命,另一场景则未必。通过将错误传递出去,各应用可按最合适的方式处理,保持 mySqrt() 最大化模块化,而把错误处理放在更具体的上下文中。
异常处理与栈回卷
本部分阐述多函数场景下异常处理的实际流程。
相关内容
如需回顾调用栈与栈回卷,请参见《栈与堆》。
当异常被抛出时,程序首先检查当前函数能否立即处理(即异常在当前函数的 try 块内抛出且存在对应 catch)。若能,则就地处理。
若不能,程序继续检查调用者(调用栈上一层)能否处理。要让调用者处理,当前函数的调用必须位于调用者的 try 块内,且存在匹配的 catch。若仍未找到,则检查再上一层,依此类推。
该检查沿调用栈逐层向上,直至找到处理器或查遍所有函数仍未果。
若找到匹配的异常处理器,执行从抛出点跳转到对应 catch 块顶部。这需要回卷栈(将当前函数及中间函数逐层弹出调用栈),直至处理异常的函数位于栈顶。
若未找到匹配处理器,栈可能回卷也可能不回卷,下一节《未捕获异常与通配处理器》将详细讨论。
当函数被弹出调用栈时,其所有局部变量照常销毁,但不会返回值。
关键洞察
栈回卷会销毁被弹出函数的局部变量(确保析构函数执行,这是好事)。
又一个栈回卷示例
为说明上述过程,我们看一个更复杂的例子,涉及更深的调用栈。程序虽长但逻辑简单:main() → A() → B() → C() → D(),D() 抛出异常。
#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) // 不匹配:异常类型不符
{
std::cerr << "B caught double exception\n";
}
try
{
}
catch (int) // 不匹配:对 C() 的调用不在此 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) // 匹配成功并处理
{
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;
}
运行结果如下:
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() 的调用,但仅捕获 double,类型不匹配;另一 try 块虽能捕获 int,但对 C() 的调用不在其内,故不匹配。
A() 的 try 块包含对 B() 的调用,且存在 int 处理器,于是 A() 处理异常并打印 “A caught int exception”。
异常处理后,控制流在 A() 的 catch 块后继续,打印 “End A”,随后正常返回 main()。由于异常已由 A() 处理,main() 的 catch 块不会执行,main() 仅打印 “End main” 并正常结束。
程序展示了若干重要原则:
- 抛出异常的函数的直接调用者并非必须处理异常;C() 把责任转交给更上层。
- 若 try 块不含匹配的 catch,则等同于无 try 块,同样发生栈回卷;B() 即属此类。
- 若函数虽有匹配 catch,但对当前函数的调用不在对应 try 块内,该 catch 不会使用;B() 的第二个 try 块即如此。
- 一旦匹配 catch 执行完毕,执行流从该 catch 块后继续;A() 处理异常后继续打印 “End A”。回到 main() 时异常早已处理完毕,main() 甚至不知道异常曾发生!
可见,栈回卷带来极大便利:若函数不愿处理异常,可置之不理,异常将沿栈上浮,直至找到愿意处理的函数。这使我们得以在调用栈中最合适的位置处理错误。
下一节,我们将探讨异常未被捕获的情形,并给出预防方法。