C++未捕获异常与通配处理器深度解析

到目前为止,您应当对异常的工作机制有了基本认识。本节将讨论几种更具特色的异常场景。

未捕获异常

当函数抛出异常而又不自行处理时,它隐含地假设调用栈中的某个函数会处理该异常。以下示例中,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,该处理器绝不会触发,从而让异常保持未捕获状态,便于调试器中断。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"