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

异常处理是"把'出事了'从'正常流程'里拎出来单独说"的一套机制。 三个关键字:try(盯着这段代码)/throw(出事就扔出去)/catch(扔到我这,我来处理)。 它解决的是一件非常具体的麻烦:用返回值报错,有三个死穴 ——① 能被忽略;② 占用返回值;③ 要一层层手写传递。 它最值钱的赠品不是"能报错",而是"栈展开":异常往上飞的时候,沿途每一层的局部对象都会被正确析构 ——这就是 C++ 里"资源管理靠对象"(10.3 的析构)之所以成立的地基。

📝 本节和前面几节的关系

本节是第十章"构造与析构"的正式兑现。 10.3 讲过:局部对象离开作用域就析构,析构顺序与构造相反 ——但你那时可能有个疑问:"那要是函数中途return了、或者出错了呢?" 答案就是本节:出错了也一样。异常会替你把该调的析构全调一遍,然后再把错误送到处理它的地方。 它也是本章前半段的呼应:14.1 讲"运行期按类型判断",14.2 讲"运行期按类型匹配" ——catch 后面那个括号里的类型,就是一次运行期的类型匹配(14.2.2 / 14.2.3 会讲透它和 dynamic_cast 是同一套逻辑)。 往回想:6.2.5 的"返回值"(一个函数只有一个出口)——异常是一条"不走返回值"的出口;6.3 的调用栈(一摞便签)是"栈展开"这个名字的来历。

一、先看"不用异常"有多难受

假设要写一个"读配置、算比例、写结果"的流程,中间可能出错。用返回值报错,大概是这样:

int readConfig(Config &c);          // 0 = 成功,负数 = 各种错误
int compute(const Config &c, double &out);
int writeResult(const string &f, double v);

int main() {
    Config c;
    double r;
    int err;
    if ((err = readConfig(c)) != 0) { /* 处理 */ return err; }
    if ((err = compute(c, r))  != 0) { /* 处理 */ return err; }
    if ((err = writeResult("out.txt", r)) != 0) { /* 处理 */ return err; }
    ...
}

这段代码有四个死穴:

死穴 说明
① 能被忽略 readConfig(c); 不接返回值,编译器一声不响 ——错误就这么消失了。 这是最致命的一条
② 占用了返回值 返回值只有一个位置。 想"返回结果 + 返回错误码",就得多一个"输出参数"(double &out),接口被撑胖
③ 要手动传递 每一层都要写 if (err) return err; ——中间任何一层忘了、或者想"顺手做点别的事"忘了传,链路就断了
④ 和合法值混在一起 -1 到底是"出错"还是"算出来的结果就是 -1"? 返回值语义被污染(6.2.5 讲过"返回值只能是那一个")

异常对这四个死穴的回答是:

死穴 异常怎么办
① 能被忽略 忽略不了:没人 catch 就一路往上飞,最后终止程序 ——想装看不见都不行
② 占用返回值 throw 是"第二套返回通道":返回值照常返回结果,错误走异常,互不干扰
③ 要手动传递 不用传:自动沿调用栈向上找"谁愿意接",中间层可以完全不管
④ 混在一起 类型分开:正常走返回值(double),异常走异常对象(FileError),一目了然

二、三个关键字:一幅图

        try {
            ──── 正常流程 ────
            if (出事了)  throw 某个异常对象;      ← ① 扔
            ──── 后面的正常流程(不执行了)────
        }
        catch (类型A &e) { ── 处理类型A ── }       ← ② 接
        catch (类型B &e) { ── 处理类型B ── }
        catch (...)      { ── 什么都能接 ── }

        ③ 没人接?→ 继续向上抛,直到 main 之外 → std::terminate(程序终止)

用一句话说清三者的关系:

try 划定"风险区",throw 是"报警器",catch 是"报警接收站"。 报警器一响,风险区里剩下的代码立刻不执行了 ——控制流要去找接收站。

关键概念:一个函数调用链上,只要有一层写了 try/catch,上面几层的代码就不用操心这件事。

main ──► A() ──► B() ──► C() ──► throw
         │
         └──── 异常从 C 一路飞回 main,只要 main 里有 catch 就行;
               A、B、C 里都不用写任何"传递错误"的代码

三、最值钱的赠品:栈展开(stack unwinding)

这是本节最需要理解的一段。

"函数调用的局部变量都在栈上"(6.7 讲过)。异常从深处一路往外飞的时候,沿途那些"已经离开"的函数,它们的局部变量怎么办?

标准规定:异常传播时,每一层被"路过"的作用域都要按正常退出的规则清理 ——也就是说:try 里、try 的调用链上所有"正在活着"的局部对象,都会按"后构造先析构"的顺序被析构。 这个过程叫"栈展开"。

为什么这是"最值钱的赠品"?因为它让下面这句话变成了铁律:

