🔷 第十四章 RTTI与异常处理
📄 一句话概括

throw 表达式; 就是"报警" ——它把表达式的值拷贝成一个"异常对象",然后让控制流立刻离开当前函数,去找愿意接它的人。 本节要记住四件事: 1. 抛出的是"一份拷贝"(不是原来那个对象,所以抛局部变量也是安全的); 2. 惯例抛"类对象",不抛裸指针、不抛裸内置类型(裸指针没人负责释放,裸内置类型带不了信息); 3. throw;(不带表达式)在 catch 里 = 原样重新抛出(不切片); 4. 两个雷区:析构函数里别抛(默认 noexcept,抛了直接终止)、构造函数里抛要自己收拾(这个对象的析构函数不会被调用)。

📝 本节和前面几节的关系

14.2 讲了"异常是第二套返回通道 + 栈展开",本节讲这条通道的入口:throw。 它同时把第十章两笔旧账结清: - 10.3(析构):"析构函数里不要抛异常" 这条规矩,现在能说清原因了 ——栈展开途中再抛一个异常,就有两个异常同时在飞,标准只能调 terminate; - 10.2(构造):"构造函数中途失败怎么办?" ——这个对象根本没构造完,析构函数不会被调用,所以它必须自己把已经拿到的东西还回去。 14.2.4 会给本节 Demo 里那个"从 runtime_error 派生"的写法一个正式解释(std::exception 家族的规矩)。

一、throw 抛出去的是什么:一份"拷贝"

throw 表达式;         // 最简单的形式

执行 throw 时,编译器做这几件事:

  1. 求值那个表达式;
  2. 把结果"拷贝"到一个由运行时管理的特殊位置(这个拷贝叫"异常对象")——它不放在你的栈上,所以抛出后原来的局部对象就算销毁了,异常对象也还活着;
  3. 开始"找接收站":沿调用栈往外走,每离开一层就做一次栈展开(14.2 讲的);
  4. 找到匹配的 catch 就进去执行;一路找到头都没人接,就调 std::terminate()(程序终止)。

"拷贝"这件事有个很实用的结论:

void f() {
    MyError e("出错了");     // 局部对象
    throw e;                 // 拷贝一份带出去
}                            // ← f 结束时 e 被析构,但异常对象还活着,catch 里照样能用

所以:抛局部对象是安全、且是正常用法。 (这也意味着"异常对象的拷贝构造/移动构造必须是可用的" ——标准库的异常类都满足这一点。)

二、抛什么:惯例与三条禁忌

抛什么 评价
类对象(尤其是从 std::exception 派生的) ✅ 推荐的惯例 ——能带信息(文件名、错误码)、能分层捕获、能调 what()
裸内置类型(throw 404; throw "错误";) ⚠️ 能编过能接住,但只能靠值区分 ——带不了信息、容易撞车(throw 404 和真正的数字 404 分不清)
裸指针(throw new MyError();) ❌ 别抛 ——因为"谁负责 delete"立刻变成一个悬案(catch 到的地方必须记得删;没人接就泄漏)
局部对象的指针/引用 ❌ 别抛 ——异常飞出去时局部对象已析构,指针立刻悬空
标准库已经有的类型(out_of_range / invalid_argument / runtime_error …) ✅ 优先用现成的(14.2.4)

一句话:"抛类对象,优先抛那些从 std::exception 家族里来的。"

三、throw 之后会发生什么

三条极简的行为规则:

  1. 本层剩下的语句一条都不执行(就像 return,但方向是往外飞);
  2. 本层的局部对象会被析构(栈展开:先进后出,10.3 的顺序);
  3. catch 块执行完后,程序从 catch 之后继续 ——不会回到 throw 那一行(14.2 的误区里强调过:异常不能"续命")。
void h() {
    Guard g("h 的 Guard");
    cout << "抛之前" << endl;
    throw runtime_error("炸了");
    cout << "抛之后 —— 这行永远不会执行" << endl;
}

四、throw;:在 catch 里"原样转交"

catch 块里可以写一个"没有表达式"的 throw,意思是:把当前这个异常原样再抛出去,让外层去处理。

try {
    openFile(name);
} catch (const FileError &e) {
    log("这次打开失败:" + string(e.what()));   // 先记一笔(本层能做的事)
    throw;                                     // ★ 原样往上送
}

throw; 和 throw e; 的区别(很重要):

写法 抛出去的东西
throw; 原来那个异常对象(同一个,类型不变) ——推荐
throw e; 把 e 再拷贝一份 ——如果 e 是按值捕获的(已经切片过),拷贝的也是被切过的版本 ——信息丢了

所以规矩是:要重新抛出,一律写 throw;,绝不写 throw e;。(而 catch 参数也应该用引用捕获,原因在 14.2.2。)

五、雷区一:析构函数里不要抛异常

原因在前一节说了,这里把它钉死:

栈展开的过程本身就是"正在处理一个异常"。如果在这个过程中,某个析构函数又抛出一个异常 ——两个异常同时在飞 ——标准规定这时调用 std::terminate(),程序直接终止。

