如果你写过一段时间的 C 代码,一定经历过这样的场面:一个返回值是 int 的函数,你没法确定它到底是"成功返回、值是 -1"还是"失败、错误码也是 -1"。你得去翻文档、查错误码表、写一堆 if 判断,一不小心就把错误漏过去了。这种"用一个数字悄悄告诉你出错了"的日子,C++ 想彻底终结它。终结它的武器,就是今天要讲的异常(exception)。

异常是 C++ 里最"重"也最容易被误解的机制之一。很多人学了多年,只记得 try、catch、throw 三个关键字,却不明白它背后为什么要一棵"栈解旋(栈展开)"、为什么要绑定 RAII(资源获取即初始化)、为什么析构函数里抛出异常会引发天大的麻烦。这篇文章我会把异常从 0 讲透:它是怎么来的、为什么存在、底层发生了什么、有哪些坑、以及如何写出异常安全的代码。

这篇文章默认你已经会写 C++ 类、知道什么是作用域和函数调用栈。不过别担心,这些前置知识我会在用的地方就地展开讲透,你不需要回头补课。另外说明一下:这里的代码都以 C++11 及以上为标准(你机器的编译器基本都支持),凡是涉及版本差异的(C++98、C++11、C++17、C++20、C++23)我会在对应位置明确标注;如果某个行为在不同编译器(GCC/Clang、MSVC)下有实现差异,我也会注明。

C 语言时代的错误处理之痛

要理解异常为什么存在,先得看看异常之前的世界是什么样子的。C 语言时代,处理错误主要靠错误码(error code)。错误码的本质,就是对错误信息进行分类编号——比如 0 表示成功,-1 表示文件打不开,-2 表示内存分配失败,-3 表示权限不足……程序员拿到一个错误码之后,还得去查表、去查文档,才知道这个数字到底意味着什么。

你有没有想过:错误码本质上是把你的错误变成了一串数字,而数字本身不携带任何"为什么"的信息。-1 到底是"文件不存在"还是"文件被占用"还是"没有权限"?如果你不查文档,你根本不知道。这是错误码的第一个痛点。很多大型 C 系统(比如 Windows 的 Win32 编程)会把错误码做成一个大结构(HRESULT 之类的复合错误码),里面既编码了错误类别,又编码了错误号码——这本质上是在拼命往"一个数字"里塞更多信息,而它的极端尽头,就是我们今天要讲的异常对象。

更痛的还在后面。写一段要在中途分配多个资源的 C 代码你就明白了:

#include <cstdio>
#include <cstdlib>
 
// C 风格的错误处理:靠错误码 + 逐层 if 判断 + 手动清理
int doStuff() {
    // 第一步:申请一块缓冲区,失败返回 -1
    char* buffer0 = (char*)malloc(100);
    if (buffer0 == nullptr) {
        return -1;              // 出错,直接返回错误码
    }
 
    // 第二步:打开文件,失败要记得释放之前的内存!
    FILE* f = fopen("file.txt", "rb");
    if (f == nullptr) {
        free(buffer0);          // 手动清理 buffer0,否则内存泄漏
        return -2;              // 返回另一个错误码
    }
 
    // 第三步:再申请一块缓冲区
    char* buffer1 = (char*)malloc(100);
    if (buffer1 == nullptr) {
        free(buffer0);          // 又要手动清理
        fclose(f);              // 还要关闭文件
        return -1;
    }
 
    // 正常逻辑......
    printf("正常执行\n");
 
    // 全部成功,最后统一释放
    free(buffer0);
    free(buffer1);
    fclose(f);
    return 0;                   // 0 表示成功
}

这段代码暴露了错误码方案的四个致命问题:

第一,错误处理代码把业务逻辑完全淹没掉了。 你看,为了做"申请内存 + 打开文件 + 申请内存"三件小事,前面一半的代码全是在判断错误、释放资源、返回错误码。真正干活的逻辑只有最后那三行。如果一个函数要串十几个步骤,那整个函数就会变成"错误处理套娃",中心思想全被埋没了。

第二,资源清理逻辑被反复复制。 每一步失败都要手动释放之前申请的所有资源。上面还只是两个缓冲区加一个文件,如果是十个资源,你就得在十几个失败点写十几段重复的清理代码。任何一处忘记释放,就是一次内存泄漏或文件句柄泄漏。维护这种代码,无异于走钢丝。

第三,错误码很容易被忽略。 C 里的函数返回一个 int,你完全可以只写 doStuff(); 而不管它的返回值。编译器不会拦你,程序也照样跑——只是错误在无声无息中被吞掉了。等你发现数据不对再回头排查时,早已找不到肇事地点。这就是"错误码不可强制"——你可以选择性失明。请记住"不可强制"这四个字,因为它恰恰是异常机制最核心的对立面:异常是强制的——你不接住它,程序就终止给你看。

第四,错误码本身表达能力太弱。 一个 int 能承载的信息极其有限。是哪个文件打不开?期望的权限是什么?错误发生时的具体现场数据是什么?这些都塞不进一个数字里。有人会用 goto 跳到一个统一的清理标签来缓解问题——下面的代码是 C 里常见的"统一清理出口"写法:

#include <cstdio>
#include <cstdlib>
 
// C 风格的统一清理出口(error label / cleanup label)
int doStuffFixed() {
    char* buffer0 = (char*)malloc(100);
    if (buffer0 == nullptr) { return -1; }
 
    FILE* f = fopen("file.txt", "rb");
    if (f == nullptr) { goto cleanup_buffer0; }
 
    char* buffer1 = (char*)malloc(100);
    if (buffer1 == nullptr) { goto cleanup_openfile; }
 
    printf("正常执行\n");
 
    free(buffer0);
    free(buffer1);
    fclose(f);
    return 0;
 
cleanup_openfile:
    free(buffer0);
    fclose(f);
    return -1;
cleanup_buffer0:
    free(buffer0);
    return -1;
}

注意,即便用 goto 收敛了清理逻辑,代码量依然很大,而且 goto 没有结构性约束:标签号多了之后,那个"清理出口"本身会膨胀成一棵需要你人工维护的树——哪个失败点该清理哪几个资源,全凭你的记性。写多了照样是一团乱麻。

你现在应该能体会到,C 时代的程序员不是不想好好处理错误,而是工具本身不给力。他们需要一个更强大的机制:能传出丰富的错误信息、能强制调用者处理、还能自动做好资源清理。C++ 的异常机制,就是为回答这些问题而生的。

异常是什么,为什么需要异常

先看标准定义的思路。异常处理机制允许程序中独立开发的部分能够在运行时就出现的问题进行"通信"并做出相应的处理。关键是这句话的后半句:它让我们把"问题的检测"与"问题的解决"剥离开。

你可以把异常理解成一条"紧急热线"。程序的某一部分只负责"发现出了问题",然后把这个问题的详情装进一个包裹,沿着一根看不见的线向"上"传递;而解决这个问题的代码,可能藏在几层函数之外。检测环节完全不需要知道处理模块的细节——写检测代码的人只管抛,写处理代码的人只管接,两拨人可以各干各的,互不打扰。

这跟错误码是截然不同的世界观:

  • 错误码是"把错误压缩成一个数字,塞进函数的普通返回值通道里";
  • 异常是"把错误包装成一个对象,走一条独立的、强制的、无法被普通 return 掩盖的通道"。

注意"强制"两个字。一个被 throw 抛出的异常,如果你不在某处 catch 住,程序最终会终止——它不会被静默忽略。这就解决了 C 时代"错误码被吞掉"的问题。

而且,异常抛出的不是一个数字,而是一个对象。对象里可以装任意的、类型化的、结构化的信息——错误消息是一串 std::string,错误码是一个 int,甚至还能附加一段出错的 SQL、一个失败的文件名、一个请求的 URL。它的表达能力比一个 int 强出几个数量级。

同时,异常走的是一条独立通道。它不占用你函数的返回值——函数该返回 double 还是返回 double,出错时走异常通道,成功时走返回值通道,两条路互不干扰。你在 C 里纠结的"-1 到底是成功返回值还是错误码"这类问题,在 C++ 里从设计上就不存在了。

最后也是最深刻的:异常机制天生和"自动清理"绑定。当一个异常沿着调用链向外传播时,沿途创建的每一个对象都会被自动析构(这就是后面要重点讲的"栈解旋")。如果你配合 RAII(Resource Acquisition Is Initialization,资源获取即初始化)——也就是把资源(内存、文件、锁、网络连接)封装进一个对象,让析构函数负责释放——那么异常传播的过程中,资源会被自动、确定、不遗漏地释放掉。C 时代那堆写到你怀疑人生的手动 free 和 fclose,在 C++ 里可以完全消失。

一句话总结:异常是 C++ 给出的"出错时该怎么优雅、强制、自动地处理好一切"的标准答案。

在深入细节之前,先给你一个总览,把这条道路的地图铺开。异常机制在 C++ 里实际上由五个部件构成,缺一不可:

  1. 抛出点(throw)——谁报告问题;
  2. 捕获点(catch)——谁处理问题;
  3. 传播路径(栈解旋)——问题怎么从抛出点走到捕获点,这一路上发生了什么事;
  4. 匹配规则——一个异常到底该由哪个 catch 来接;
  5. 生命周期与安全纪律——异常对象活多久、资源怎么清、析构里能不能抛、函数承诺不抛(noexcept)等等。

下面我们先把 1、2、3 的基本盘看清楚。

throw 与 try/catch 的基本结构

异常机制有三个主角,你要把它们的职责分清楚:

  • throw:抛。程序发现问题时,用一个 throw 表达式抛出一个对象,从而引发一个异常。
  • try:试。把"可能出错的代码"包进一个 try 块里,准备接住可能抛出的异常。
  • catch:接。try 块后面紧跟一个或多个 catch 块,每个 catch 负责处理某种类型的异常。

下面是一段经典入门代码——写一个除法函数,除数为 0 时抛出异常:

#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;
 
// 除法函数:b 为 0 时抛出一个异常对象
double Divide(int a, int b) {
    if (b == 0) {
        string s("Divide by zero condition!");   // 生成一个 string 对象
        throw s;                                 // 抛出这个 string,异常引发了
    }
    return (double)a / (double)b;                // 正常时返回商
}
 
int main() {
    try {
        // 把可能出错的调用放进 try 块
        cout << Divide(10, 0) << endl;           // 这里会抛出异常
        // 注意:一旦 Divide 抛出异常,这一行及之后不执行
    }
    catch (const string& errmsg) {
        // 捕获一个 string 类型的异常
        cout << "捕获到异常:" << errmsg << endl;
    }
    cout << "程序继续执行" << endl;
    return 0;
}

逐行理解上面的代码。在 Divide 函数里,当 b == 0 时,我们把一个 string s 通过 throw s; 抛了出去。请体会一个关键点:当 throw 执行时,throw 之后的语句将不再被执行。所以在 Divide 里,throw s; 一旦执行,就永远到不了 return (double)a / (double)b; 那一行。

程序的执行流,从 throw 的那个位置,直接"跳"到了与它匹配的 catch 块。这个 catch 可能在同一个函数里,也可能在调用链上更外层的另一个函数里。无论多远,控制权都从 throw 位置转移到了 catch 位置。这就好比你在电梯里按下求救按钮,电梯服务台在几百米外接起电话——中间隔着很多东西,但求救信息瞬间就到了。

为了更贴近真实,我把例子扩展成三层调用链:main 调用 Func,Func 调用 Divide。看一看异常是怎么沿着调用链一路找上去的:

#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;
 
double Divide(int a, int b) {
    try {
        if (b == 0) {
            string s("Divide by zero condition!");
            throw s;                       // 抛出 string
        }
        else {
            return (double)a / (double)b;  // 正常返回
        }
    }
    catch (int errid) {
        // 这个 catch 只接 int 类型,接不住 string
        cout << "Divide 里的catch: " << errid << endl;
    }
    return 0;                              // 出错但没人接时兜底返回
}
 
void Func() {
    int len = 10, time = 0;                // 模拟输入
    try {
        cout << Divide(len, time) << endl; // Divide 抛出 string
    }
    catch (const char* errmsg) {
        // 这个 catch 只接 const char*,也接不住 string
        cout << "Func 里的catch: " << errmsg << endl;
    }
    cout << "Func 函数本行执行" << endl;    // 注意这行
}
 
