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

catch 是"报警接收站",写法是 catch (类型 &变量) { … }。一个 try 后面可以挂任意多个 catch,它们像 switch 一样从前往后试 ——但和 switch 不同的是,它按"书写顺序"匹配,先到先得,不是"最精确的胜出"。 本节三个必记的点: 1. catch 一律用 const T &(按引用)捕获 ——按值捕获会把派生类"切片"成基类,信息丢一半; 2. catch 的顺序必须"从最具体到最笼统" ——基类写在前面会把派生类全截胡; 3. catch (...) 只能放在最后 ——它是"什么都接"的兜底网络。

📝 本节和前面几节的关系

14.2.1 讲了"怎么扔",本节讲"怎么接"。14.2.3 会把"到底什么异常能被哪个 catch 接住"这条规则讲成一张精确的表 ——本节先靠直觉走一遍(父子关系、先到先得),下一节再补上"哪些转换被允许"的细节。 本节和第 14.1 是一件事的两面:catch 的匹配过程,本质上就是拿异常的动态类型去和每一行 catch 里的类型做比较 ——和 dynamic_cast 是同一套"运行期类型检查"(所以按引用捕获才有意义:只有引用才能保住动态类型)。

一、最小骨架

try {
    // 风险区:可能抛出异常的代码
} catch (const SomeError &e) {
    // 接住这种错误,处理它
} catch (const OtherError &e) {
    // 接住另一种
} catch (...) {
    // 兜底:什么都能接(必须放最后)
}
// 捕获处理完后,程序从这里继续

几个语法要点:

二、匹配规则:按书写顺序,先到先得

这一点非常关键:catch 不是"找最精确的那一个",而是"从上往下第一个能接住的"。

try {
    throw NetError("超时");        // NetError 继承自 AppError
} catch (const AppError &e) {      // ← 写在前面
    // ★ 就在这里被接住并处理完了
} catch (const NetError &e) {      // ← 永远轮不到它
    // 这行代码是"死的"
}

后果与规矩:

catch 必须按"从最具体(派生最深)到最笼统(基类)"的顺序排列。 catch (...) 是"最笼统"的,永远放最后。

编译器会帮你:GCC 对"派生类写在基类后面"这种情况通常会给一个警告(大意是"这个类型的异常会被前面的处理器接走")——看到这种警告,就是在提醒你顺序写反了。 (注意:是警告不是错误,程序照样跑,但那个 catch 永远不会工作。这类"静默失效"最该警惕。)

三、坑一:按值捕获会"切片"

这是本节最实用的一条,也是"必须用引用"的原因。

class AppError : public runtime_error { … };
class NetError : public AppError { … };     // 更具体的类型,带了额外信息

try {
    throw NetError("超时");
} catch (AppError e) {          // ❌ 按值:拷贝时按 AppError 的大小切
    // 这里 e 是一个"被切过的" AppError,NetError 多出来的部分没了
}
catch (const AppError &e) {     // ✅ 按引用:绑定到真正的那个异常对象
    // e 的静态类型是 AppError,但动态类型还是 NetError
}

"切片"和 11.4.2 的对象切片是同一个机制:按值传递/拷贝只复制基类那部分的内存。

捕获方式 结果
catch (AppError e) 按值拷贝 → 切片(派生信息丢失,typeid(e) 也变成基类)
catch (AppError &e) 引用绑定 → 完整(typeid(e) 仍是真身;但引用能改到异常对象,一般不希望你改)
catch (const AppError &e) ✅ 推荐写法:不切片 + 不能改 + 能接住常量异常

所以规矩一句话:catch 的参数一律写 const T &。

四、坑二:catch (...) 的位置

catch (...) { }        // 三个点,意思是"无论什么类型"

它是兜底网:任何类型的异常都能被它接住(包括内置类型 throw 42;、包括别人写的奇怪类型)。

它的位置只能是最后 ——写在前面,后面的 catch 全部作废(编译器一般也会拦/警告)。

什么时候该用它:在"最外层"保证程序不会裸死 ——记一笔日志、释放点全局资源、然后决定是继续还是退出。

int main() {
    try {
        run();
    } catch (const exception &e) {
        cerr << "标准异常:" << e.what() << endl;      // 能说清是什么
    } catch (...) {
        cerr << "未知异常(类型不明)" << endl;         // 兜底:至少留个痕迹
    }
}