C++11 之后的规矩更硬:

class Bad {
public:
    ~Bad() { throw runtime_error("析构里抛"); }    // ☠️ 一旦真的抛出 → terminate
};

正确做法:析构函数自己做"最后一道防线" ——把可能抛的操作包起来,出错只记日志、不往外抛。

~Connection() {
    try { socket.close(); }        // close 可能抛
    catch (const exception &e) {   // 吞掉(最多记一笔)
        cerr << "关闭连接失败:" << e.what() << endl;
    }
}

六、雷区二:构造函数里抛异常 —— 析构函数不会被调用!

这是本节最容易被忽略、也最容易造成泄漏的一条。

class Holder {
public:
    Holder() {
        a = new int[100];      // ① 拿到资源
        b = new int[100];      // ② 假设这一句抛了 bad_alloc
    }                          //    那么 ~Holder() 永远不会被调用 → a 泄漏!
    ~Holder() { delete[] a; delete[] b; }
};

为什么不会调用析构:因为"这个对象从来没构造完成" ——析构的前提是"对象曾经完整地出生过"。

标准的确切规则(值得记准):

谁 在构造函数抛异常时会不会被析构
这个类自己的析构函数 ~Holder() ❌ 不会(对象没出生)
已经构造完成的成员对象(比如成员里放了个 vector) ✅ 会(它们已经是完整对象)
已经构造完成的基类子对象 ✅ 会(11.1.3 的构造顺序:先父后子)

所以有三条出路,从好到坏:

办法 说明
① 让成员对象去管资源(首选) 成员写成 vector<int> / string 而不是裸 int * ——它们自己会在栈展开时把资源还回去(这就是"栈展开"最大的用处)
② 用"函数 try 块" Holder() try { … } catch (...) { /* 收拾 */ throw; } ——能写,但教材不展开,认得语法即可
③ 在构造函数里手写清理(Demo 用这个,为了看清机制) 每个可能抛的地方之前,先 delete 掉已经拿到的资源,再抛 ——容易忘、容易漏,只在学习时用

一句话总结这一节的两个雷区:

析构函数是"清扫工",不能边清扫边报警。 构造函数是"还没建好的房子",塌了不算"拆除",所以它得自己把料搬走。

七、没人接的下场:terminate

异常一路飞出去、飞出 main 都没有 catch,标准规定调用 std::terminate() ——默认行为是打印一条信息并终止程序(GCC/MinGW 会打印 terminate called after throwing an instance of 'std::runtime_error' 之类的字样,具体文字由实现决定)。

这不是"崩溃",是"标准规定的收场方式" ——栈展开仍然会做完(局部对象照常析构),然后程序死掉。

八、动手 Demo:抛自定义异常 + 看构造函数失败

任务:把本节的四件事各看一遍 ——带信息的自定义异常、throw 之后的代码不执行、throw; 重抛、构造失败的清理责任。

#include <iostream>
#include <string>
#include <stdexcept>
using namespace std;

// ── 自定义异常:从标准库的 runtime_error 派生(14.2.4 的规矩)──
class FileError : public runtime_error {
public:
    FileError(const string &file, const string &why)
        : runtime_error("文件【" + file + "】" + why), file_(file) {}
    const string &file() const { return file_; }        // 额外信息:文件名
private:
    string file_;
};

void openFile(const string &name) {
    cout << "  试着打开 " << name << endl;
    if (name.empty())        throw FileError(name, "名字是空的");
    if (name != "data.txt")  throw FileError(name, "不存在");
    cout << "  打开成功" << endl;
}

// ── 一个"构造中途会失败"的类 ──────────────
class Resource {
public:
    Resource(bool ok) {
        cout << "  Resource 构造开始" << endl;
        buf_ = new int[10];                              // 先拿到资源
        if (!ok) {
            delete[] buf_;                               // ★ 自己收拾:析构不会被调用
            throw runtime_error("Resource 构造失败");
        }
        cout << "  Resource 构造完成" << endl;
    }
    ~Resource() { delete[] buf_; cout << "  Resource 析构" << endl; }
private:
    int *buf_;
};

int main() {
    // ① 顺利的一路
    try {
        openFile("data.txt");
        cout << "① 一切正常" << endl;
    } catch (const FileError &e) { cout << "① " << e.what() << endl; }

    // ② 出错:throw 之后的语句不会执行
    try {
        openFile("missing.txt");
        cout << "② 这句永远不会执行" << endl;
    } catch (const FileError &e) {
        cout << "② 抓到:" << e.what() << "(文件名 = " << e.file() << ")" << endl;
    }

    // ③ 重抛:内层记一笔,原样交给外层
    try {
        try {
            openFile("");
        } catch (const FileError &e) {
            cout << "③ 内层看一眼:" << e.what() << endl;
            throw;                                       // ★ 原样重抛(不切片)
        }
    } catch (const exception &e) {                       // 外层用基类接
        cout << "③ 外层接住(基类视角):" << e.what() << endl;
    }

    // ④ 构造失败:看析构有没有被调用
    try {
        Resource bad(false);
    } catch (const exception &e) {
        cout << "④ 接住:" << e.what() << "(注意:上面没有出现 Resource 析构)" << endl;
    }

    // ⑤ 构造成功:对照看析构出现
    try {
        Resource good(true);
        cout << "⑤ 这一行跑完,good 就该析构了" << endl;
    } catch (...) { }
    return 0;
}

