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

异常匹配的规则比"能隐式转换就能接住"要严得多。 标准只允许四种转换: 1. 精确匹配(类型完全一样); 2. 加上/去掉 const(限定转换); 3. 派生类 → 基类(最常用的"宽松"); 4. 指针的标准转换(Derived * → Base *、任意指针 → void *)。 明确不允许的两类,是本节最该记住的:算术转换(throw 3; 接不进 catch (double))、用户定义转换(throw "abc"; 接不进 catch (std::string))。 配上一个 noexcept:函数可以声明"我保证不抛" ——一旦异常逃出去,直接终止(这是给编译器优化的承诺,也是给调用方的契约)。

📝 本节和前面几节的关系

14.2.2 凭直觉讲了"派生类写在基类前面",本节把规则补成精确的一张表 ——为什么"派生 → 基类"可以、而"int → double"不可以? 因为前者的转换语义是"我知道这是它的祖先,我接受这份更一般的视角",而后者是"类型都不一样,你凭什么接"。 它和 14.1.4 是同一套思路:catch 的匹配本质上是一次"隐形转换" ——把"异常对象"隐式转换成 catch 里声明的类型(允许的转换清单和隐式转换的清单并不一样,这正是本节的价值)。 noexcept 这一半,是把前面所有"抛异常"的讨论反过来说:不抛也是一种信息 ——它让调用方(甚至编译器)敢做更多优化。

一、先记住"匹配"在做一件什么事

catch (T 参数) 的意思可以翻译成一句话:

"这个异常对象,能不能隐式转换成 T 类型?能的话,我就接。"

但"隐式转换"在这里被单独收紧了 ——只留下四种,其余全不许。

二、允许的四种转换(完整清单)

# 允许的转换 例子
① 精确匹配(类型一样,或只差 cv 限定) throw 3; → catch (int);throw 3; → catch (const int &)
② 派生类 → 基类(对象、引用、指针都行) throw Derived(); → catch (const Base &)
③ 数组 / 函数 → 指针 throw "abc"; 抛出的其实是 const char *
④ 指针的标准转换 Derived * → Base *;任意对象指针 → void *

注意 ② 里"对象"这一种的含义:catch (Base b) 也能接住 Derived 异常 ——它会做"派生类对象 → 基类对象"的拷贝,也就是说会切片(14.2.2 的坑一在这里有了正式解释:切片不是意外,是标准允许的转换,只是我们不想要它)。

引用这一种是"不切片"的关键:catch (const Base &b) 里,b 绑定到异常对象的 Base 子对象上,异常对象本身仍然是 Derived ——所以 typeid(b) 还能问出真身(Demo ②)。

三、明确不允许的两类(本节重点)

① 不允许"算术转换"

try {
    throw 3;                     // 抛一个 int
} catch (double d) {             // ❌ 接不住!int → double 属于算术转换
    cout << "不会执行" << endl;
}
// 结果:这个异常没人接 → 继续往上飞 → 最后 terminate

为什么标准要这么定? 因为"能接住"应该意味着"你真的打算处理这种东西":

如果 int 能悄悄被 catch (double) 接住,那下一个 bug 就是"我以为我在处理浮点错误,结果接住的是一个错误码 3"。

这条规矩和函数重载/模板推导是一个态度:C++ 不喜欢"悄悄的、能省一步的类型转换"(12.1.2 的 f(3, 4.5) 报 deduced conflicting types 也是同一个脾气)。

这条规矩的实用结论:抛什么类型,catch 就写什么类型 ——要么精确、要么走继承关系,别指望"差不多能转"。

② 不允许"用户定义转换"

try {
    throw "abc";                  // 抛的是 const char *
} catch (const std::string &s) {  // ❌ 接不住!const char* → string 要走构造函数
    cout << "不会执行" << endl;
}
// 结果:没人接 → terminate

const char * 想变成 std::string,必须调用 string 的构造函数 ——那叫"用户定义转换",不在允许清单里。

同理:

抛出的类型 catch 类型 能不能接
const char *(throw "abc") const char * / const void * ✅
const char * std::string ❌ (用户定义转换)
int int / const int & ✅
int double / long ❌ (算术转换)
Derived const Base & / Base / const Base * ✅
Base Derived ❌ (反向的继承转换不允许)
任意类型 ... ✅

最后一行说明:catch (...) 是绕开所有规则的后门 ——它什么都接。所以"接不住的异常"最后的归宿只有两个:catch (...),或者 terminate。

四、匹配的是"异常对象自己的类型"

"派生 → 基类"能被接住,是因为"异常对象本身就是一个派生类对象"。所以关键问题变成:抛出去的那个异常对象,到底是什么类型?

规则很短:

异常对象的类型 = throw 那句话里表达式的静态类型(引用会被剥掉),再按这个类型拷贝一份出来。

两种写法会让人意外:

// ① 经过"基类引用"中转 → 抛出去的是被切成基类的对象
Derived d;
Base &r = d;
throw r;                // 异常对象类型 = Base(Derived 那部分被切掉了)
// catch (const Base &) 能接;catch (const Derived &) 接不住

// ② 直接抛具体类型的对象 → 类型完整
throw d;                // 异常对象类型 = Derived(d 的静态类型就是 Derived)
// catch (const Base &) 和 catch (const Derived &) 都能接

指针也一样,按"指针自己的类型"算:

Derived d;
Base *p = &d;
throw p;                // 异常对象类型 = Base *(不是 Derived *)
// catch (Base *) 能接;catch (Derived *) 接不住(这里不做"向下转换")

所以实用结论是两条:

  1. 要保留完整类型,就直接 throw 一个具体类型的对象(throw Derived();),别经过基类引用/指针中转;
  2. "既能被基类 catch 兜住、又能保住真身"靠的是 catch (const Base &) 这个引用 ——前提是异常对象本身就是派生类。

这也把 14.2.2 的坑说圆了:按值捕获会切片,是因为"派生类对象 → 基类对象"这个拷贝被标准允许了;按引用捕获不切片,是因为引用直接绑定在异常对象上,什么都没拷。

一句话:异常对象的类型,"以 throw 那句话里写出来的类型为准"。 想清这一点,就不会惊讶于"为什么 catch (const Derived &) 没接住"。

五、noexcept:承诺"不抛"

前面所有内容都在讲"怎么抛、怎么接",noexcept 是把话说反过来说:

void f() noexcept;              // 我保证不抛(抛了就是我的错)

规则:

默认就是 noexcept 的场合(记住这几类):

场合 说明
析构函数 14.2.1 讲过:栈展开途中再抛,就有两个异常同时在飞 ——所以默认"不许抛"
operator delete / operator delete[] 内存释放失败没法处理
移动构造 / 移动赋值(在成员都不抛的前提下) 标准库据此决定"搬还是拷"(vector 扩容时优先搬)

查询一个表达式会不会抛:noexcept(...)(此时它是运算符,不是说明符)

cout << noexcept(f());          // 1(f 声明了 noexcept)或 0

虚函数的 noexcept 是契约的一部分:

一句话:noexcept 是"对外承诺",写下去就等于告诉全世界"这里不需要 try"。承诺之前,先确定自己真的做得到。

顺带认得一个老写法:throw() 是 C++98 时代的等价物(C++11 起被弃用、C++17 移除)。在旧教材/旧代码里见到 void f() throw();,它就是老版的 noexcept。

六、动手 Demo:把匹配表逐条验一遍

任务:把"能接住"的几种各验一次,把"接不住"的两种写成注释 + 说明(因为它们会直接 terminate)。

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

class Base { public: virtual ~Base() {} };
class Derived : public Base {};

void safeThing() noexcept {}                   // 声明"我保证不抛"

int main() {
    // ── ① 精确匹配 + 限定转换 ───────────────
    try {
        throw 3;
    } catch (const int &n) {                       // const 引用:允许
        cout << "① int → const int & :接住 " << n << endl;
    }

    // ── ② 派生 → 基类(最常用的宽松)────────
    try {
        throw Derived();
    } catch (const Base &b) {                      // 允许,而且不切片
        cout << "② Derived → const Base & :接住,动态类型是 "
             << typeid(b).name() << endl;
    }

    // ── ③ 指针的标准转换 ───────────────────
    try {
        throw "出错了";                             // 抛的是 const char *
    } catch (const void *p) {                      // 任意指针 → void *:允许
        cout << "③ const char * → const void * :接住了指针" << endl;
    }

    // ── ④ 反过来的继承转换不允许 ─────────────
    // try { throw Base(); } catch (const Derived &d) { }   // ❌ 接不住 → terminate

    // ── ⑤ 算术转换不允许 ──────────────────
    cout << "⑤ 这两段如果放开,都会直接 terminate:" << endl;
    cout << "   throw 3;        → catch (double d)        ← 算术转换,接不住" << endl;
    cout << "   throw \"abc\";   → catch (const string &)  ← 用户定义转换,接不住" << endl;

    // ── ⑥ catch(...) 什么都能接 ────────────
    try {
        throw 3.5;
    } catch (...) {
        cout << "⑥ catch(...) 接住了 double" << endl;
    }

    // ── ⑦ noexcept 查询 ──────────────────
    cout << "⑦ noexcept(1 + 1) = " << noexcept(1 + 1) << "(纯表达式,不会抛)" << endl;
    cout << "   noexcept(safeThing()) = " << noexcept(safeThing()) << "(它声明了 noexcept)" << endl;
    return 0;
}