只要把资源交给一个"析构函数里负责释放它"的对象,那么"出错"和"正常返回"对资源来说是一样的 ——都会被释放。

void risky() {
    FILE *fp = fopen("a.txt", "r");     // ① 手工管理:出错了要记得关
    if (somethingBad()) {
        fclose(fp);                     // ← 忘了这句就漏了
        throw runtime_error("坏了");
    }
    fclose(fp);                         // ← 正常路径也要写一次
}

// ── 换成"交给对象管" ────────────────────
void safe() {
    ifstream f("a.txt");                // 对象:构造时打开
    if (somethingBad()) throw runtime_error("坏了");
    // 不用手写关闭:异常飞出去时,f 的析构函数会被栈展开调用 → 自动关闭
}

这就是 C++ 资源管理的核心套路(后面会专门学到一个名字:RAII ——"资源获取即初始化"): 把"释放"的代码写进析构函数,剩下的交给栈展开。

两条推论(都是本节的重点):

  1. 析构函数必须"可靠" ——因为栈展开会调用它,如果它自己抛异常,就成了"处理异常时又出事",程序只能直接终止。 所以 C++11 起析构函数默认不抛异常(14.2.1 会讲这条规矩)。
  2. 构造函数抛异常时,这个对象的析构函数不会被调用(对象从来没"生下来")——所以构造函数要自己收拾自己已经拿到的资源(10.2 / 10.3 的账,14.2.1 一起还)。

四、代价与分寸:什么时候该用异常

异常不是没有代价的(14.2.2 会细说),但代价的位置很反直觉:

情况 代价
没抛异常的正常流程 接近零开销(现代编译器用"零成本异常模型":正常路径上几乎没有额外指令)
抛出并处理异常 很贵(要查表、沿栈展开、构造并拷贝异常对象)

所以判据非常清楚:

异常是给"异常的事"用的,不是给"每天都会发生的事"用的。

场景 该用异常吗
除数为 0 且这是"不该发生"的输入 ✅ 该抛
内存不够(new 失败) ✅ 标准库就抛 bad_alloc
文件打不开、连接失败 ✅ 该抛(这是"环境不配合")
用户在登录框里输错了密码 ❌ 不该 ——这是"每天都在发生"的正常分支,用返回值/布尔就行
循环里"读到末尾" ❌ 不该 ——流用 eof() 状态表示,不抛异常
用异常做循环控制流 ❌ 绝对不该(性能+可读性双输)

另一条分寸:"能在哪儿处理,才在哪儿 catch。" 捕不了、也不知道怎么办,就别拦它 ——让它继续往上飞,交给真正知道怎么办的那一层。 这也是为什么 14.2.2 里"到处写 catch 然后什么都不做"是最坏的写法。

五、本章地图

14.2 异常处理
├─ 14.2.1 抛出异常     throw 怎么用、抛什么类型、throw 之后会发生什么
│                      + 两个雷区(析构里别抛、构造里抛要自己收拾)
├─ 14.2.2 异常捕获     try / catch 的语法;顺序、切片、catch(...)
├─ 14.2.3 异常匹配     到底"什么异常能被哪个 catch 接住"(规则比想象的严格)
└─ 14.2.4 标准异常     std::exception 家族;怎么自己写异常类

六、动手 Demo:先把"栈展开"看一遍

任务:让异常穿过两层函数飞出来,在每一层放一个"会说话的局部对象",看它们是不是都被正确析构了。

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

// 一个"会报出自己生灭"的类 —— 用来看栈展开
class Guard {
public:
    Guard(const string &n) : name_(n) { cout << "  + 构造 " << name_ << endl; }
    ~Guard()                          { cout << "  - 析构 " << name_ << endl; }
private:
    string name_;
};

// ── 旧办法:返回值报错 ─────────────────────
bool safeDivide(double a, double b, double &out) {
    if (b == 0) return false;          // 报错靠返回值(调用方可能忘了看)
    out = a / b;
    return true;
}

// ── 新办法:抛异常 ─────────────────────────
double divide(double a, double b) {
    Guard g("divide 里的 Guard");
    if (b == 0) throw invalid_argument("除数不能为 0");     // ★ 扔出去
    return a / b;
}

void middle() {
    Guard g("middle 里的 Guard");
    divide(1, 0);                      // 异常从这里飞过 middle
    cout << "  middle:这行永远不会执行" << endl;
}

int main() {
    // ① 返回值版:安全性靠"我记不记得检查"
    double r = 0;
    if (safeDivide(10, 0, r)) cout << "① 结果 " << r << endl;
    else                      cout << "① 出错了(幸好我记得看返回值)" << endl;

    // ② 异常版:错误自动找到处理它的地方
    cout << "-- ② 开始抛,注意每层的 Guard --" << endl;
    try {
        middle();
    } catch (const invalid_argument &e) {
        cout << "② 在 main 里接住了:" << e.what() << endl;
    }
    cout << "-- ② 结束 --" << endl;
    return 0;
}