int main() {
    try {
        Func();
    }
    catch (const string& errmsg) {
        // 这个 catch 接 const string&,接住了!
        cout << "main 里的catch: " << errmsg << endl;
    }
    return 0;
}

这段代码演示了异常传播的三个要点:

要点一:异常会一路向上传递。 Divide 抛出 string,它自己的 catch (int) 接不住(类型不匹配),Func 的 catch (const char*) 也接不住,最后 main 的 catch (const string&) 接住了。异常不挑剔接收者在哪一层,只要类型匹配对它就行。

要点二:异常传播过程中,有些代码会被"提早退出"。 你看 Func 里那行 cout << "Func 函数本行执行" << endl;——它在 try 块之外的函数体末尾。当异常在 Func 里没被接住、往上抛到 main 时,Func 函数就"提早退出"了,那行打印根本不会执行。「沿着调用链的函数可能提早退出」这句来自课件的话,就是这个意思。这也能回答一个常见困惑:"我明明在函数末尾写了清理代码,为什么没执行?"——因为异常把它跳过了,这正是异常和资源的恩怨情仇所在,后面异常安全一节会专门展开。

要点三:throw 的对象会被拷贝。 在 Divide 里抛出的其实是一个局部对象 s。s 是 Divide 栈上的局部变量,Divide 一旦退出它就没了。但异常对象需要活下去,直到 catch 把它接住为止。所以编译器在 throw s; 时,会生成一个 s 的拷贝,这个拷贝放在一块安全的、专门为异常管理的内存里,等 catch 子句处理完才销毁。这个过程和处理函数的"传值返回"非常相似——你可以理解为:throw 实际上做了一次"按值返回异常对象"的动作。

这里值得把"异常对象放在哪"这件事讲得再深一层。因为抛出的对象必须活到所有 catch 处理完毕,它不能放在函数的栈帧上(栈帧会随函数退出而销毁)。所以在 Itanium C++ ABI(GCC / Clang 在 x86-64、ARM 等平台的通用底层约定)下,throw 会被编译器改写成两个底层调用:先调用 __cxa_allocate_exception 在**堆上(或栈上的一块兜底应急缓冲)**申请一块内存,把异常对象"搬"进去,再调用 __cxa_throw 真正启动传播;一路传播、被某个 catch 接住、处理完毕之后,再由底层调用该异常对象的析构函数并释放这块内存。MSVC 的实现细节不同(它走的是基于 Windows SEH 的 _CxxThrowException 与每线程的异常槽位),但"异常对象有独立于局部栈帧的存储、生命周期够长"这一点是共通的。这就是为什么我们说"捕获异常最好用引用"的又一个理由——用值捕获会多一次不必要的拷贝,而捕获对象本体此刻明明就一直在那里。

另外还有个更微妙的点(C++11 起):抛出的对象是从 throw 操作数拷贝(或移动)构造出来的,而"能否移动"取决于该类型的移动构造是否可用。所以你在 throw s; 时,s 既可能被拷贝、也可能被移动构造到那个"安全存储"里——但无论哪种,s 这个局部变量本身在函数退出时都会被正常析构。C++ 标准仓库里哪怕再好的编译优化(copy elision)也不能无限跨界,异常对象的构造始终伴随一次针对 throw 表达式的拷贝或移动构造。

还要提醒一个初学者常见误区:抛异常用的是值语义,不是抛指针。throw new std::string("...") 这种写法在语法上合法,但几乎总是错——因为没有标准途径保证谁去 delete 这个指针,而且捕获方必须写成 catch (std::string* p) 并记得手动释放,异常安全荡然无存。永远记住三条铁律:扔对象(throw by value)、接引用(catch by reference),具体原因在后面的"自定义异常类"和"取消切割"处会彻底展开。

另外,还有一个"哪里能写 throw"的边界问题。throw 表达式; 可以出现在能被求值的任何位置:函数体内、if 里、三元表达式里、甚至某个真正被调用的函数内部。它的类型决定后续匹配。比如 throw 42; 抛的是 int,throw std::runtime_error("x"); 抛的是临时构造出来的 std::runtime_error 对象。只要你能写出一个表达式,你就能 throw 它的值——但实践上我们只 throw 对象、并且是带丰富信息的异常类,而不是裸的 int 或裸指针。

现在你该理解那句定义了:被选中的处理代码,是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。 抛出对象的类型和内容,决定了异常处理部分到底被告知了什么错误。

异常的匹配规则与 catch 的顺序

上一节你已经看到:异常是要"按类型匹配"的。那么匹配的规则究竟是什么?标准对"抛出对象与 catch 参数之间允许的转换"给了非常明确的限制。一般规则是:抛出对象和 catch 类型必须完全匹配,且"完全匹配"指的是异常对象的静态类型与 catch 参数类型本身匹配(允许忽略 cv 限定),不是像普通函数重载那样玩一系列隐式转换。 如果多个 catch 类型都能匹配,就选离异常抛出位置最近的那个。

先澄清一个最常见的误解,用一个 demo 说明:匹配不是"可隐式转换就行"。 很多人以为 throw 42;(抛 int)能被 catch (double) 接住(毕竟 int 可以语义上转成 double),但标准明确不允许这种隐式算术转换参与异常匹配。写一个程序验证:

#include <iostream>
using std::cout;
using std::endl;
 
int main() {
    try {
        throw 42;              // 抛出一个 int
    }
    catch (double d) {
        // int 可以转 double,但异常匹配不允许这种算术转换
        cout << "catch double: " << d << endl;
    }
    catch (int i) {
        // 必须精确匹配 int,这里才接得住
        cout << "catch int: " << i << endl;
    }
    return 0;
}

运行结果只有 catch int: 42。那个 catch (double) 永远轮不到——尽管 42 放在普通赋值表达式里可以给 double。在异常匹配里,"看类型行事"的精确度被大大提高了,它几乎不做算术转换。 这最直观地体现了异常的"强类型":抛出去的到底是什么类型,就只认那个类型。

那么在精确匹配之外,标准还开了哪几个例外?一共四条:

  1. 从非常量到常量的转换(权限缩小):抛出一个 string,既可以用 catch (string) 接,也可以用 catch (const string&) 接。道理和函数传参时"非常量对象可以绑定到 const 引用"一致:你只是许诺"我只读它,不改它"。
  2. 数组到指向元素类型的指针、函数到指向函数的指针:抛出 const char[8] 数组可以退化成 const char* 被接住,抛出函数可以退化成函数指针被接住。
  3. 从派生类到基类的转换:一个 Derived 类型的异常,可以被 catch (Base&) 接住。这条非常实用——实际工程里设计异常体系,几乎都是靠这一条,用"捕获基类"来一网打尽某个大类之下的所有异常。注意,反方向的"从基类到派生类"不允许:你抛一个 Exception,是不会被 catch (HttpException&) 接住的。
  4. catch (...):匹配任何类型(下一节细讲)。

先说第 3 条——它是最常用、也最考人的。看一个演示派生类到基类转换的经典例子。捕获基类引用,派生类对象也能被接住:

#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;
 
// 基类:表示一个"通用的错误"
class Exception {
public:
    Exception(const string& errmsg, int id)
        : _errmsg(errmsg), _id(id) {}
    virtual string what() const {   // 虚函数:返回错误描述
        return _errmsg;
    }
    int getid() const {             // 返回错误编号
        return _id;
    }
protected:
    string _errmsg;                 // 错误描述
    int _id;                        // 错误编号
};
 
// 派生类:表示"数据库 SQL 错误"
class SqlException : public Exception {
public:
    SqlException(const string& errmsg, int id, const string& sql)
        : Exception(errmsg, id), _sql(sql) {}
    virtual string what() const override {
        return "SqlException:" + _errmsg + "->" + _sql;
    }
private:
    const string _sql;              // 出错的那条 SQL
};
 
int main() {
    try {
        // 抛出一个 SqlException(派生类)
        throw SqlException("权限不足", 100, "select * from name = '张三'");
    }
    catch (const Exception& e) {
        // 用基类引用捕获!派生类对象也被接住
        // 调用虚函数 what,动态绑定到 SqlException::what
        cout << e.what() << endl;
    }
    return 0;
}

这里有个必须说透的技术细节:为什么要写 catch (const Exception& e) 而不是 catch (Exception e)? 两个原因,缺一不可。

其一,捕获基类时如果用值(而非引用),会发生对象切割(slicing)——派生类里多出来的成员(比如 _sql)会被切掉,对象"退化"成纯基类,你丢失了具体信息。catch (Exception e) 会用一个拷贝构造函数从异常对象上拷一份出来,而这个拷贝构造使用的静态类型是 Exception,于是它只会"拷贝基类那一部分",派生类的专有数据被无情切掉。

其二,what() 是虚函数,只有通过引用或指针调用才会触发动态绑定(多态),才能调用到派生类重写的版本。如果按值捕获,虚函数机制就会失效,你拿到的是基类版本的 what()。也就是说,即便你没把派生类数据当回事,按值捕获也会让你永远听不到派生类重写过的声音。

为了把"切片"的恶果亲眼看到,写一个对照 demo——这次故意用按值捕获,看派生类信息如何被切掉:

#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;
 
class Exception {
public:
    Exception(const string& errmsg) : _errmsg(errmsg) {}
    virtual string what() const { return "Base:" + _errmsg; }
protected:
    string _errmsg;
};
 
class HttpException : public Exception {
public:
    HttpException(const string& errmsg, const string& type)
        : Exception(errmsg), _type(type) {}
    virtual string what() const override {
        return "Http:" + _type + ":" + _errmsg;   // 派生类重写,带 _type
    }
private:
    const string _type;                            // _type 是我们关心的现场信息
};
 
int main() {
    try {
        throw HttpException("404 not found", "get");
    }
    catch (const Exception e) {   // 按值捕获 → 切片!
        // 派生类的 _type 丢了;what 调用的也是基类版本
        cout << e.what() << endl;   // 输出只能是 "Base:404 not found"
    }
    return 0;
}

运行结果打印的是 Base:404 not found——Http: 前缀和 get 这个类型信息全没了。这就是行业铁律的来源:捕获异常一定要用引用(最好是 const 引用)。 用引用既避免了切片,也保留了多态,同时也省掉了一次无谓的拷贝开销,一石三鸟。

再看catch 的顺序。因为派生类可以转成基类,所以多个 catch 的书写顺序极其重要——必须把派生类的 catch 写在基类的前面,把更具体的写在更前面,最通用的 catch (...) 写在最后。否则一旦基类 catch 在前,派生类的异常就被它"截胡"接走了,后面那个更具体、更想针对性处理的 catch 永远轮不到。

#include <iostream>
#include <stdexcept>
using std::cout;
using std::endl;
 
int main() {
    try {
        // std::runtime_error 是 std::exception 的派生类
        throw std::runtime_error("运行时错误");
    }
    catch (const std::runtime_error& e) {
        // 具体类在前:能走到这里
        cout << "runtime_error: " << e.what() << endl;
    }
    catch (const std::exception& e) {
        // 基类在后:上面的异常不会走到这里
        cout << "std::exception: " << e.what() << endl;
    }
    catch (...) {
        // 兜底
        cout << "未知异常" << endl;
    }
    return 0;
}

如果把顺序反过来(先 std::exception 再 std::runtime_error),那么抛出的 runtime_error 会先被 std::exception 接住,后面那个专门处理 runtime_error 的 catch 就变成了"死代码"(永远执行不到),编译器还会给出"不可达"或"此 handler 不会被执行"的警告。这个顺序问题,是初学异常时最常见的坑之一,务必记住。

再补一个容易踩的顺序坑:catch (int) 和 catch (const int&) 会同时匹配同一个 throw 3,谁在前谁生效,后写的那个永远执行不到。 因为抛出的 int 既满足 catch (int),也满足 catch (const int&)(权限缩小)。所以别写两个这种"同样东西"的 catch,写了就是自找麻烦。示例:

#include <iostream>
using std::cout;
using std::endl;
 
int main() {
    try {
        throw 100;
    }
    catch (const int& e) {   // 值/引用都匹配 int,出现顺序决定胜负
        cout << "const int& 接住: " << e << endl;
    }
    catch (int e) {           // 这是死代码:上面的 const int& 已经先接住了
        cout << "int 接住(不会执行): " << e << endl;
    }
    return 0;
}

