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 (...) {
// 兜底:什么都能接(必须放最后)
}
// 捕获处理完后,程序从这里继续
几个语法要点:
- 一个
try至少跟一个catch(也可以跟一个都不少,但不能没有); catch的参数列表里通常只有"类型 + 变量名",变量名可以省略(catch (const SomeError &) { … }——不想用它的时候这么写);catch里没有"匹配顺序"的关键字(不像switch有case和break)——谁写在前面谁先试;一旦某个catch进去了,后面的catch连试都不试;catch块执行完就跳到整组try/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 那一行)
读结果:
- (1) 和 (2) 的输出内容一模一样,但类型不一样 ——
8AppError/8NetError就是"切片有没有发生"的铁证(8是名字长度,还是 g++/MinGW 的修饰名;MSVC 会给class AppError)。 注意what()为什么没变:因为what()返回的那个字符串是在构造NetError时就拼好的("网络:"前缀是NetError的构造函数加的),它存在对象里,切片切掉的是"多出来的成员",不是这个字符串。所以"切片"不一定马上看得出来 ——这正是它阴险的地方:这回看起来没事,一旦派生类里多了一个成员,就丢了。 - (3) 里第二个
catch一行都没打印 ——它彻底是死代码。 写catch时,脑子里要过一遍"这些类型之间有父子关系吗"。 - (4) 说明
catch (...)是真正的最后一道网 ——连int都能接住。 - (5) 是"异常不能续命"的现场 ——
try里throw之后那句没执行,catch结束后也没有回去。
30 秒实验
- 把 (1) 的
catch (AppError e)改成catch (const AppError &e)→8AppError变成8NetError。一句话之差,信息全回来了。 - 把 (3) 两个
catch上下调换(NetError在前)→ 输出变成"被 NetError 接走了" ——顺序一换,谁接就变了。 - 把 (4) 的
catch (...)删掉 →int那个异常没人接 → 直接terminate(程序在 (4) 处死掉,后面的 (5) 也不会执行)。 - 在 (2) 的
catch里加一行throw;→ 异常被原样继续抛出,如果没有外层catch就terminate。试给main外面再包一层(改成try { run(); } catch (const exception &e) {}那种结构)看它被谁接走。
常见误区
- "
catch会挑最精确的那个来接" ——不会。它按书写顺序试,第一个能接的就进去,后面的连看都不看。 所以顺序必须"从具体到笼统"。 - "
catch (AppError e)和catch (const AppError &e)差不多" ——差一个引用,差一个世界:按值会切片(派生信息丢失)、按引用不会。而且按值还要多拷一次异常对象(异常处理本身就贵,别再雪上加霜)。 - "
catch (...)最保险,放最前面最安全" ——放前面的话,后面所有catch全部失效(程序照样跑,你不会得到任何提示)。它只能放最后。 - "接住了就好,处理不处理无所谓" ——什么都不做的
catch是"错误黑洞"(catch (...) { }是最坏的一种):它把"能被发现的错误"变成"永远不会被发现的错误"。 最多在"最外层"用它留个日志。 - "异常被
catch之后就"解决"了,程序回到出事的地方继续" ——不会回到出事的地方(try里throw之后的语句永远不执行),只会从catch之后继续。 想要"重试",必须自己在catch里写循环(Demo 里的重试范式)。 - "每个可能出错的函数都该包一下
try/catch" ——不该。捕不了就别捕(14.2 的分寸):错误应该交给"知道怎么办"的那一层。 乱捕的后果是"错误停在错误的层",而且每一层都以为自己处理好了。 - "
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` 失效**(而且可能连警告都没有)。**它的缺点是"接住了却说不清接住的是什么"** ——**所以只适合收尸,不适合处理。**- ☐ 下一节(14.2.3 异常匹配)
相关笔记
- 抛出异常 —— 上一节:
throw的规矩与两个雷区 - 异常匹配 —— 下一节:匹配规则的精确版
- 利用虚函数实现动态绑定 —— 对象切片的原始出处
- 什么是RTTI ——
typeid与"动态类型" - 🔷 C++基础 · 索引 —— 全课路线图