看到什么算成功:

① int → const int & :接住 3
② Derived → const Base & :接住,动态类型是 7Derived
③ const char * → const void * :接住了指针
⑤ 这两段如果放开,都会直接 terminate:
   throw 3;        → catch (double d)        ← 算术转换,接不住
   throw "abc";   → catch (const string &)  ← 用户定义转换,接不住
⑥ catch(...) 接住了 double
⑦ noexcept(1 + 1) = 1(纯表达式,不会抛)
   noexcept(safeThing()) = 1(它声明了 noexcept)

读结果:

30 秒实验

  1. 把 ② 的 catch (const Base &b) 改成 catch (Base b) → 7Derived 变 4Base。这就是"按值捕获 = 切片"的铁证(14.2.2 的结构在匹配规则里有了正式解释)。
  2. 放开 ⑤ 的第一段(throw 3; + catch (double d))→ terminate。注意 GCC 在编译时可能就给你一条警告(大意是"这个 int 异常不会被这个处理器接住")——"警告要当错误看"在异常这块尤其适用。
  3. 放开 ⑤ 的第二段 → 同样 terminate。 在它后面加一行 catch (...) → 又活了(被兜底网接住)。这直观说明"catch (...) 是不可少的安全网"。
  4. 写一个 void g() noexcept { throw 1; } 并在 main 里 try { g(); } catch (int) {} → 照样 terminate ——因为异常在"离开 g 的那一刻"就已经触发了 noexcept 的后果,根本没机会进到你的 catch 里。 这是 noexcept 最反直觉的一点。

常见误区

  1. "能隐式转换就能被 catch 接住" ——不是。只允许四种(精确 / cv 限定 / 派生→基类 / 指针标准转换)。算术转换和用户定义转换都不算。
  2. "throw 3; 能被 catch (double) 接住" ——接不住(而且编译期不报错、运行期直接死)。抛什么类型就 catch 什么类型。
  3. "throw "abc"; 能被 catch (const string &) 接住" ——接不住(const char * → string 是用户定义转换)。要么 catch (const char *),要么改成抛 std::string(更好的做法:抛一个从 std::exception 派生的类,14.2.4)。
  4. "catch (Base b) 和 catch (const Base &b) 一样能接住派生类异常,所以随便" ——都能接住,但前者会切片(typeid 退化成 Base、派生类多出来的信息丢了)。catch 一律写引用。
  5. "catch (Derived &) 能接住 throw Base();" ——不能(反向的继承转换不允许)。基类异常不能被"更具体的类型"接住 ——这也是为什么 catch 要"从具体到笼统"排列:更具体的 catch 只能接"确实更具体"的异常。
  6. "throw 一个 Base &,异常类型会跟着真实对象走" ——它以 throw 那句话里表达式的静态类型为准(引用会被剥掉):Base &r = derived; throw r; 抛出去的,是被切片成 Base 的异常对象,catch (const Derived &) 接不住。 要保住类型,就直接 throw 具体类型的对象本身(别用基类引用/指针中转)。
  7. "noexcept 只是给编译器优化的提示" ——它首先是一条"契约":一旦异常逃出去就 terminate(连外层 catch 都拦不住)。所以"拿不准就别写" ——乱写 noexcept 等于给自己埋一颗"异常时直接死"的雷。
  8. "noexcept 是函数自己的事,跟继承无关" ——关系很大:基类虚函数写了 noexcept,派生类重写时不能去掉(不能从"不抛"变成"可能抛")。承诺只能收紧,不能放松。
自测三问(点开对答案) 1. **异常匹配允许哪四种转换?** ——**① 精确匹配(含 cv 限定差异);② 派生类 → 基类(对象/引用/指针都算);③ 数组、函数 → 指针;④ 指针的标准转换(`Derived *` → `Base *`、任意指针 → `void *`)。** **不允许:算术转换、用户定义转换。** 2. **`throw 3;` 为什么接不进 `catch (double)`?这种错误什么时候被发现?** ——因为**`int → double` 是算术转换,不在允许清单里**。**它不会在编译期报错**(**运行时没人接 → `terminate`**)。**编译器可能给一条警告,所以要"把警告当错误看"。** 3. **`noexcept` 的两层含义是什么?** ——**① 契约**:**承诺本函数不抛**;**异常一旦逃出这个函数就调用 `std::terminate()`**(外层 `catch` 也救不了);**② 优化许可**:**编译器/标准库据此决定"要不要准备栈展开"、"移动还是拷贝"**。**写它的前提是"真的确定不抛"**;**析构函数默认就是 `noexcept`。**

相关笔记