到目前为止,您应当对异常的工作机制有了基本认识。本节将讨论几种更具特色的异常场景。
未捕获异常
当函数抛出异常而又不自行处理时,它隐含地假设调用栈中的某个函数会处理该异常。以下示例中,mySqrt() 抛出了异常,却假设有人会处理——但如果实际上无人处理,会发生什么?
下面再次给出平方根程序,但去掉了 main() 中的 try 块:
#include <iostream>
#include <cmath> // sqrt()
// 模块化的平方根函数
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;
// 注意:此处无异常处理器!
std::cout << "The sqrt of " << x << " is " << mySqrt(x) << '\n';
return 0;
}
若用户输入 -4,则 mySqrt(-4) 引发异常。mySqrt() 自身不处理,程序继续查看调用栈是否有处理器。main() 亦无对应处理器,于是找不到任何处理器。
当异常无法被任何处理器捕获时,系统将调用 std::terminate(),应用程序立即终止。此时 调用栈可能回卷,也可能不回卷!若未回卷,局部变量不会被销毁,这些变量析构函数中预期的清理工作亦不会执行。
警告
异常未被处理时,调用栈可能不回卷。
若未回卷,局部变量不被销毁,若这些变量拥有非平凡析构函数,则可能导致资源泄漏或其他问题。
补充说明
乍看之下不回卷栈似乎奇怪,但此举有其合理性。未处理异常通常是我们希望极力避免的灾难。若此时回卷栈,则导致抛出该异常时栈状态的调试信息将全部丢失。保持栈不回卷可保留这些信息,便于定位并修复未处理异常。
当异常未被捕获时,操作系统通常会发出通知。具体方式依操作系统而异,可能包括打印错误信息、弹出错误对话框或直接崩溃。不同系统的“优雅”程度不一,总之应尽量避免这种情况。
通配处理器(catch-all handler)
我们面临一个两难:
- 函数可能抛出任意数据类型的异常(含用户自定义类型),潜在异常类型无限多。
- 若异常未被捕获,程序将立即终止,且可能未正确回卷栈。
- 为每一种可能的异常类型编写显式 catch 块既繁琐又不现实。
C++ 为此提供了“通配处理器”,可捕获所有类型的异常。该处理器与普通 catch 块语法一致,只是使用省略号 ...
作为类型,故又称“省略号处理器”。
回忆《省略号及其避免》中,省略号曾用于向函数传递任意类型实参;在此处,它表示任意类型的异常。示例:
#include <iostream>
int main()
{
try
{
throw 5; // 抛出 int 异常
}
catch (double x)
{
std::cout << "捕获到 double 异常: " << x << '\n';
}
catch (...) // 通配处理器
{
std::cout << "捕获到未知类型异常\n";
}
}
由于没有针对 int 的专门处理器,异常被通配处理器捕获,程序输出:
捕获到未知类型异常
通配处理器必须位于所有 catch 块链的最后,以确保特定类型的异常优先被更匹配的处理器捕获。
通配处理器常留空:
catch (...) { } // 忽略任何意外异常
如此可捕获所有未被预料的异常,确保栈回卷至此处,防止程序立即终止,但不执行任何具体错误处理。
使用通配处理器包装 main()
一种常见做法是用通配处理器包裹 main():
#include <iostream>
struct GameSession
{
// 游戏会话数据
};
void runGame(GameSession&)
{
throw 1; // 故意抛出异常
}
void saveGame(GameSession&)
{
// 保存用户存档
}
int main()
{
GameSession session{};
try
{
runGame(session);
}
catch (...)
{
std::cerr << "异常终止\n";
}
saveGame(session); // 即使触发了通配处理器,也要保存游戏
return 0;
}
若 runGame() 或其调用的任何函数抛出了未被处理的异常,都会被该通配处理器捕获。栈将按顺序回卷(确保局部变量析构),程序不会立即终止,我们可打印自定义错误信息并在退出前保存用户状态。
建议
若程序使用异常,建议在 main() 中使用通配处理器,以保证出现未处理异常时行为有序。
一旦异常被通配处理器捕获,应假定程序已处于不确定状态,立即执行必要的清理工作后终止。
调试未捕获异常
未捕获异常表明发生了意料之外的情况,我们往往希望诊断原因。多数调试器可配置为在未捕获异常处中断,使我们能在抛出点查看栈状态。然而,若存在通配处理器,所有异常均被视为已处理,且栈已回卷,调试信息随之丢失。
因此,在调试构建中,可有条件地禁用通配处理器。一种做法如下:
#include <iostream>
struct GameSession
{
// 游戏会话数据
};
void runGame(GameSession&)
{
throw 1;
}
void saveGame(GameSession&)
{
// 保存用户存档
}
// 一个无法实例化的哑元异常类
class DummyException
{
DummyException() = delete;
};
int main()
{
GameSession session{};
try
{
runGame(session);
}
#ifndef NDEBUG // 调试模式:不编译通配处理器
catch (DummyException) // 永远不会被触发,仅为语法需要
{
}
#else // 发布模式:编译通配处理器
catch (...)
{
std::cerr << "异常终止\n";
}
#endif
saveGame(session);
return 0;
}
语法上,try 块必须至少关联一个 catch 块。若条件编译去掉通配处理器,则需同时去掉 try 块,或条件编译引入另一 catch 块。后者更简洁。
为此,我们定义了无法实例化的 DummyException(默认构造函数被删除,且无其他构造函数)。当定义了 NDEBUG(发布模式)时,编译通配处理器;否则编译 DummyException 的 catch 块,由于无法抛出 DummyException,该处理器绝不会触发,从而让异常保持未捕获状态,便于调试器中断。