注意 catch (...) 的缺点:它接住了,但你说不出"接住的是什么" ——没有变量、没有类型、没有信息。 所以它只适合"收尸",不适合"处理"。

五、坑三:捕获之后,控制流不会再回到 throw 那一行

这一点在 14.2 提过,这里再钉一次(因为它决定了"异常不能当重试机制"):

try ──┬── 语句1
      ├── throw ──────────┐
      └── 语句2(不执行)  │
                          ▼
                    catch(执行处理)
                          │
                          ▼
                    从 try/catch 之后继续

catch 块执行完,程序从"整组 try/catch 的后面"继续走 ——try 里 throw 之后的语句不会补执行,也不会回到 throw 那里。

想要"重试",只能自己在 catch 里写循环/递归:

for (int attempt = 1; attempt <= 3; attempt++) {
    try {
        connect();
        break;                       // 成功就跳出重试循环
    } catch (const NetError &e) {
        cerr << "第 " << attempt << " 次失败:" << e.what() << endl;
        if (attempt == 3) throw;     // 三次都失败,交给上层
    }
}

这段代码同时演示了"catch 里能做些什么"(见下一节)。

六、catch 里该干什么:三件事

一个 catch 里能干的事,其实就三类:

事 例子 说明
① 记录下来 写日志、计数、上报 最常见("我处理不了,但我要留证据")
② 就地解决 重试、用默认值兜底、提示用户重输 只有你真的知道怎么办时才做
③ 转交/包装 throw;(原样上抛)或 throw AppError("上层信息:" + e.what())(换一个类型再抛) "包装"能加上下文信息,代价是原始类型被换掉(要注意别把有用的类型信息丢掉)

反面教材(最坏的一种):

try { doSomething(); }
catch (...) { }          // ☠️ 接住了,什么都不做,还一点痕迹不留

这比"不写 try"更糟:不写 try 的话,异常会飞出去(至少能被别人看见);这么写,错误就人间蒸发了。 如果实在只是想"忽略",也要写清楚为什么忽略(比如"文件不存在是允许的,继续")。

另一条分寸(14.2 说过,这里再强调):

"能在哪儿处理,才在哪儿 catch。" 捕不了、不知道怎么处理,就让它往上飞。 到处 catch 会让错误"停在错误的层" ——顶层的人以为一切正常,其实中间早就出过事了。

七、动手 Demo:切片、顺序、兜底

任务:把三个坑一次性看全 ——按值捕获的切片、顺序写反的截胡、catch(...) 的兜底。

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

class AppError : public runtime_error {          // 基类
public:
    AppError(const string &m) : runtime_error(m) {}
};

class NetError : public AppError {               // 派生类(更具体)
public:
    NetError(const string &m) : AppError("网络:" + m) {}
};

int main() {
    // ── ① 按值捕获:会切片 ─────────────────
    cout << "-- (1) 按值捕获 --" << endl;
    try {
        throw NetError("超时");
    } catch (AppError e) {                       // ❌ 按值
        cout << "  内容 = " << e.what() << endl;
        cout << "  类型 = " << typeid(e).name() << "(退化成基类了)" << endl;
    }

    // ── ② 按 const 引用捕获:不切片 ─────────
    cout << "-- (2) 按 const 引用捕获 --" << endl;
    try {
        throw NetError("超时");
    } catch (const AppError &e) {                // ✅ 引用
        cout << "  内容 = " << e.what() << endl;
        cout << "  类型 = " << typeid(e).name() << "(还是真身)" << endl;
    }

    // ── ③ 顺序:基类写在前面会截胡 ──────────
    cout << "-- (3) 顺序(先到先得)--" << endl;
    try {
        throw NetError("超时");
    } catch (const AppError &e) {                // 写在前面 → 它先接
        cout << "  被 AppError 接走了:" << e.what() << endl;
    } catch (const NetError &e) {                // 永远轮不到(编译器可能给警告)
        cout << "  这句永远不会执行" << endl;
    }

    // ── ④ catch(...) 兜底 ──────────────────
    cout << "-- (4) catch(...) 兜底 --" << endl;
    try {
        throw 42;                                // 抛了个 int
    } catch (const exception &e) {
        cout << "  这句不执行(int 不是 exception)" << endl;
    } catch (...) {
        cout << "  兜底接住了未知异常" << endl;
    }

    // ── ⑤ 捕获之后从 catch 后面继续 ─────────
    cout << "-- (5) 捕获之后 --" << endl;
    try {
        cout << "  try 里:准备抛" << endl;
        throw runtime_error("炸");
        cout << "  try 里:永远到不了" << endl;
    } catch (const exception &e) {
        cout << "  catch 里:处理 " << e.what() << endl;
    }
    cout << "  从 catch 之后继续往下跑(没有回到 throw 那一行)" << endl;
    return 0;
}