现在把"匹配规则"浓缩成一张记忆表,常看常新:

抛出对象类型能接住的 catch 参数说明
intcatch (int)、catch (const int&)精确匹配
intcatch (double)❌ 异常匹配不做算术转换
const char[N]catch (const char*)数组退化为指针
Derivedcatch (Base&)、catch (const Base&)派生类到基类,最常用
Basecatch (Derived&)❌ 基类到派生类不允许
任意类型catch (...)兜底

匹配的本质,是在"强类型 + 只认那几种被明令允许的转换 + 就近原则"三重约束下,把异常对象交到正确的人手里。正是这份"强类型",让异常能精确表达"你是谁、你错在哪"。

catch(...) 捕获一切

在上一节的兜底 demo 里已经出现了 catch (...),它的三个点是"省略号",意思是"匹配任意类型"。它的存在解决了一个现实问题:你的函数可能抛出一个你根本没有预料到的异常类型。比如某些第三方库可能抛出一个奇怪的枚举类型、一个裸指针、甚至一个 int。如果你只写了针对 std::exception 的 catch,这些"野"异常就会漏出去。

课件里特别强调了一个工程实践:如果不是发生严重错误,我们通常不希望程序因为一个未捕获的异常而终止。所以一般在 main 函数的最后,都会放一个 catch (...) 作为"最后防线",确保任何异常都不会逃出程序导致崩溃。它的威力在于能兜住一切类型。

但有一个明确的遗憾:catch (...) 虽然能捕获任意类型的异常,它却完全不知道异常的内容是什么。接收异常所需的"参数"被省略号替换掉了,你在 catch 块里没有任何变量名可用——你只知道"出事了一件坏事",但不知道具体是什么事。所以它通常只用来做最后一层的日志或兜底,真正的精细化处理还是靠前面的具体 catch。

看一个真实服务里"基类捕获 + catch(...) 兜底"的组合用法:

#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;
 
class Exception {
public:
    Exception(const string& errmsg, int id)
        : _errmsg(errmsg), _id(id) {}
    virtual string what() const { return _errmsg; }
    int getid() const { return _id; }
protected:
    string _errmsg;
    int _id;
};
 
class HttpException : public Exception {
public:
    HttpException(const string& errmsg, int id, const string& type)
        : Exception(errmsg, id), _type(type) {}
    virtual string what() const override {
        return "HttpException:" + _type + ":" + _errmsg;
    }
private:
    const string _type;
};
 
int main() {
    try {
        // 这里抛出一个我们认识的派生类异常
        throw HttpException("请求资源不存在", 100, "get");
    }
    catch (const Exception& e) {
        // 先尝试用基类引用接收一切已知异常
        cout << e.what() << endl;
    }
    catch (...) {
        // 最后防线:兜住所有无法识别的野异常
        cout << "UNKNOWN EXCEPTION" << endl;
    }
    return 0;
}

这个模式就是大型项目通用的"异常见底"套路:具体业务异常用基类引用接住、识别、处理;剩下的、无法识别的异常用 catch (...) 兜底,保证程序不至于直接崩溃。你可以把 catch (...) 理解成消防通道的"应急灯"——平时你可能根本用不到它,但它在那,你就心安。

关于 catch (...) 有几个重要的边界,值得掰清楚:

第一,catch (...) 是"只兜底、不诊断"的。 它拿不到任何关于异常类型或内容的信息,所以它只能做三件事:记录一条"出错了"的日志、做一些不依赖异常内容的资源清理(比如复位一个全局状态)、以及——把异常继续抛给外层。它不能用来做"按错误类型分派"的精处理,那是具体 catch 的活。

第二,catch (...) 和 catch (const Exception& e) 的顺序。 因为 catch (...) 匹配一切,它一旦出现在前面,后面的任何具体 catch 全变成死代码。所以纪律是:catch (...) 一定写在最后。编译器对这种情况同样会给出"不可达 handler"的警告。

第三,也是力量所在:在 catch (...) 内部,你依然可以用 throw; 把当前异常原样抛出去(下一节会讲),这给了"兜底清理但保留错误信息"的能力。 典型用法是:中层函数不知道某个异常具体是什么,但它需要确保"无论什么异常,我先把该清的资源清干净",然后再把异常原样交给上层。这时就用 catch (...) { 清理; throw; }。这是"捕获后重抛"最常见的生产场景之一。

第四,throw; 是唯一能"拿住" catch (...) 背后异常的手段。 因为 catch (...) 没有参数名、拿不到异常对象本身,你无法把它作为值再抛出去——但你仍然可以用裸 throw; 原封不动地转发。换句话说,catch (...) + throw; 的组合,就是标准的"擦屁股 + 甩锅"两件套:我在这一层把烂摊子收拾好,但责任不背,让上面的人去决定怎么骂。

关于 bad_alloc 的兜底还有个有趣的补充:throw ... catch(...) 不能捕获真正的硬件级 fault(比如除零的硬件陷阱、段错误 SegFault),那些是 CPU / 操作系统层面的"信号",不是 C++ 异常。catch (...) 只接得住 C++ 的 throw 抛出来的东西。这一点很多人混淆,特此澄清。

自定义异常类

工程上抛异常,最忌讳的就是随手抛一个裸的 int 或者 const char*(虽然语法完全允许)。原因前面分析过:一个裸类型承载不了丰富的结构化信息,也没法和"捕获基类一网打尽"的多态体系配合。正确做法是:自己设计异常类体系,让所有异常都继承自一个公共基类,各业务模块的异常成为它的派生类。

这种设计的巨大威力在于:虽然每个模块抛出的具体异常类型不同、携带的信息不同,但外层的调用者只需要捕获基类引用,就能同时接住所有派生异常,再通过虚函数 what() 拿到各自的详情。你不需要为每一类异常写一个个 catch——那会写到手软。

课件里给了一个生动的模拟:设计一个 HTTP 服务,由数据库模块(SQL)、缓存模块(Cache)、网络模块(HTTP)三层组成,每一层都可能出错,且各层的错误信息各不相同。我们让它们都继承自同一个 Exception 基类,各自重写 what() 携带自定义信息:

#include <iostream>
#include <string>
#include <ctime>
#include <thread>
#include <chrono>
#include <cstdlib>
using std::cout;
using std::endl;
using std::string;
 
// ── 公共基类:所有模块异常的总接口 ──
class Exception {
public:
    Exception(const string& errmsg, int id)
        : _errmsg(errmsg), _id(id) {}
    virtual string what() const {       // 虚函数,子类都可重写
        return _errmsg;
    }
    int getid() const {                 // 获取错误编号
        return _id;
    }
protected:
    string _errmsg;                     // 描述性错误信息
    int _id;                            // 错误编号
};
 
// ── SQL 模块的异常:额外携带出错的那条 SQL ──
class SqlException : public Exception {
public:
    SqlException(const string& errmsg, int id, const string& sql)
        : Exception(errmsg, id), _sql(sql) {}
    virtual string what() const override {
        return "SqlException:" + _errmsg + "->" + _sql;
    }
private:
    const string _sql;
};
 
// ── 缓存模块的异常 ──
class CacheException : public Exception {
public:
    CacheException(const string& errmsg, int id)
        : Exception(errmsg, id) {}
    virtual string what() const override {
        return "CacheException:" + _errmsg;
    }
};
 
// ── HTTP 模块的异常:额外携带请求类型 get/post ──
class HttpException : public Exception {
public:
    HttpException(const string& errmsg, int id, const string& type)
        : Exception(errmsg, id), _type(type) {}
    virtual string what() const override {
        return "HttpException:" + _type + ":" + _errmsg;
    }
private:
    const string _type;
};
 
// ── 三个模拟模块:以一定概率随机抛异常 ──
void SQLMgr() {
    if (rand() % 7 == 0) {               // 约 1/7 概率失败
        throw SqlException("权限不足", 100, "select * from name = '张三'");
    } else {
        cout << "SQLMgr 调用成功" << endl;
    }
}
 
void CacheMgr() {
    if (rand() % 5 == 0) {
        throw CacheException("权限不足", 100);
    } else if (rand() % 6 == 0) {
        throw CacheException("数据不存在", 101);
    } else {
        cout << "CacheMgr 调用成功" << endl;
    }
    SQLMgr();                            // 缓存没问题,继续调 SQL
}
 
void HttpServer() {
    if (rand() % 3 == 0) {
        throw HttpException("请求资源不存在", 100, "get");
    } else if (rand() % 4 == 0) {
        throw HttpException("权限不足", 101, "post");
    } else {
        cout << "HttpServer 调用成功" << endl;
    }
    CacheMgr();                          // 网络没问题,继续调缓存
}
 
int main() {
    srand((unsigned int)time(nullptr));  // 用当前时间播种随机数
    while (true) {
        std::this_thread::sleep_for(std::chrono::seconds(1));  // 每1秒跑一轮
        try {
            HttpServer();                // 整个服务层的入口
        }
        catch (const Exception& e) {
            // 只写一个 catch!三种派生异常全部接住
            // 虚函数 what() 会动态分派到各自的重写版本
            cout << e.what() << endl;
        }
        catch (...) {                    // 兜底
            cout << "UNKNOWN EXCEPTION" << endl;
        }
    }
    return 0;
}

这段代码把自定义异常类的全部精华都展示出来了:

  • 每个模块的异常都是 Exception 的派生类,能额外携带自己的专属数据(_sql、_type);
  • 每个派生类都重写了虚函数 what(),把模块名、错误编号、详细信息拼装成一条完整可读的错误报告;
  • 最外层 main 里只有一个 catch (const Exception& e),就统一接住了 SqlException、CacheException、HttpException 这三种类型;
  • 调用 e.what() 时,因为捕获的是引用且 what() 是虚函数,触发动态绑定(多态),会精确打印出抛出的那个派生类的信息。这就是"捕获基类、识别具体"的设计红利。

在设计自定义异常类时,有几条"老司机"级的经验,一并送给你:

一是基类最好有设计良好的虚析构函数。 异常对象在被 catch 处理完后,要调用它的析构函数;如果 catch 用的是基类引用,而基类的析构函数不是虚的,那按 C++ 的多态规则,析构时只会调基类析构、派生类析构被漏掉(这是"多态对象必须配虚析构"的铁律,异常类同样适用)。示例里的异常类成员基本是 string、int 这种"自带析构"的东西,所以看不出问题;但如果你在派生异常里嵌套了裸指针资源,没有虚析构就会泄漏。给你的异常基类加一个 virtual ~Exception() = default;(或 virtual ~Exception() noexcept {})是稳妥答案。

二是一个异常类型最好配套一个数值错误码(_id)+ 可读消息(_errmsg)。 因为上层有时需要"机器可判断"(用 getid() 做分支,比如"网络错误编号 102 可以重试"),有时需要"人类可阅读"(用 what() 打印日志)。两者分开存,是工程共识。

三是记住 what() 的经典受约:它应当是 no-throw 的。 因为 what() 常常在错误处理路径(甚至 catch 块里、日志系统里)被调用,如果它本身会抛异常,那就在错误路径上又点燃一根导火索。标准要求 std::exception::what() 不抛,你自己的 what() 也应如此——实现时避免在其中做可能分配内存的操作(比如反复拼接大字符串)。教学示例里为了直观用了字符串拼接,那是为讲清楚语义做的简化;真实库里 what() 里要格外克制。

你只需要记住这个设计套路,以后在自己的项目里照葫芦画瓢即可:建一个公共异常基类(最好继承自 std::exception,下一节标准库部分会说),给所有模块建异常子类,外层一律捕获基类引用,利用虚函数 what() 输出详情。

栈解旋(栈展开)的原理

栈解旋可能是异常机制里最值得深挖的概念。先问你一个我们前面一直悬着的问题:异常沿着调用链从深层函数一路向上冒泡时,那些沿途函数的局部对象到底谁负责销毁?

答案是:异常机制会"倒着"把调用链上已经构造好的对象一个个析构掉。这个过程,就是栈解旋(stack unwinding,也叫栈展开)。