看到什么算成功:

① 出错了(幸好我记得看返回值)
-- ② 开始抛,注意每层的 Guard --
  + 构造 middle 里的 Guard
  + 构造 divide 里的 Guard
  - 析构 divide 里的 Guard
  - 析构 middle 里的 Guard
② 在 main 里接住了:除数不能为 0
-- ② 结束 --

读结果(这一屏就是本节的全部重点):

如果没人接会怎样? 把 try/catch 整段删掉:程序会打印 terminate called after throwing an instance of 'std::invalid_argument' 然后直接结束。 两个 Guard 仍然会被析构(栈展开照做),但程序死了 ——这就是"异常不能被忽略"的代价,也是它的价值。

30 秒实验

  1. 把 middle() 里的 Guard g 注释掉,再跑:少一行构造/析构,但结论不变。试着在 main 里也放一个 Guard(放在 try 之前)→ 它不会在异常时析构(因为异常被 catch 接住后,main 继续跑,直到作用域自然结束)。体会"栈展开只清理"路过但不返回"的作用域"。
  2. 把 catch (const invalid_argument &e) 改成 catch (const exception &e) → 照样接得住(invalid_argument 是 std::exception 的派生类,14.2.4 讲这套家族)。
  3. 在 divide 里 throw 之前加一句 cout << "抛之前" << endl;,throw 之后加一句 cout << "抛之后" << endl; → 只会看到"抛之前"。
  4. 写一个死循环抛异常的 demo(比如 100 万次 try { throw 1; } catch (int) {})——跑起来会明显变慢。这是"异常不适合当控制流"的现场证据。

常见误区

  1. "异常就是"另一种 return" ——差别很大:return 是回到调用者并给一个值;异常是"跳过所有中间层、直接找接收站",而且中间层可以被"强制清理"(栈展开)。 异常是一条"越过调用者的紧急通道"。
  2. "用异常会拖慢程序,所以我全用返回值" ——正常路径上异常几乎没有开销(零成本异常模型)。真正贵的是"抛出并处理"那一刻。 所以判据是"这件事多久发生一次",不是"有没有用异常"。
  3. "异常是给"错误"用的,只要是错误就该抛" ——"错误"和"异常"不是一回事:用户输错密码、读到文件末尾、查找没找到,都是"正常的分支",不该用异常(用返回值/状态位)。异常留给"不该发生、而且本层处理不了"的事。
  4. "每个函数都该 try/catch 一下,显得稳妥" ——恰恰相反:捕不了、也不知道怎么办,就别捕。 "捕获了什么都不做"更是最坏的做法 ——它把"能被发现的错误"变成了"悄悄消失的错误"(比不用异常还糟)。
  5. "异常能把出错的那行代码"续上"" ——不能。throw 之后本层剩下的语句全部不执行;catch 执行完,程序从 catch 之后继续(不会回到 throw 的地方)。这是异常和"回调/重试"最本质的区别。
  6. "析构函数里抛异常没关系,反正会被接住" ——很有关系:栈展开过程中如果析构函数又抛一个异常,就有两个异常同时"在飞" ——标准规定这时直接调用 std::terminate(程序终止)。 所以 C++11 起析构函数默认不抛异常(14.2.1)。
  7. "try 包得越大越好,一次包住整个 main" ——要分情况:"兜底一个总 catch"确实有用(防止程序裸死、留个日志),但"能在哪儿处理就在哪儿捕"更重要。try 块越小,"哪些变量还活着"越清楚(栈展开对 try 里已构造的对象才有效)。
自测三问(点开对答案) 1. **异常处理解决了"用返回值报错"的哪四个死穴?** ——**① 错误能被忽略**(异常不接就会终止,忽略不了);**② 占用返回值**(异常是第二套通道,返回值照常返回结果);**③ 要层层手动传递**(异常自动沿调用栈找接收站);**④ 错误码与合法值混淆**(类型分开了)。 2. **什么是栈展开?为什么说它是"最值钱的赠品"?** ——**异常向上传播时,沿途每一层局部对象都会按"后构造先析构"的顺序被析构** ——**这个过程叫栈展开。** 它的价值是:**只要把资源交给一个"析构函数负责释放"的对象,出错路径和正常路径都能自动释放** ——**这就是 C++ 资源管理(RAII)的地基。** 3. **"异常不能当控制流用"的判据是什么?** ——**问一句"这件事多久发生一次"**:**罕见且本层处理不了 → 异常**;**每天都会发生(输错密码、读到末尾、查找失败)→ 不用异常**。**代价的角度**:不抛异常时几乎没有开销,抛出并处理那一下非常贵。

相关笔记