异常匹配的规则比"能隐式转换就能接住"要严得多。 标准只允许四种转换:
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 *) 接不住(这里不做"向下转换")
所以实用结论是两条:
- 要保留完整类型,就直接
throw一个具体类型的对象(throw Derived();),别经过基类引用/指针中转; - "既能被基类
catch兜住、又能保住真身"靠的是catch (const Base &)这个引用 ——前提是异常对象本身就是派生类。
这也把 14.2.2 的坑说圆了:按值捕获会切片,是因为"派生类对象 → 基类对象"这个拷贝被标准允许了;按引用捕获不切片,是因为引用直接绑定在异常对象上,什么都没拷。
一句话:异常对象的类型,"以 throw 那句话里写出来的类型为准"。 想清这一点,就不会惊讶于"为什么 catch (const Derived &) 没接住"。
五、noexcept:承诺"不抛"
前面所有内容都在讲"怎么抛、怎么接",noexcept 是把话说反过来说:
void f() noexcept; // 我保证不抛(抛了就是我的错)
规则:
- 一旦异常从这个函数里逃出去(本函数没接住、传播到函数之外)→ 直接调用
std::terminate()(程序终止); noexcept是"契约":编译器可以据此做优化(比如省略栈展开的准备工作),调用方也可以放心地不做try;- 不要滥用 ——只要你不能 100% 保证不抛,就别写。
默认就是 noexcept 的场合(记住这几类):
| 场合 | 说明 |
|---|---|
| 析构函数 | 14.2.1 讲过:栈展开途中再抛,就有两个异常同时在飞 ——所以默认"不许抛" |
operator delete / operator delete[] |
内存释放失败没法处理 |
| 移动构造 / 移动赋值(在成员都不抛的前提下) | 标准库据此决定"搬还是拷"(vector 扩容时优先搬) |
查询一个表达式会不会抛:noexcept(...)(此时它是运算符,不是说明符)
cout << noexcept(f()); // 1(f 声明了 noexcept)或 0
虚函数的 noexcept 是契约的一部分:
- 基类声明了
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)
读结果:
- ② 是本节最有价值的输出:
7Derived说明引用捕获保住了真身(7是名字长度,还是 g++/MinGW 的修饰名)。如果把catch (const Base &b)改成catch (Base b),这里就会打印4Base——切片了。 - ⑤ 是"接不住"的两种:注意它们的表现形式不是"编译错误",而是"没人接 →
terminate" ——编译期一切正常,运行期直接死。这正是异常匹配规则"藏在暗处"的原因(也是编译器可能给你一条警告的原因)。 - ⑦ 里
noexcept(...)是运算符:它回答"这个表达式会不会抛"。noexcept(1+1)是1(显然不抛);空 lambda 也是1。如果你写noexcept(divide(1, 0)),那要看divide有没有声明noexcept——编译器只看声明,不看实际代码抛不抛。
30 秒实验
- 把 ② 的
catch (const Base &b)改成catch (Base b)→7Derived变4Base。这就是"按值捕获 = 切片"的铁证(14.2.2 的结构在匹配规则里有了正式解释)。 - 放开 ⑤ 的第一段(
throw 3;+catch (double d))→terminate。注意 GCC 在编译时可能就给你一条警告(大意是"这个 int 异常不会被这个处理器接住")——"警告要当错误看"在异常这块尤其适用。 - 放开 ⑤ 的第二段 → 同样
terminate。 在它后面加一行catch (...)→ 又活了(被兜底网接住)。这直观说明"catch (...)是不可少的安全网"。 - 写一个
void g() noexcept { throw 1; }并在main里try { g(); } catch (int) {}→ 照样terminate——因为异常在"离开g的那一刻"就已经触发了noexcept的后果,根本没机会进到你的catch里。 这是noexcept最反直觉的一点。
常见误区
- "能隐式转换就能被
catch接住" ——不是。只允许四种(精确 / cv 限定 / 派生→基类 / 指针标准转换)。算术转换和用户定义转换都不算。 - "
throw 3;能被catch (double)接住" ——接不住(而且编译期不报错、运行期直接死)。抛什么类型就catch什么类型。 - "
throw "abc";能被catch (const string &)接住" ——接不住(const char *→string是用户定义转换)。要么catch (const char *),要么改成抛std::string(更好的做法:抛一个从std::exception派生的类,14.2.4)。 - "
catch (Base b)和catch (const Base &b)一样能接住派生类异常,所以随便" ——都能接住,但前者会切片(typeid退化成Base、派生类多出来的信息丢了)。catch一律写引用。 - "
catch (Derived &)能接住throw Base();" ——不能(反向的继承转换不允许)。基类异常不能被"更具体的类型"接住 ——这也是为什么catch要"从具体到笼统"排列:更具体的catch只能接"确实更具体"的异常。 - "
throw一个Base &,异常类型会跟着真实对象走" ——它以throw那句话里表达式的静态类型为准(引用会被剥掉):Base &r = derived; throw r;抛出去的,是被切片成Base的异常对象,catch (const Derived &)接不住。 要保住类型,就直接throw具体类型的对象本身(别用基类引用/指针中转)。 - "
noexcept只是给编译器优化的提示" ——它首先是一条"契约":一旦异常逃出去就terminate(连外层catch都拦不住)。所以"拿不准就别写" ——乱写noexcept等于给自己埋一颗"异常时直接死"的雷。 - "
noexcept是函数自己的事,跟继承无关" ——关系很大:基类虚函数写了noexcept,派生类重写时不能去掉(不能从"不抛"变成"可能抛")。承诺只能收紧,不能放松。
自测三问(点开对答案)
1. **异常匹配允许哪四种转换?** ——**① 精确匹配(含 cv 限定差异);② 派生类 → 基类(对象/引用/指针都算);③ 数组、函数 → 指针;④ 指针的标准转换(`Derived *` → `Base *`、任意指针 → `void *`)。** **不允许:算术转换、用户定义转换。** 2. **`throw 3;` 为什么接不进 `catch (double)`?这种错误什么时候被发现?** ——因为**`int → double` 是算术转换,不在允许清单里**。**它不会在编译期报错**(**运行时没人接 → `terminate`**)。**编译器可能给一条警告,所以要"把警告当错误看"。** 3. **`noexcept` 的两层含义是什么?** ——**① 契约**:**承诺本函数不抛**;**异常一旦逃出这个函数就调用 `std::terminate()`**(外层 `catch` 也救不了);**② 优化许可**:**编译器/标准库据此决定"要不要准备栈展开"、"移动还是拷贝"**。**写它的前提是"真的确定不抛"**;**析构函数默认就是 `noexcept`。**- ☐ 下一节(14.2.4 标准异常)