先复习一个前置知识:什么是调用栈?当你调用一个函数时,计算机会用一段叫"栈"的内存(栈从高地址向低地址增长)记录调用的过程。main 调用 Func,Func 调用 Divide,那么栈上就叠了三层"栈帧":最下面是 main 的 main 帧,中间是 Func 帧,最上面是 Divide 帧。每个栈帧里装着该函数的局部变量。函数正常 return 时,它的栈帧会被弹出销毁,局部对象随之析构。

异常发生时,发生的是一连串非正常的提前弹出:

  1. 在 Divide 里 throw s;,先暂停 Divide 的执行;
  2. 查找匹配的 catch:先看 throw 是否在 Divide 自己的 try 块里;如果在且有匹配 catch,就直接跳到那个 catch 处处理;
  3. 如果 Divide 没有 try/catch,或者有了但类型不匹配,那么 Divide 就地退出,Divide 的栈帧弹出、里面的局部对象析构,然后异常继续往调用链的上层走;
  4. 重复第 2、3 步,一层一层地剥掉栈帧、析构对象、向上寻找;
  5. 直到 main:如果找到了匹配的 catch,就进 catch 继续执行;如果连 main 都找不到匹配的 catch,程序会调用标准库的 terminate 函数,直接终止进程。

上面这个过程——异常一路向上剥栈帧、沿途析构对象、寻找匹配 catch——就是栈解旋(栈展开)的完整画面。标准规定:栈解旋过程中,所有构造完成的局部对象都会被自动析构。

在这套"物理过程"的背后,底层其实还有更精致的结构,值得展开讲讲,因为它能帮你理解"为什么异常在无异常的快乐路径上几乎零成本、一抛出却很贵"。在 Itanium ABI(GCC/Clang)下,异常传播分两个阶段完成:

  • 第一阶段(探查/寻找):运行时系统沿着栈帧往回走,查阅每个函数编译时生成的那张"函数表"(.gcc_except_table,里面记录了每个区域可能处理哪些类型的异常、代码跳到哪个清理/捕获取向代码块 landing pad),找到第一个能匹配的 catch 所在位置。这个阶段不执行任何用户代码,只是"勘察地形"。
  • 第二阶段(解旋):从抛出点一路向上,对每个栈帧调用该帧内所有已构造局部对象的析构函数,执行各帧的清理动作,直到进入第一阶段找到的那个 catch。这个阶段才是我们说的"栈解旋"在真实运行时的体现。

编译器之所以能降低快乐路径开销,是因为它在正常代码里几乎不插入异常相关的运行时检查,而是把大量处理逻辑(异常表、landing pad)放到"冷区"(很少执行到的代码段);只有真的抛异常,才走那条昂贵的异常表查找 + 两阶段解旋路径。这就是常说的"零开销异常的模型"——但注意,"零开销"严格说指的是"没抛异常时几乎无额外成本",一旦真抛了,代价相当可观(这也解释了后面"性能热路径别用异常"的建议)。

还要注意一个细节:栈解旋只照顾"自动存储期"的对象(局部栈对象)。静态对象(static 局部变量)、thread_local 对象、以及全局对象,在栈解旋中不会被析构——它们不属于任何人的栈帧,异常路过时不会顺便清理它们。这也是为什么 RAII 要强调的是"把资源放进栈对象的生命周期",而不是图省事塞进 static 里。

这是一条极其有价值、也极其容易出问题的保证。有价值,是因为它给了"自动清理"的基石:只要你的资源是用带析构函数的对象管理的(RAII),那么异常一路解开时,资源会被自动、确定不遗漏地释放。容易出问题,是因为它引出了两个著名的坑(后面专门讲):析构函数里抛异常会二次引爆;以及如果对象没有 RAII 封装(比如裸 new 出来的指针),栈解旋并不会帮你 delete 它,只会"看着"指针变量被销毁——于是内存泄漏。

用一个实验来亲眼验证栈解旋。我们设计一个带析构打印的类,在多层调用链中层层构造对象,然后抛异常,观察析构函数被一路调用上来:

#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;
 
// 一个用来"观察"构造与析构时机的类
struct Observer {
    string name;
    Observer(const string& n) : name(n) {
        cout << "构造: " << name << endl;
    }
    ~Observer() {
        cout << "析构: " << name << endl;
    }
};
 
void Level3() {
    Observer o3("Level3 的 o3");     // 栈帧上的局部对象
    throw 42;                        // 抛出异常,下面的语句不执行
    cout << "Level3 末尾,不执行" << endl;
}
 
void Level2() {
    Observer o2("Level2 的 o2");
    Level3();                        // 异常从这里抛上来
    cout << "Level2 末尾,不执行" << endl;
}
 
void Level1() {
    Observer o1("Level1 的 o1");
    try {
        Level2();
    }
    catch (int e) {                  // 在 Level1 接住 int 异常
        cout << "Level1 捕获到 int: " << e << endl;
    }
    cout << "Level1 末尾,正常执行" << endl;
}
 
int main() {
    Level1();
    return 0;
}

这个程序的输出非常能说明问题(顺序打印):

构造: Level1 的 o1
构造: Level2 的 o2
构造: Level3 的 o3
析构: Level3 的 o3
析构: Level2 的 o2
Level1 捕获到 int: 42
Level1 末尾,正常执行
析构: Level1 的 o1

仔细品一下这个输出顺序,它就是这个知识点全部的精髓:

第一段:正常的构造。 Level1 → Level2 → Level3 逐层进入,三个 Observer 依次构造。这就是"先进后出"的栈。

第二段:栈解旋。 Level3 抛出 42。因为 Level3 和 Level2 都没有合适的 catch(Level2 里根本没有 try),所以异常先让 Level3 退出,析构 o3;再让 Level2 退出,析构 o2——析构顺序和构造顺序完全相反(后构造的先析构,这是栈的本性)。然后异常到达 Level1 的 try 里,被 catch (int) 接住。

第三段:catch 处理 + 正常继续。 进入 Level1 的 catch,打印"捕获到 int: 42",然后 catch 之后的普通代码继续执行,打印"Level1 末尾,正常执行"。

第四段:Level1 正常 return。 最后 Level1 正常返回,o1 被正常析构,打印"析构: Level1 的 o1"。注意,o1 不是在栈解旋时销毁的,而是因为 Level1 成功处理了异常、正常退出函数时才销毁的——它从来不在异常路径上出问题。

这个实验,请一定自己跑一遍,眼见为实。它把"异常一路传播时析构被一路调用"这个抽象概念变得无比具体。栈解旋不是一句口号,它是每次异常发生时的真实物理过程。

再强调一个边界:如果异常一路解旋到 main,仍然没有匹配的 catch,standard 库函数 terminate 会被调用,程序直接终止。关于这一点,微软文档与 cppreference 一致确认:未捕获的异常最终会导致调用 std::terminate,其默认行为是调用 abort,进程随之结束,控制权交还操作系统。 因为默认的 terminate 行为是调用 abort,而 abort 不执行栈解旋、不调用任何析构函数——所以你连"善后"的机会都没有,资源、缓冲、日志统统不进保存。这意味着你要么在某个中间层把异常处理好,要么用前面讲的 catch (...) 在 main 收口。

顺带一提,std::terminate 其实是一个可以被替换的分发函数:标准库 <exception> 提供了 std::set_terminate,你可以注册一个自定义的"临终处理函数",在程序即将崩溃前写一条终极日志、或 dump 现场。但要清醒:它只负责"临终致辞",注册的处理器运行结束后,程序依然会以 abort 之类的方式终止,你不能在 terminate 处理器里"救活"程序。它更像火葬场的司仪,而不是急诊医生。

异常的重新抛出

上一节我们看到,异常被 catch 接住后,catch 块里的代码会继续执行,然后 try/catch 外面的代码也照常往下走。这就是"异常被处理掉了"的正常路径。

但有时我们想玩一个更高级的操作:catch 到异常后,自己只做一部分处理(比如记录日志、释放资源、分类),然后把同一个异常原样再扔出去,交给更外层的人继续处理。这就是重新抛出(rethrow),语法极其简单——一个光秃秃的 throw;,不带任何表达式。

这里有个必须说透的真相:throw;(不带值)只能出现在 catch 块内部,它的作用是把"当前正在被 Catch 处理的这个异常对象"原封不动地重新抛出。注意"原封不动"四个字——它抛出的不是那个 catch 参数的值拷贝,而是当前的异常对象本身,连类型、动态类型(如果有多态就保持多态)都会原样保留。

从底层看,throw; 重抛走的是"依赖异常(dependent exception)"机制:它并不会重新做一份拷贝,而是用一个新的"轻量壳"去引用那个还没被销毁的原始异常对象。所以重抛既保持了多态信息,又多了一次拷贝的成本也省掉了——这正是throw; 和 throw e; 在效率上也高下立判的地方。

这点非常关键,也很有迷惑性。想象你在 catch (const Exception& e) 里看到它的参数 e,e 是被当作 Exception 基类引用接住的。如果此时你用 throw e;(带值),那你抛出去的其实是 e 的拷贝,而 e 的静态类型是 Exception —— 多态信息丢失,切成了基类。但如果你用 throw;(不带值),那么抛出去的是那个原始的、动态类型完整的异常对象。这正是工程里坚持用 throw; 重新抛出的根本原因:它保证"捕获到什么就原样抛出什么",绝不做任何类型退化。

为了把"重抛 vs 用参数重抛"的差别钉进脑子,写一个直观对比 demo:

#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;
 
class Exception {
public:
    Exception(const string& msg) : _msg(msg) {}
    virtual string what() const { return "Base:" + _msg; }
protected:
    string _msg;
};
 
class NetException : public Exception {
public:
    NetException(const string& msg) : Exception(msg) {}
    virtual string what() const override { return "Net:" + _msg; }  // 派生类重写
};
 
// 错误示范:用参数 e 再抛一次 → 切片成基类
void badRethrow() {
    try {
        throw NetException("timeout");
    }
    catch (const Exception& e) {
        throw e;          // e 的静态类型是 Exception → 抛出去就成了极类切片!
    }
}
 
// 正确做法:裸 throw; → 原样重抛,动态类型保留
void goodRethrow() {
    try {
        throw NetException("timeout");
    }
    catch (const Exception& e) {
        throw;            // 原样抛回动态类型完整的 NetException
    }
}
 
int main() {
    try         { badRethrow();  }
    catch (const NetException& e) {   // 根本接不到!来的已是切片后的基类
        cout << "bad   -> 接住 NetException: " << e.what() << endl;
    }
    catch (const Exception& e) {      // 只能退而求其次接到基类切片
        cout << "bad   -> 被迫接住 Exception(切片): " << e.what() << endl;
    }
 
    try         { goodRethrow(); }
    catch (const NetException& e) {   // 这才是原样到达
        cout << "good  -> 接住 NetException: " << e.what() << endl;
    }
    return 0;
}

运行结果会清晰显示:bad 那一路因为切片,最想接的 catch (const NetException&) 接了个寂寞,只能被基类 catch 兜住,what() 打印的还是基类版本;而 good 那一路,catch (const NetException&) 精准命中,what() 打印的是派生类重写的内容。一个小小的 throw; vs throw e;,天壤之别。

课件里给了一个特别生活化的例子——发消息时的重试逻辑:

#include <iostream>
#include <string>
#include <cstdlib>
#include <ctime>
using std::cout;
using std::endl;
using std::cin;
using std::string;
 
// 公共异常基类
class Exception {
public:
    Exception(const string& errmsg, int id)
        : _errmsg(errmsg), _id(id) {}
    virtual string what() const { return _errmsg; }
    int getid() const { return _id; }
protected:
    string _errmsg;
    int _id;
};
 
class HttpException : public Exception {
public:
    HttpException(const string& errmsg, int id, const string& type)
        : Exception(errmsg, id), _type(type) {}
    virtual string what() const override {
        return "HttpException:" + _type + ":" + _errmsg;
    }
private:
    const string _type;
};
 
// 底层发送:可能抛"网络不稳定"或"已不是好友"
void _SeedMsg(const string& s) {
    if (rand() % 2 == 0) {
        throw HttpException("网络不稳定,发送失败", 102, "put");
    } else if (rand() % 7 == 0) {
        throw HttpException("你已经不是对象的好友,发送失败", 103, "put");
    } else {
        cout << s << " 发送成功" << endl;
    }
}
 