看到什么算成功:

-- (1) 按值捕获 --
  内容 = 网络:超时
  类型 = 8AppError(退化成基类了)
-- (2) 按 const 引用捕获 --
  内容 = 网络:超时
  类型 = 8NetError(还是真身)
-- (3) 顺序(先到先得)--
  被 AppError 接走了:网络:超时
-- (4) catch(...) 兜底 --
  兜底接住了未知异常
-- (5) 捕获之后 --
  try 里:准备抛
  catch 里:处理 炸
  从 catch 之后继续往下跑(没有回到 throw 那一行)

读结果:

30 秒实验

  1. 把 (1) 的 catch (AppError e) 改成 catch (const AppError &e) → 8AppError 变成 8NetError。一句话之差,信息全回来了。
  2. 把 (3) 两个 catch 上下调换(NetError 在前)→ 输出变成"被 NetError 接走了" ——顺序一换,谁接就变了。
  3. 把 (4) 的 catch (...) 删掉 → int 那个异常没人接 → 直接 terminate(程序在 (4) 处死掉,后面的 (5) 也不会执行)。
  4. 在 (2) 的 catch 里加一行 throw; → 异常被原样继续抛出,如果没有外层 catch 就 terminate。试给 main 外面再包一层(改成 try { run(); } catch (const exception &e) {} 那种结构)看它被谁接走。

常见误区

  1. "catch 会挑最精确的那个来接" ——不会。它按书写顺序试,第一个能接的就进去,后面的连看都不看。 所以顺序必须"从具体到笼统"。
  2. "catch (AppError e) 和 catch (const AppError &e) 差不多" ——差一个引用,差一个世界:按值会切片(派生信息丢失)、按引用不会。而且按值还要多拷一次异常对象(异常处理本身就贵,别再雪上加霜)。
  3. "catch (...) 最保险,放最前面最安全" ——放前面的话,后面所有 catch 全部失效(程序照样跑,你不会得到任何提示)。它只能放最后。
  4. "接住了就好,处理不处理无所谓" ——什么都不做的 catch 是"错误黑洞"(catch (...) { } 是最坏的一种):它把"能被发现的错误"变成"永远不会被发现的错误"。 最多在"最外层"用它留个日志。
  5. "异常被 catch 之后就"解决"了,程序回到出事的地方继续" ——不会回到出事的地方(try 里 throw 之后的语句永远不执行),只会从 catch 之后继续。 想要"重试",必须自己在 catch 里写循环(Demo 里的重试范式)。
  6. "每个可能出错的函数都该包一下 try/catch" ——不该。捕不了就别捕(14.2 的分寸):错误应该交给"知道怎么办"的那一层。 乱捕的后果是"错误停在错误的层",而且每一层都以为自己处理好了。
  7. "catch (const exception &e) 能接住所有异常" ——接不住 int / const char * 这类非类类型(那是 catch (...) 的活)。所以标准库的异常(派生自 std::exception)能被它接住,但别人随手 throw 404; 就不能(14.2.3 会讲那条精确的匹配规则)。
自测三问(点开对答案) 1. **`catch` 为什么必须"从具体到笼统"排列?** ——因为 **`catch` 是"按书写顺序,第一个能接住的就进去"**,**后面的连试都不试**。**把基类写在前面,它会把所有派生类异常全截胡**,后面的 `catch` 变成死代码(编译器可能给警告)。 2. **为什么 `catch` 参数要写 `const T &`?** ——因为**按值捕获会切片**(拷贝时只复制基类部分,派生类的额外信息丢失、`typeid` 也退化成基类);**`const` 是"我不打算改这个异常对象"的声明**;**引用还能少一次拷贝**(异常本来就贵)。**一句话:按引用捕获才能保住异常的"真身"。** 3. **`catch (...)` 该怎么用?为什么不能写在前面?** ——它是**"什么都接"的兜底网**,用来在**最外层**保证程序不裸死(**至少记一条日志**)。**写在前面会让后面所有 `catch` 失效**(而且可能连警告都没有)。**它的缺点是"接住了却说不清接住的是什么"** ——**所以只适合收尸,不适合处理。**

相关笔记