看到什么算成功:

  试着打开 data.txt
  打开成功
① 一切正常
  试着打开 missing.txt
② 抓到:文件【missing.txt】不存在(文件名 = missing.txt)
  试着打开 
③ 内层看一眼:文件【】名字是空的
③ 外层接住(基类视角):文件【】名字是空的
  Resource 构造开始
④ 接住:Resource 构造失败(注意:上面没有出现 Resource 析构)
  Resource 构造开始
  Resource 构造完成
⑤ 这一行跑完,good 就该析构了
  Resource 析构

读结果:

30 秒实验

  1. 把 ④ 里那句 delete[] buf_; 注释掉 → 程序输出一模一样(泄漏是"看不见"的)。这就是为什么"资源要交给成员对象管"(14.2 的第①条出路)。
  2. 把 ③ 内层的 throw; 改成 throw e; → 结果看起来一样(因为这里是按引用捕获的)。把 ③ 的 catch (const FileError &e) 改成按值 catch (FileError e),再对比 throw; 和 throw e; → 这就要到 14.2.2 才看得清了(切片)。
  3. 删掉所有 catch,只留 openFile("missing.txt"); → 程序打印 terminate called after throwing an instance of 'FileError'(具体文字由编译器决定)后终止。这就是"异常不能被忽略"。
  4. 写一个 ~Bad() { throw 1; } 的类,在 try 里构造再抛出看看 → 大概率以 terminate 收场 ——亲手确认"析构不能抛异常"这条规矩不是吓唬人。

常见误区

  1. "throw 抛出的是那个对象本身" ——是它的拷贝(拷贝到一个由运行时管理的异常对象里)。这带来两个后果:① 抛局部对象是安全的(拷贝已经飞出去了);② 异常类型必须能拷贝/移动。
  2. "throw; 和 throw e; 差不多" ——差很多:throw; 重抛原对象(类型完整);throw e; 会再拷一份 e ——如果 e 是按值捕获的(已经切片),拷出去的也是被切过的那个,外层接到的信息就少了。
  3. "抛 std::string 或 const char * 也挺好,反正能带信息" ——能带,但不能分层捕获、不能挂额外字段。惯例是抛"从 std::exception 派生的类对象"(这样 catch (const exception &e) 能兜底,14.2.4)。
  4. "抛 new 出来的指针,反正 catch 里能接住" ——"接住"和"谁来删"是两件事。抛裸指针 = 把内存管理的责任扔给一个可能不存在的人。 实在要抛指针,就抛智能指针(教材后续会讲)。
  5. "析构函数里抛异常是"更严格的检查"" ——是把程序送去 terminate。析构函数是"清理现场"的角色,它出事的唯一后果是"清理失败 + 程序终止"。 这一条比想象中严肃:C++11 起它默认就是 noexcept。
  6. "构造函数抛异常时,对象会走一遍析构把内存还回去" ——不会。析构函数只在"对象完整构造过"的前提下才会被调用。 所以构造函数必须自己清理(或者干脆把资源交给成员对象管,让栈展开替你清理)。
  7. "抛异常会跳回 catch 里继续执行,相当于一种 goto" ——能理解成"往外跳",但落地方式完全不同:它会把沿途每一层的作用域清理干净(栈展开),而 goto 什么都不管(5.3.4 说过 goto 不能跨函数、还会绕过初始化)。异常是"带着清理的外跳"。
自测三问(点开对答案) 1. **`throw e;` 和 `throw;` 的区别是什么?该用哪个?** ——**`throw e;` 会把 `e` 再拷贝一份抛出去;`throw;` 重抛"当前正在处理的这个异常对象"(类型不变)**。**要转交给外层,一律写 `throw;`** ——**否则如果 `e` 是按值捕获的(已切片),信息就丢了一层。** 2. **为什么析构函数里不能抛异常?** ——因为**栈展开过程本身就是"正在处理异常"**。**析构函数在此时再抛,就有两个异常同时在飞**,标准规定只能调用 **`std::terminate()`**(程序终止)。**C++11 起析构函数默认是 `noexcept` 的**,**异常逃出去就直接终止。** 正确做法:**在析构里 `try/catch` 兜住,出错只记日志。** 3. **构造函数里抛异常时,谁的析构会被调用、谁不会?** ——**这个类自己的析构函数不会被调用**(对象从未构造完成);**但已经构造完成的成员对象和基类子对象会**(它们是完整对象,栈展开会负责)。**所以:① 优先把资源交给成员对象(如 `vector` / `string`)管;② 非要自己管,就必须在每个 `throw` 之前先释放已经拿到的资源。**

相关笔记