// 发送层:网络差就重试,最多重试 3 次;不是网络问题就重新抛出
void SendMsg(const string& s) {
    for (size_t i = 0; i < 4; i++) {       // 尝试 1 次 + 重试 3 次 = 4 轮
        try {
            _SeedMsg(s);                   // 尝试发送
            break;                         // 成功就跳出循环
        }
        catch (const Exception& e) {
            if (e.getid() == 102) {        // 102 号 = 网络不稳定,可重试
                if (i == 3) {
                    throw;                 // 三次重试均失败,重新抛出
                }
                cout << "开始第" << i + 1 << " 次重试" << endl;
            } else {
                throw;                     // 非网络问题(如 103),直接重新抛给外层
            }
        }
    }
}
 
int main() {
    srand((unsigned int)time(nullptr));
    string str;
    while (cin >> str) {                   // 输入一行就尝试发一条
        try {
            SendMsg(str);
        }
        catch (const Exception& e) {
            cout << e.what() << endl << endl;
        }
        catch (...) {
            cout << "UNKNOWN EXCEPTION" << endl;
        }
    }
    return 0;
}

这段代码把"分类 + 重试 + 重抛"讲得很完整:

  • catch (const Exception& e) 接到异常后,通过 getid() 判断错误编号;
  • 编号 102 表示"网络不稳定"——这是可以重试的,于是打印重试提示,让 for 循环再走一圈;如果已经重试到底(i == 3)还失败,说明网络实在太差,用 throw; 重新抛出,交给 main 去曝光错误;
  • 编号不是 102(比如 103"已不是好友")——重试多少次都没用,所以也直接用 throw; 重新抛出,交给 main。

你在 main 的 catch (const Exception& e) 里拿到并打印的,正是 SendMsg 里用 throw; 抛上来的原始异常。多态信息完整,what() 能打出精确详情。

这个例子还顺带演示了"按错误码分类"和"重试语义"这对常见组合。注意:判断"能不能重试"用的是 getid()(一个可靠的、机器可读的整数),而不是把 what() 当字符串去比较。因为字符串比较脆弱(拼写、包装、版本都可能变化),而错误码是稳定契约。这是异常类设计里"数值 id 用于判断 + 文本 msg 用于展示"分分工的最直观理由。

这里特别提醒一个坑:重新抛出一定要写 throw;,千万不要想当然地写 throw e;。 前面解释过了,两者语义完全不同。这是异常相关面试题的高频考点,同时也是线上 bug 的高发地。

关于重抛还有几条边界,一并讲清:

其一,throw; 必须在某个正在活动的 catch 块里用——如果在一个没有正在处理的异常的上下文里执行 throw;,会是一个逻辑错误,程序会调用 std::terminate。编译器通常能抓住你写错了位置("rethrow outside of a catch block"之类的错误)。

其二,异常对象在重抛后依然只有一份。 你在一层 catch (const Exception& e) 里 throw; 之后,这层 catch 退出,但异常对象不会先被销毁再被重新投出去——它被"接住、处理、又被抛回"的整个过程里始终是同一个对象。这也从侧面解释了:为什么异常对象必须活得比任何一场栈解旋都久。

其三,"清理后重抛"是异常安全的核心招式。 想象一个中转层:它可能抛出一个你没见过的异常类型,但它必须确保自己占用的资源先被释放。标准解法就是 try { ... } catch (...) { 释放资源; throw; }——catch (...) 兜住一切、清理完再用 throw; 原样上抛。这跟后面 RAII 的"让析构自动清理"是两条平行路线,RAII 更优(不用手写),但当你处理的是"裸资源"或"必须在某一瞬间做特定收尾"时,这套手工"清理+重抛"依然是必选项。

构造函数与析构函数中抛异常的坑

异常机制有一套严苛的"纪律",其中最考验人的,是构造函数和析构函数这两个特殊的成员函数。它们的坑一个在"进",一个在"出",我们分开讲透。

构造函数中抛异常。

构造函数是造对象的入口。如果一个类在自己的构造函数里抛出了异常,会发生什么?这个对象根本没能被此刻"完全造出来",它的析构函数不会被调用。这听起来像是个 bug,但其实是合理的:因为你连构造都没成功,凭什么要求析构?一个从来没"降生"完整的对象,自然谈不上"殒落"。

但这里藏着一个非常容易被忽视的隐患:构造函数里已经成功构造的部分成员,它们的析构函数会被调用。什么意思呢?看下面:

#include <iostream>
#include <string>
#include <memory>
using std::cout;
using std::endl;
using std::string;
 
class Inner {
public:
    Inner() { cout << "Inner 构造" << endl; }
    ~Inner() { cout << "Inner 析构" << endl; }
};
 
class Box {
public:
    Box() {
        cout << "Box 构造:前半段" << endl;
        // 假设这里出错了
        throw "构造到一半失败了";       // 异常从构造函数抛出
    }
    ~Box() { cout << "Box 析构" << endl; }   // 永远不会被调用
private:
    Inner _inner;                    // 一个成员对象
};
 
int main() {
    try {
        Box b;                       // 构造 Box 时抛异常
    }
    catch (const char* msg) {
        cout << "捕获: " << msg << endl;
    }
    return 0;
}

运行结果会告诉你真相:

Inner 构造
Box 构造:前半段
Inner 析构
捕获: 构造到一半失败了

观察要点:

  • Inner _inner 是 Box 的成员,它在 Box 的构造函数体执行之前,先由编译器自动构造(这叫"成员先于构造函数体初始化",即 member initialization order)。所以你看到 Inner 构造 在后面 Box 构造:前半段 之前。
  • 但也就是说,到 throw 执行的那一刻,Box 对象并没有被完整构造成功(构造函数体没跑完)。于是:
    • Box 自己的析构函数 不执行(输出里看不到 Box 析构);
    • 而已经成功构造的成员对象 _inner,它的析构函数 被调用(所以看到 Inner 析构)。

这个"不成文的规则",正是 C++ 保证"构造函数抛异常不会泄漏已构造成员"的机制。它利用栈解旋与成员析构的天然配合,把"构造函数到一半"的烂摊子收拾干净。但前提是:你的资源一定要由 RAII 对象(如 Inner、智能指针)管理。如果 Box 的构造函数里用裸 new 申请了一块堆内存、还没来得及释放就抛异常,那块内存就泄漏了——因为 Box 析构不执行,没人帮你 delete。这正是"构造函数里只用 RAII 管理资源"这条铁律的由来。这一结论与 cppreference "构造函数中抛出的异常不会导致析构函数被调用,但已构造的成员/基子对象会被析构"一致。

想得更深一层,这条规则的设计动机其实是:C++ 必须保证"构造函数要么成功交付一个完整对象,要么连'还差一点'的半成品都不留下"。如果一个半成品也要被析构,那么析构函数就得面对"有些成员还没初始化"的烂摊子——得靠 flag 或判空来兜,极其脆弱。所以标准干脆定死:构造抛异常,只有那些已构造完成的成员/基类子对象会被正确清理,对象本身不调用析构。这是把"构造不完整"和"析构要开经"这两件根本不兼容的事彻底分开。

为了亲眼验证"已构造成员的析构被调用、而对象本身析构不调用",我建议你顺手把 Inner _inner 那一行下的 Box 换成"用裸指针 + 自己管理"做一次反面实验——你会立刻看到那块堆内存没人释放(数一数 new 和 delete 的配对数量就明白了)。

还有一个进阶知识点值得摆上台面:函数 try 块(function-try-block)。普通的 try/catch 放在构造函数体里,只能捕获构造函数体(body)抛出的异常,却接不住成员或基类初始化列表抛出的异常(因为那些发生在进入函数体之前)。如果你想让"成员初始化时的异常也能被接住并转换成另一种信号",可以把 try 写在冒号外、整个构造函数包成 function-try-block 的形式:

#include <iostream>
#include <stdexcept>
using std::cout;
using std::endl;
 
struct File {
    File(const char* path) {
        if (path == nullptr) {
            throw std::runtime_error("file path is null");
        }
        cout << "打开文件: " << path << endl;
    }
};
 
struct Loader {
    File dataFile;
    int buffer[4]{};
 
    // function-try-block:连成员初始化抛的异常也能接住
    Loader(const char* path)
    try : dataFile(path) {                 // try 放在初始化列表外面
        cout << "Loader 构造体" << endl;
    }
    catch (const std::exception& e) {
        // 在这里你改不了 dataFile(它已经在失败时被析构),
        // 但你可以记录、转换、重新抛出。
        cout << "Loader 初始化阶段捕获: " << e.what() << endl;
        // 注意:无论这里是否 return,构造仍视为失败,
        // 因为异常会借此退出构造函数。
        throw;                              // 继续传播(或不写 throw 也会自动重抛)
    }
};
 
int main() {
    try {
        Loader l(nullptr);
    }
    catch (const std::exception& e) {
        cout << "main 捕获: " << e.what() << endl;
    }
    return 0;
}

function-try-block 是个相对冷门但关键时刻救命的东西:如果你在构造函数体里 try { ... } catch 普通写法,成员 File dataFile 抛的 runtime_error 会直接飞出去,你的 catch 根本看不到。改写成上面这种写法后,成员初始化的异常就能被包装处理并重新抛出。要特别注意它的限制:在 function-try-block 的 catch 里,你不能修改出错的成员(因为那些已构造成功的成员此刻已经被析构了),也无法改变"构造失败"的结论——这个 catch 结束后,构造函数依然被视为抛异常失败(即使你不写 throw;,也会被自动重抛)。所以它的用途是"拦截、记录、统一再抛",而不是"修复对象"。

析构函数中抛异常。

如果说构造函数抛异常是"进不去",那析构函数抛异常就是"出不来时的二次引爆"。问题严重得多,因为分两种场景:

场景一:正常路径下析构函数抛异常。 一个对象在函数正常返回时被析构,析构函数抛了异常。这个异常会向外传播——可能被外层 catch 接住,也可能传导到 main 导致 terminate。这本身已经够闹心,但还不是最糟的。

场景二:栈解旋过程中析构函数再次抛异常。 这是致命的。还记得吗,栈解旋本身就是为了处理一个异常而一路析构对象。假如在解旋的途中,某个对象的析构函数又抛了异常……此时系统手里已经有两个同时"在途"的异常。C++ 标准根本不知道怎么同时处理两个异常,所以结果只有一个:直接调用 std::terminate 终止程序,而且这个过程不保证继续解旋,你的资源可能因此泄漏。这也是 C++ Core Guidelines 与《Effective C++》第 8 条共同强调的重点:绝不能让异常逃离析构函数。

现代 C++ 对析构吃异常这事儿还有一层"暗网兜底":C++11 起,所有析构函数默认被隐式声明为 noexcept(true)(你可以显式写成 ~Foo() noexcept(false) 来抵消,但几乎没人这么干)。也就是说,哪怕你不在析构函数后写 noexcept,编译器也当它是"保证不抛"的;一旦它真的抛了,程序直接 terminate——这和"在任何 noexcept 函数里抛异常"是同一副下场。所以"析构函数不抛异常"不只是一条风格建议,而是被语言默认行为给强制了的纪律。

看一个能立刻让程序崩溃的负面例子(仅供理解,请不要这样写):

#include <iostream>
using std::cout;
using std::endl;
 
struct Bomb {
    ~Bomb() { throw "析构时爆炸"; }    // 析构函数抛异常(反面教材!)
};
 
void helper() {
    Bomb b;                             // 局部对象
    throw 100;                          // 先抛出一个 int,触发栈解旋
    // 栈解旋析构 b 时,Bomb 析构又抛出异常 → 两个异常在途 → terminate
}
 
int main() {
    try {
        helper();
    }
    catch (int e) {
        cout << "捕获 int " << e << endl;
    }
    catch (const char* s) {
        cout << "捕获字符串 " << s << endl;
    }
    return 0;
}

你可以编译运行它,大概率会在控制台看到 terminate 相关的错误信息或"已停止工作"的对话框,而 main 里的 catch 一个都没能执行。因为 helper() 抛出的 int 还没被处理,栈解旋去析构 b,Bomb 的析构又抛异常,系统直接判了程序死刑。

正确的做法是什么?析构函数必须是一个绝不抛出异常的"好员工"。总是能用 try/catch 把它内部可能抛异常的操作兜住:

#include <iostream>
using std::cout;
using std::endl;
 
class Resource {
public:
    // 资源可能失败的释放操作,一律兜住,绝不让异常逃出析构函数
    ~Resource() {
        try {
            // 模拟释放 10 个资源,第 5 个会失败
            for (int i = 1; i <= 10; i++) {
                if (i == 5) {
                    throw "第5个资源释放失败";
                }
                cout << "释放第 " << i << " 个资源" << endl;
            }
        }
        catch (...) {
            // 即便失败也要把异常吞掉,保证析构正常返回
            cout << "捕获并忽略释放异常,析构继续收尾" << endl;
        }
        cout << "析构正常收尾完成" << endl;
    }
};
 
int main() {
    Resource r;                          // 正常作用域结束时析构
    return 0;
}

这段代码演示了《Effective C++》第 8 条"别让异常逃离析构函数"的落地写法:在析构函数内部用 try/catch 把一切可能抛异常的操作包裹住,宁可吞掉异常也不让它逃出去。否则——轻则导致后续资源无法释放(比如要释放 10 个资源,第 5 个抛异常,剩 5 个没释放,资源泄漏)、重则和别的在途异常叠加导致 terminate。

关于"析构里释放资源失败怎么办"的工程讨论,这里再给一个更精细的心法:释放失败本质上不是"能抛出去给别人处理"的错误,而是"当前对象已经要死了、剩下的善后只能由我做完"的收尾问题。 所以成熟的做法不是"抛出异常去要别人帮忙",而是:能重试就重试、能兜底就兜底(比如实在失败的关不掉的文件,记日志并继续释放后面的),最后把这个对象从资源表里移除,让局面变得干净。把异常抛出去的冲动,恰恰是你第一步要戒掉的。

异常安全:资源、RAII 与四个等级

现在我们到了异常机制的终极议题:当你写下一段可能抛异常的代码,你如何保证"万一真抛了,程序也是安全的"? 这就是异常安全(exception safety)。它考验的不是你会不会写 try/catch,而是你会不会写出"即使出错也能把场面收拾干净"的代码。

最朴素的异常安全问题长这样。你申请了一块内存,准备用完再释放,可中间的代码抛了个异常:

#include <iostream>
#include <string>
using std::cout;
using std::endl;
 
double Divide(int a, int b) {
    if (b == 0) {
        throw "Division by zero condition!";   // 除 0 抛异常
    }
    return (double)a / (double)b;
}
 
// 反面教材:裸 new 的内存可能泄漏
void FuncBad() {
    int* array = new int[10];          // 申请堆内存
    // 中间若抛异常(比如下面的 Divide),array 不会自动释放
    int len = 10, time = 0;
    cout << Divide(len, time) << endl;
    delete[] array;                    // 这行可能永远到不了
    // 一旦 Divide 抛异常,程序跳过 delete[] 直接上抛 → 内存泄漏
}

上面 FuncBad 的问题很清楚:Divide 抛异常后,delete[] array; 那行被跳过,而普通的裸指针 array 在栈解旋时只会被销毁指针变量本身,绝不会帮你 delete 它指向的那 10 个 int。于是 10 个 int 的堆内存就泄漏了。这就是最原始、最直接由异常导致的资源泄漏。

我把这个因果链再拆清楚一点,因为它牵涉一个常见的误解:

  • 栈解旋只负责析构"自动存储期"的完整对象;
  • 裸指针 array 本身是一个 int* 类型的自动对象,它会被"析构"——但 int* 的析构什么都不做(它不像 std::unique_ptr 那样有"释放所指向内存"的语义),所以指针变量消失了,它指向的堆块却成了孤儿。
  • 于是泄漏发生。根因不是"异常发生了",而是"这片内存的管理责任没有交给任何会析构的东西"。 这个判断标准,是后面理解 RAII 万能钥匙的钥匙。

方案一:catch 住、清理、再重新抛出。

这是不借助 RAII 的手动补救办法。课件里给了标准套路——捕获异常、释放资源、再用 throw; 把异常继续抛给外层:

#include <iostream>
using std::cout;
using std::endl;
 
double Divide(int a, int b) {
    if (b == 0) {
        throw "Division by zero condition!";
    }
    return (double)a / (double)b;
}
 
void Func() {
    int* array = new int[10];          // 申请内存
    try {
        int len = 10, time = 0;
        cout << Divide(len, time) << endl;   // 可能抛异常
    }
    catch (...) {
        // 捕获后先完成清理
        delete[] array;                // 手动释放内存
        throw;                         // 再重新抛出,注意必须是裸 throw;
        // 异常对象原样传递,外层照样能处理
    }
    // 走到这里说明没有异常,正常释放
    delete[] array;
}

这个写法本身是"正确"的,但你看,那些烦人的手动释放又回来了——那正是 C 时代最让人抓狂的东西。它能解决,但不优雅,而且极其容易写漏。所以它是"过渡方案",不是"最终答案"。

方案二:RAII + 智能指针——异常安全的真正基石。

异常安全的核心理念其实是八个字:让资源自己管理自己。把内存、文件、锁这些"数字资源"封装进一个对象,放进对象的生命周期里,让析构函数负责释放。这样,无论正常返回还是异常解旋,析构函数都会被自动调用,资源就永远被自动、确定、无遗漏地释放。这个思想叫 RAII(Resource Acquisition Is Initialization,资源获取即初始化)——它是 C++ 独有的武器,也是异常安全的基石。业界有一句话:RAII 是唯一能写正确 C++ 的道路(C++ Core Guidelines E.6)。

说清楚 RAII 这个名字为什么叫"资源获取即初始化":它的要点是把"拿到资源"和"构造一个管理对象"绑定在同一条时间线上。你不在构造之后孤零零地"申请一份资源、自己记着要释放",而是构造时就让它替你拿着这份资源,从那一刻起,这份资源的存亡就交给了这个对象自己的生存周期。构造即占有,析构即归还——这就是"获取即初始化"的真义。它比"我们通常叫它 RAII"重要得多的是那个"即"字,强调的是时机上的零缝隙。

std::unique_ptr 就是标准库送你的 RAII 智能指针:构造时接管一片堆内存,析构时自动 delete。用它重写上面那个函数,你会发现整个 try/catch 都不需要了:

#include <iostream>
#include <memory>
using std::cout;
using std::endl;
 
double Divide(int a, int b) {
    if (b == 0) {
        throw "Division by zero condition!";
    }
    return (double)a / (double)b;
}
 
void Func() {
    // unique_ptr 在构造时 new 出数组,析构时自动 delete[]
    std::unique_ptr<int[]> array(new int[10]);
    int len = 10, time = 0;
    // 即便这里抛异常,array 这个对象也会被栈解旋自动析构
    // 析构函数内部会自动 delete[] 底层数组 → 没有任何泄漏
    cout << Divide(len, time) << endl;
    // 无需任何手动 delete,无需任何 catch
}
 
int main() {
    try {
        Func();
    }
    catch (const char* msg) {
        cout << "捕获: " << msg << endl;
    }
    return 0;
}

妙不可言。unique_ptr 把资源管理彻底吸收了;栈解旋时它的析构自动执行、底层数组自动释放,你连 catch 为他们操心都省了。这也解释了为什么"智能指针 + RAII"是异常安全的基石——因为它把最容易出错的资源清理环节交给了语言本身的可确定性保证。

同样的思路,凡是"用对象包裹的资源"都能享受 RAII:文件(std::fstream)、互斥锁(std::lock_guard)、动态数组(std::vector)、字符串(std::string)……它们在异常下都是安全的。比如锁就是一个非常经典的例子——手动 lock 之后如果中间抛异常忘了 unlock,其他线程可能永远等不到这把锁;而 std::lock_guard 在作用域结束(无论正常还是异常)时自动 unlock,从根上消灭了"锁泄漏"。无论是最小到一把锁、还是一块内存,RAII 把"资源该何时归还"浓缩进对象生命周期这一件事里,异常安全就自然达成。

异常安全四等级。

有了上面的基础,就可以系统地介绍异常安全的四个等级(从弱到强),这是业界衡量一段代码"出错时能撑到什么程度"的标尺。需要说明的是,标准文献中常见的是三等级划分(basic / strong / noexcept),此处课件采用四等级口径,即把"no-throw"与"构造函数失败导致对象未完成"的单体保证单列,本质一致,特此注明。

  1. 基本保证(Basic Guarantee):函数抛异常后,程序仍处于一个有效状态,所有资源都被正确释放、对象内部的不变式(invariant,即对象始终应满足的不变量)仍被满足,但具体状态可能是"任意合理状态"——比如一个容器的元素数量可能已变、但没有被破坏。这是大多数 try/catch 版本的清理代码能达到的等级。

  2. 强保证(Strong Guarantee):函数抛异常后,程序状态完全回滚到调用前,就像这个函数从没执行过一样。典型实现手法是"写时拷贝 + commit"——先在副本上做完所有操作,全部成功后再一次 swap(交换指针)提交,而 swap 本身不抛异常(no-throw)。在 Excel 这类"打开出错要能撤销"的场景里,强保证是硬需求。文档来源:couleslaw.github.io 异常安全指南。

  3. no-throw 保证(No-throw Guarantee):函数承诺永不抛异常。出错时它要么忽略该问题、要么返回错误码,但绝不往外抛。这类函数通常是析构函数、swap 函数、移动构造函数/赋值(它们为了性能往往用 noexcept 标注,见下一节)。这条几乎就是"析构函数不建议抛异常"的语言化。

  4. no-fail 保证(No-fail Guarantee):极少数情况下甚至不允许出错(出错即程序状态错乱),通常是基础类型上的极小操作。这一档在课件四个等级里常与第三档合并讨论。

关于强保证,值得再掰开一点,因为你在实际工程里最可能需要它。 "强保证"的黄金实现套路是"copy-and-swap(拷贝并交换)":任何会改变对象状态的操作,都先在一份拷贝上执行;只有当全部步骤都成功时,才用一句绝不抛异常的 swap 把临时结果"切"进去。因为 swap 不抛,所以要么整套改变一次到位、要么一个字节都不变——这正好满足"要么全成功、要么像没发生过"。想想数据库事务的"原子提交",强保证就是 C++ 层面的"事务"。

为了让你亲手体会"强保证 vs 基本保证"的差异,写一个对比 demo。关键是看 swap 这类"承诺不抛"的操作如何充当那个"安全提交点":

#include <iostream>
#include <utility>
#include <vector>
#include <string>
using std::cout;
using std::endl;
 
class Registry {
public:
    // 加入一条记录;用 copy-and-swap 实现强保证
    void addIfOk(const std::string& key, const std::string& val) {
        std::vector<std::string> tmp = _items;  // 1) 拷贝当前状态
        tmp.push_back(key + "=" + val);         // 2) 在拷贝上改动(可能抛异常也无妨)
        _items.swap(tmp);                        // 3) 用不抛的 swap 一次性提交
        // 若第 2 步抛异常,_items 仍是旧状态 → 强保证达成
    }
 
    void show() const {
        for (const auto& s : _items) cout << "  [" << s << "]" << endl;
    }
private:
    std::vector<std::string> _items;
};
 
int main() {
    Registry r;
    r.addIfOk("a", "1");
    r.addIfOk("b", "2");
    cout << "当前内容:" << endl;
    r.show();
    return 0;
}

这里真正的"点睛之笔"是第 3 步 _items.swap(tmp):std::vector::swap 和成员 swap 被标准约定为 noexcept 不抛异常。因为整段操作里唯一可能抛异常的是 push_back(它可能扩容、可能分配内存),而它跑在 tmp 这份拷贝上;只有全部成功才 swap。于是"中间抛异常 → 原对象毫发无损"就变成了语言层面的承诺。你在自己的库代码里写"强保证"时,核心就是找到(或自己写一个)不抛异常的 swap 作为安全提交点。

你在写库代码时,应该给自己定一条刻在心里的目标:析构函数和 swap 给 no-throw,业务函数至少给基本保证,敏感函数冲刺强保证,普通函数能 RAII 化就 RAII 化。做到这些,你的代码在异常面前就是"脚底有根"的。

异常规范与 noexcept

程序里有个朴素的愿望:如果我知道一个函数不会抛异常,我这个调用方就能省去一堆 try/catch 和心理负担。 这个愿望催生了"异常规范"(exception specification)这个家伙。它的历史非常曲折,我们梳理清楚,因为版本差异(C++98/11/17)在这里体现得淋漓尽致。

C++98 的动态异常规范(dynamic exception specification)。

最初的 C++98 提供的是"动态异常规范",写法是在函数参数列表后面跟一个 throw(...):

// C++98 风格:声明该函数只会抛出 std::bad_alloc
void* operator new (std::size_t size) throw (std::bad_alloc);
 
// C++98 风格:声明该函数不会抛出任何异常
void* operator delete (std::size_t size, void* ptr) throw();

throw(类型1, 类型2, ...) 表示"这个函数只可能抛出这些类型的异常",多个类型用逗号分隔;throw() 空括号表示"这个函数不抛异常"。

理想很丰满,现实很骨感。这种机制在实践中基本失败了,原因有几个:一是它写起来啰嗦,还要精确列全所有可能类型,维护地狱;二是它只在运行时违背时才由 std::unexpected 兜底,反而带来新的性能与行为开销;三是标准库自己都没用好它。所以到 C++11 时,动态异常规范被标记为弃用(deprecated),到 C++17 被删除——唯一幸存的 throw() 被视作 noexcept(true) 的别名。这一点在 ISO 提案 P0003R1《Removing Deprecated Exception Specifications from C++17》与微软文档中均得到确认。

C++11 的 noexcept。

C++11 提出的新替代方案叫 noexcept,一个干净利落的二元开关:函数参数列表后面加 noexcept,表示"保证不抛异常";啥都不加,表示"可能抛异常"。这是二值化的、带语感的设计,比列一长串类型清爽得多:

// C++11 起:显式声明不抛异常
size_type size() const noexcept;
iterator begin() noexcept;

noexcept 也可以带一个编译期布尔参数,写作 noexcept(表达式)(即 noexcept(true) / noexcept(false))。裸写 noexcept 就等价于 noexcept(true);显式 noexcept(false) 则是把"可能抛"重新说清楚,没人闲得没事写它,模板推导时才会用到。还有一种叫条件 noexcept:一个模板函数是否 noexcept,取决于它的类型参数本身是否 noexcept——这在容器、泛型库的"如果可移动就移、否则就拷贝"策略里是核心,比如 std::swap 就经常写成"当底层类型可 noexcept 移动时才宣称 noexcept"。

现代 C++ 里,noexcept 已经成了标准库的标配:std::vector 的 size()、begin()、swap 等都标注了 noexcept。它给调用方传递了"放心调用"的信号,也为编译器优化(比如 vector 扩容时大胆用移动构造而不用担心异常半途而废)创造了条件。

这里想让你看到一个 noexcept 对编译器决策的真切实例:STL 容器在扩容时,决定用"搬移元素"还是"复制元素",往往就看元素的移动构造是否 noexcept。因为如果移动构造会抛异常,扩容中途抛了会留下"部分搬走部分没搬"的混乱局面,STL 宁可退而求其次走拷贝路径(拷贝失败时原容器还完整,能用强保证兜底);而如果移动构造承诺了 noexcept,STL 才敢放心用更快的移动。所以 noexcept 不只是注释,它直接参与 STL 对"安全和性能"两种策略的选择——这是它最被低估的价值。

noexcept 的底层真相:它是"契约",编译器不强制。

这是 noexcept 最关键的、也最容易让人误解的一点:编译器并不会在编译时去验证一个 noexcept 函数真的不会抛异常。哪怕你在 noexcept 函数体里写了一个 throw,或者调用了一个可能抛异常的函数,编译器通常照样能编译通过(最多给个警告)。它是程序员和调用方之间的一份"口头承诺"。

但这份承诺如果被打破,后果极严重:一个被声明为 noexcept 的函数如果当真抛出了异常,程序会直接调用 std::terminate 终止,根本不会再去尝试寻找任何外层 catch。而且从 noexcept 函数里抛出异常时不保证栈解旋——你的对象可能不会被析构。 这一点在 learncpp 的《Exception specifications and noexcept》、微软文档以及 C++ Core Guidelines 中都讲得明明白白。

把"为何编译期不强制、运行期却直接 terminate"这层逻辑再往深挖一层,你就真正懂 noexcept 了:编译器没在编译期查你,是因为"会不会抛"这件事通常要到运行时才知道(比如你调用的某个库函数是否抛、那些指针是否为空)。但既然你向调用方立了不抛的军令状,运行时一旦破了约,系统已经无法把这场异常"安全地交还给"任何 catch 了(因为按契约这里根本不该有异常),所以只能走上 std::terminate 这条唯一不落空的收尾路。换句话说:noexcept 把"错误处理"降级成了"崩溃报告"——它不对处理负责,只对你违约这件事负责。

所以记住一句话:noexcept 是写给调用方的承诺,违约的代价是程序直接终止。它在移动构造函数、swap、析构函数里特别常见——这些函数如果抛异常,容器和异常机制本身就没法安稳运转,所以约定它们绝不抛。

noexcept 的运算符形态。

最后,noexcept 还可以作为运算符使用:noexcept(表达式) 在编译期计算该表达式"是否会抛异常",可能抛就得到 false,确定不抛就得到 true。注意它和"声明用 noexcept"共用同一个关键字,但一个是运算符,一个是说明符,用途完全不同,不要混淆。

关键要记牢:noexcept(表达式) 运算符读的是"声明",不是"函数体"。它看你表达式所调用函数的声明里有没有 noexcept,以及表达式本身有没有隐式的抛点;它绝不打开函数体去检查你实际写了什么。一个函数体明明会抛、但只要它声明成 noexcept,运算符就会回 true。反过来,一个函数体空空如也、根本不会抛,但只要它声明时没写 noexcept,运算符就会回 false。

看一个综合 demo,把说明符(声明用)、运算符(探测用)、以及"noexcept 函数真抛就 terminate"三件事一次验证清楚。注意:我先声明一个 noexcept 版本和一个"没写 noexcept"的版本,好让运算符的取值差异无从抵赖:

#include <iostream>
using std::cout;
using std::endl;
 
// 声明为 noexcept:承诺不抛。但函数体里其实会抛(反面教材!弃用这么写)
double Divide(int a, int b) noexcept {
    if (b == 0) {
        throw "Division by zero condition!";   // 违反 noexcept 契约 → terminate
    }
    return (double)a / (double)b;
}
 
// 没有 noexcept:明确表示"可能抛"
double DividePotentially(int a, int b) {
    if (b == 0) {
        throw "Division by zero condition!";
    }
    return (double)a / (double)b;
}
 
int main() {
    // noexcept 作为运算符:编译期判断"该表达式按声明是否会抛"
    int i = 0;
    cout << "noexcept(Divide(1,2))           = "
         << noexcept(Divide(1, 2)) << endl;      // 1:因为 Divide 声明了 noexcept
    cout << "noexcept(DividePotentially(1,2)) = "
         << noexcept(DividePotentially(1, 2)) << endl; // 0:没声明 noexcept
    cout << "noexcept(++i)                    = "
         << noexcept(++i) << endl;                // 1:内建自增不抛
    cout << "noexcept(1/2)                    = "
         << noexcept(1 / 2) << endl;              // 1:整型除法不抛(无异常)
 
    // 注意一个陷阱:即便传入的参数(1,2)在正常运行下根本不会抛,
    // noexcept(Divide(1,2)) 依然回到 1 —— 运算符只看声明!
    // 而 noexcept(DividePotentially(...)) 就算传 (1,0) 也是 0 —— 同样只看声明。
 
    // 演示:noexcept 函数真抛异常 → 直接 terminate,catch 形同虚设
    try {
        cout << Divide(10, 0) << endl;   // 这里真的抛了
    }
    catch (const char* msg) {
        // 这个 catch 永远接不到——因为 Divide 违背了 noexcept
        cout << "捕获: " << msg << endl;
    }
    return 0;
}

把这个 demo 里最容易让人懵的地方摊开讲:noexcept(Divide(1,2)) 为何是 1,而不是 0? 因为运算符看到 Divide 的声明上挂着 noexcept,于是它回答"这个表达式不会抛"→ true(1)。它并不在乎你运行时真的调 Divide(10,0) 会炸,也不在乎函数体里真的写了 throw;那些是运行时的事,而运算符是编译期的判断。同理,noexcept(DividePotentially(1,2)) 即便参数是 (1,2)(正常运算根本不抛),也恒为 0,因为它的声明上没写 noexcept。

我特意把 demo 设计成"noexcept 版本 + 未加 noexcept 版本"的对照组,而不是把同样的两个函数都打成 noexcept 再来打印——因为同样一个 noexcept 声明,用运算符和用运行时行为去看,结论是不同的:运算符(读声明)说它不抛,运行时(看真身)它却抛了。这两者不矛盾,只是看问题的角度不同,也恰恰是 noexcept 最可爱又最坑人的地方。把这个 demo 编译跑一遍,你会把"说明符 vs 运算符""声明 vs 运行时"两对区别牢牢刻进肌肉记忆。

运行这段代码,前四个 noexcept(...) 运算符打印依次是 1 0 1 1;最后一个 try/catch 里,虽然你写了 catch (const char*),但由于 Divide 标了 noexcept,异常一抛出就会被系统当成"违约",直接 terminate 终止——那个 catch 形同虚设。在绝大多数实现上,这会直接打印 terminate 信息并(可能是非零)退出,你连那个 捕获: 都看不到。

这个演示能帮你把两件事彻底钉死在记忆里:第一,noexcept(表达式) 运算符算的是"按声明会不会抛",与入参无关、与运行时行为无关;第二,noexcept 函数真正抛异常时会跳过所有 catch 直接 terminate。

篇幅允许,再补一个 noexcept 的实用技巧:用运算符给"自己的函数是否 noexcept"写条件 noexcept。比如模板里 void f(T t) noexcept(noexcept(g(t)));,意思是"f 是否 noexcept 取决于 g(t) 是否 noexcept"。外层那个 noexcept(...) 是声明用的说明符,里面的 noexcept(g(t)) 是运算符——一个句子里两次出现 noexcept,含义一个说明符、一个运算符。这是泛型库的高频写法,所以说"分清说明符和运算符"真不是书呆子打转,是实打实要看懂 STL 源码的入场券。

标准库的异常体系

前面我们一直在自己造基类,但 C++ 标准库早已贴心地给了你一套现成的异常继承体系,基类就是 std::exception(声明在 <exception> 头文件)。它是所有标准库异常的公共祖先,关系如下(节选常用部分):

std::exception
  ├── std::logic_error         逻辑错误(本可由编程规避)
  │     ├── std::invalid_argument     参数非法
  │     ├── std::length_error         长度超限
  │     ├── std::out_of_range         越界访问
  │     ├── std::domain_error         定义域错误
  │     └── ...
  ├── std::runtime_error       运行时错误(运行环境导致的、难以预防)
  │     ├── std::range_error         值域错误
  │     ├── std::overflow_error      算术上溢
  │     └── std::underflow_error     算术下溢
  ├── std::bad_alloc           内存分配失败(new 失败时抛出)
  ├── std::bad_cast            非法的 dynamic_cast 转换
  ├── std::bad_typeid          typeid 作用于空指针所指向对象
  ├── std::bad_weak_ptr        shared_ptr 从失效的 weak_ptr 构造
  ├── std::bad_function_call   空 std::function 被调用
  └── std::bad_optional_access 空 std::optional 被访问(C++17)

顺便说一句 std::logic_error 和 std::runtime_error 的哲学区别,这是面试常客:logic_error 是"本来可以靠写代码避免"的错误(参数非法、越界、长度超限——它们大多源自调用方没守规矩),runtime_error 是"运行环境变化导致的、再小心也未必防得住"的错误(内存耗尽、算术溢出、文件不存在)。理解这个分类,选择自己的异常继承谁才有方向感。

std::exception 向你公开了一个关键的虚函数 what(),返回 const char*,用于获取异常的描述信息。派生类可以(也通常必须)重写它给你更精确的说明。因为它是虚函数、基类是公共祖先,所以日常写程序有一条黄金准则:只要在主函数捕获 std::exception(用引用),并把捕获到的异常信息通过 what() 打印出来,那么几乎所有标准库抛出的异常都能被你兜住。这正是标准库设计这套体系的初衷——给你一个统一的捕获入口。

一个实际验证标准库异常的例子——new 分配特大内存触发 bad_alloc:

#include <iostream>
#include <new>          // std::bad_alloc
#include <exception>    // std::exception
using std::cout;
using std::endl;
 
int main() {
    try {
        // 尝试分配一个天文数字大小的内存,极可能失败并抛出 bad_alloc
        char* p = new char[static_cast<size_t>(-1) / 2];
        cout << "分配成功,p = " << static_cast<void*>(p) << endl;
        delete[] p;
    }
    catch (const std::bad_alloc& e) {
        cout << "bad_alloc: " << e.what() << endl;
    }
    catch (const std::exception& e) {
        // 更通用的兜底:所有标准库异常都能接住
        cout << "std::exception: " << e.what() << endl;
    }
    catch (...) {
        cout << "未知异常" << endl;
    }
    return 0;
}

如果你在 64 位机器上运行,new 一个约 2^63 字节的数组几乎必然会失败,标准库会抛出一个 std::bad_alloc,被你第一个 catch 接住并打印 what()。换成别的标准库异常同理——std::vector 越界(out_of_range)、std::stoi 解析失败、std::bitset 越界、std::any_cast / std::get 类型不匹配(bad_any_cast / bad_variant_access)、算术溢出……都会被 std::exception 体系接管。这也是为什么"顶层一个 catch (const std::exception&) + 一个 catch (...)"就能兜住整个标准库的程序。

通过 std::vector::at 演示标准库 out_of_range 的另一个独立可编译 demo,让你对"标准库也会抛"彻底有体感:

#include <iostream>
#include <vector>
#include <stdexcept>   // std::out_of_range 所在
using std::cout;
using std::endl;
 
int main() {
    std::vector<int> v = {10, 20, 30};
    try {
        // at() 越界访问会抛 out_of_range;而 operator[] 不会检查(UB)
        cout << "v.at(5) = " << v.at(5) << endl;
    }
    catch (const std::out_of_range& e) {
        cout << "out_of_range: " << e.what() << endl;
    }
    catch (const std::exception& e) {
        cout << "std::exception: " << e.what() << endl;
    }
    return 0;
}

注意这里顺带埋了一个标准库的"坑知识点":std::vector 的 operator[] 是不检查越界的(越界访问是未定义行为,可能直接崩),而成员 at() 是检查越界并抛 out_of_range 的。所以"要不要异常"甚至体现在标准库内部对同一件事的两种策略里——at() 走异常通道、operator[] 不走。这与我们前面讲的"异常是为真正的意外设计的"一脉相承:operator[] 默认为"你保证索引合法"。

行业建议:你自己的自定义异常类,尽量不要另起炉灶从头造基类,而是继承自 std::exception(或它的派生类 std::runtime_error、std::logic_error)。这样你既能利用标准的捕获入口,你的类也能被那些"只捕获 std::exception"的通用兜底代码一视同仁地接住。外面包一层 std::exception,你的异常世界就和整个标准库通了。

把"继承 std::exception"的好处再具象一点,写一个完整可编译的例子,把它和你前面的 Exception 基类风格接上头:

#include <iostream>
#include <stdexcept>          // std::runtime_error 所在
using std::cout;
using std::endl;
using std::string;
 
// 继承标准库的 runtime_error:省得自己实现 what() 的存储,直接搭便车
class PropertyError : public std::runtime_error {
public:
    explicit PropertyError(const string& which)
        : std::runtime_error("属性缺失:" + which), _which(which) {}
    const string& which() const { return _which; }   // 附加业务字段
private:
    string _which;
};
 
// 泛型兜底:只捕获 std::exception 就够,自己的子类也能被它接住
void runBizLogic() {
    throw PropertyError("username");                  // 抛我们自己的异常
}
 
int main() {
    try {
        runBizLogic();
    }
    catch (const PropertyError& e) {
        // 想精确处理就精确接住
        cout << "精确接住 PropertyError: " << e.what() << ", 缺的字段=" << e.which() << endl;
    }
    catch (const std::exception& e) {
        // 不想精确管时,基类 std::exception 也能一网打尽所有标准+自定义异常
        cout << "基类接住: " << e.what() << endl;
    }
    return 0;
}

继承 std::runtime_error(而不是硬生生手写一个裸 Exception)有个隐性红利:你不用自己实现、维护 what() 要存储的那份描述字符串,报错文案的存取、断言、复制构造这些都搭了标准库的顺风车;你只需在构造时把文案传进 std::runtime_error 的构造函数即可。这在真实工程里能省掉一大片样板代码。

何时不用异常

异常不是万能的,甚至在某些领域是被严格禁止的。把一个"不该用异常"的场景硬塞给异常,结果往往比不用更糟。下面是几个业界公认的、应该考虑避免异常的场景:

性能敏感的热路径。 throw/catch 是有运行成本的:异常对象的构造与拷贝、栈解旋沿途的析构、异常类型匹配的查找。虽然现代实现(如 itanum abi 的"零开销异常")在"没有异常被抛出"时几乎不加额外成本(零开销机制——无异常抛出时无额外运行时开销,一旦抛出才走昂贵的异常表路径),但一旦真的抛出异常,代价是相当高的(前面栈解旋讲过,涉及两阶段解旋、异常表查找、沿途析构)。在游戏帧循环、高频数值计算、实时系统这类以毫秒甚至微秒计的场景里,一个在循环内高频抛异常的写法,就是性能事故。这类场景请优先用错误码或 std::optional(可选值,表示"可能为空的结果")。

这里再补一个更精确的现代替代品全景,帮你把"热路径该用什么"一竿子理清:

  • std::optional<T>(C++17):表示"要么有值、要么空"。适合"可能没有结果,但这不算错误"。
  • std::expected<T, E>(C++23):表示"要么成功拿 T,要么失败带 E"。比 optional 更进一步,能携带错误信息,是目前"用返回值表达可能失败"的最现代形态,正有逐步成为热路径标配的趋势。
  • 传统错误码 / bool + 出参:老朋友,胜在零成本、可跨语言边界。

对于"经常失败、失败频率高、代价要可控"的路径,这些返回值方案都远比抛异常(一旦抛就要解旋)便宜;只有"真正意外、频率低、需要丰富上下文"时,异常才划算。

实时系统与嵌入式系统。 实时系统的硬性要求是"响应时间可预测、最坏情况可控"。而异常处理的路径依赖异常表查找,最坏情况时间难以精确上界;此外嵌入式平台的内存里可能预分配不了异常对象需要的存储(它通常要堆内存,而嵌入式可能没有可用堆)。很多嵌入式/航空/汽车项目(比如某些 MISRA C++ 审核要求的项目)直接在编码规范里禁用 RTTI 与异常。

还要指出一个更容易被忽略的深层原因:异常匹配依赖 RTTI(运行时类型识别,即 typeinfo)。如果某个嵌入式项目为了节省空间用 -fno-rtti 关闭了 RTTI,那么多态异常的类型匹配(基类引用接住派生异常)就会失效或未定义,异常体系直接半身不遂。这也是"禁用异常"在嵌入式中如此普遍的技术根源之一。

对象的析构与资源释放路径。 前面说过,析构函数抛异常会导致二次引爆甚至 terminate。所以"析构的时候"绝不是一个抛异常的好地方——那里应该只做 no-throw 的事情。

预期内的、常规的控制流。 异常是为"异常情况"设计的,不是为"普通的分支判断"设计的。比如解析一个用户输入、遍历一个列表判断是否存在某个值——这些是日常逻辑,用 if、for、错误码、std::optional、std::expected 更自然。如果为了省事把"找不到就抛异常"当常态,你等于把昂贵且容易出错的道路当成常规路径,性能差且代码难读。

跨模块边界要三思。 当异常需要跨过 C 与 C++ 的边界(比如从 C++ 库抛进 C 调用方,或反之)、跨过不同编译器编译的 DLL/SO 边界时,异常传递的行为往往不可靠甚至未定义(A 编译器的异常表 B 编译器未必认,它们分属不同的 ABI 约定)。这类"模块是 C 写的、或编译选项不统一"的环境里,最好在边界处把异常转换成错误码再传递。C++ 异常和 C 的 errno / setjmp+longjmp 也是两套互不相通的戏路,混着用极易踩雷。

还有一条不太常被提起、但很实在的边界:异常不能跨线程传播。 一个异常在某个线程里抛出、如果没有在该线程内部被接住,它不会"跑到"别的线程去让你 catch——那个线程会直接终止。所以每个线程的入口函数都要自带 try/catch(...) 兜底("线程函数 = 另一个 main"),否则后台线程一抛就在那根线程里静悄悄死掉,排查起来极其痛苦。

那么,什么时候适合、甚至必须用异常呢?在大型的、自成一体的 C++ 代码库内部,当错误是"非预期、难以就地处理、且希望上层统一接管"时(资源获取失败、状态非法、深层业务的连续性错误),异常配合 RAII 是最优雅、最安全的方案。课件里那套多模块服务模拟(SQL/Cache/HTTP 各抛异常、最外层统一捕获基类)就是典型场景——异常让每一个模块可以"只负责检测",而把处理责任干净地上交给全局收口。

一个实用的判断口诀:"这真的是意外吗?抛出它之后,上层会想做跟普通返回不一样的事吗?这个错误需要携带丰富的上下文吗?" 三个问题都答"是",才把异常从锦囊里取出来。

一路走来

从 C 时代被错误码折磨得痛不欲生写起,我们一路拆解了异常的全貌:它是如何用"throw 抛出对象、try 包裹、catch 接住"这套机制,把问题的检测与解决彻底分开;异常对象如何被拷贝(在 Itanium ABI 里由 __cxa_allocate_exception 放到专门的存储里,活到所有 catch 处理完毕)、又如何按"类型匹配(只认强类型匹配 + 那几种明令允许的转换)+ 就近原则"找到它的接收者;catch(...) 如何在最后兜住一切、又为何不知道异常内容;自定义异常类如何靠"公共基类 + 虚函数 what()"实现捕获基类一网打尽的巧妙设计,以及完整的编配(继承 std::exception、虚析构、id+msg 双通道、no-throw 的 what)。

然后我们潜入了异常最深邃的部分:栈解旋如何在异常一路向上时把沿途构造的对象一个不落地析构掉(那个 Observer 实验的输出顺序,建议你亲手跑一遍);构造函数抛异常时"对象不完整、但已构造的成员会被析构"(配合 function-try-block 处理成员初始化异常)、析构函数抛异常时"二次引爆直接 terminate"的两大坑;异常安全的四等级,以及"RAII + 智能指针是异常安全真正基石"这句话到底意味着什么(给锁、给文件、给内存都套上 RAII,异常下也能自动归还资源);noexcept 那句"声明是承诺、违约是 terminate"的真相,以及它作为说明符与作为运算符的两种面孔(运算符读声明、不看函数体——那正是它最坑人又最重要的一点);还有标准库那棵以 std::exception 为根、以虚函数 what() 为接口的异常树。

最后我们讲了反向的智慧——何时不该用异常:性能热路径(std::optional / std::expected 接棒)、实时嵌入式(RTTI 常被禁用、优先级确定性要命)、析构路径、预期内控制流、跨 C/C++ 与跨 DLL 边界、以及"异常不能跨线程"。异常是一把锋利的刀,用对了削铁如泥,用错了伤的是自己。

现在你再回头看开篇那句"异常是 C++ 给出的出错时怎么优雅、强制、自动地处理好的标准答案",应该能会心一笑了。它的底座从来不是那三个关键字,而是析构函数、是 RAII、是对资源生命期的深刻理解。 把这三个东西揣进兜里,再遇到异常,你就不是"会写 try/catch",而是真正"懂异常"了。

送你一份"异常新手到老手"的进阶路线浓缩清单,比背理论更实在:把本篇文章里每个 try/catch 的示例都改一版"用 RAII 消除 try/catch"的写法;再写一个反面版"析构抛异常"跑给你看 terminate;最后亲手敲一段"noexcept 声明的函数真抛了"去看编译器怎么不阻止、程序又如何瞬间终止——这三件事做一遍,你对异常的信任和敬畏就都有了。

去把 Observer 那个栈解旋实验跑一遍吧,即便你现在已经读完总结,眼见为实,比背一百遍理论都管用。