throw 表达式; 就是"报警" ——它把表达式的值拷贝成一个"异常对象",然后让控制流立刻离开当前函数,去找愿意接它的人。
本节要记住四件事:
1. 抛出的是"一份拷贝"(不是原来那个对象,所以抛局部变量也是安全的);
2. 惯例抛"类对象",不抛裸指针、不抛裸内置类型(裸指针没人负责释放,裸内置类型带不了信息);
3. throw;(不带表达式)在 catch 里 = 原样重新抛出(不切片);
4. 两个雷区:析构函数里别抛(默认 noexcept,抛了直接终止)、构造函数里抛要自己收拾(这个对象的析构函数不会被调用)。
14.2 讲了"异常是第二套返回通道 + 栈展开",本节讲这条通道的入口:throw。
它同时把第十章两笔旧账结清:
- 10.3(析构):"析构函数里不要抛异常" 这条规矩,现在能说清原因了 ——栈展开途中再抛一个异常,就有两个异常同时在飞,标准只能调 terminate;
- 10.2(构造):"构造函数中途失败怎么办?" ——这个对象根本没构造完,析构函数不会被调用,所以它必须自己把已经拿到的东西还回去。
14.2.4 会给本节 Demo 里那个"从 runtime_error 派生"的写法一个正式解释(std::exception 家族的规矩)。
一、throw 抛出去的是什么:一份"拷贝"
throw 表达式; // 最简单的形式
执行 throw 时,编译器做这几件事:
- 求值那个表达式;
- 把结果"拷贝"到一个由运行时管理的特殊位置(这个拷贝叫"异常对象")——它不放在你的栈上,所以抛出后原来的局部对象就算销毁了,异常对象也还活着;
- 开始"找接收站":沿调用栈往外走,每离开一层就做一次栈展开(14.2 讲的);
- 找到匹配的
catch就进去执行;一路找到头都没人接,就调std::terminate()(程序终止)。
"拷贝"这件事有个很实用的结论:
void f() {
MyError e("出错了"); // 局部对象
throw e; // 拷贝一份带出去
} // ← f 结束时 e 被析构,但异常对象还活着,catch 里照样能用
所以:抛局部对象是安全、且是正常用法。 (这也意味着"异常对象的拷贝构造/移动构造必须是可用的" ——标准库的异常类都满足这一点。)
二、抛什么:惯例与三条禁忌
| 抛什么 | 评价 |
|---|---|
类对象(尤其是从 std::exception 派生的) |
✅ 推荐的惯例 ——能带信息(文件名、错误码)、能分层捕获、能调 what() |
裸内置类型(throw 404; throw "错误";) |
⚠️ 能编过能接住,但只能靠值区分 ——带不了信息、容易撞车(throw 404 和真正的数字 404 分不清) |
裸指针(throw new MyError();) |
❌ 别抛 ——因为"谁负责 delete"立刻变成一个悬案(catch 到的地方必须记得删;没人接就泄漏) |
| 局部对象的指针/引用 | ❌ 别抛 ——异常飞出去时局部对象已析构,指针立刻悬空 |
标准库已经有的类型(out_of_range / invalid_argument / runtime_error …) |
✅ 优先用现成的(14.2.4) |
一句话:"抛类对象,优先抛那些从 std::exception 家族里来的。"
三、throw 之后会发生什么
三条极简的行为规则:
- 本层剩下的语句一条都不执行(就像
return,但方向是往外飞); - 本层的局部对象会被析构(栈展开:先进后出,10.3 的顺序);
catch块执行完后,程序从catch之后继续 ——不会回到throw那一行(14.2 的误区里强调过:异常不能"续命")。
void h() {
Guard g("h 的 Guard");
cout << "抛之前" << endl;
throw runtime_error("炸了");
cout << "抛之后 —— 这行永远不会执行" << endl;
}
四、throw;:在 catch 里"原样转交"
catch 块里可以写一个"没有表达式"的 throw,意思是:把当前这个异常原样再抛出去,让外层去处理。
try {
openFile(name);
} catch (const FileError &e) {
log("这次打开失败:" + string(e.what())); // 先记一笔(本层能做的事)
throw; // ★ 原样往上送
}
throw; 和 throw e; 的区别(很重要):
| 写法 | 抛出去的东西 |
|---|---|
throw; |
原来那个异常对象(同一个,类型不变) ——推荐 |
throw e; |
把 e 再拷贝一份 ——如果 e 是按值捕获的(已经切片过),拷贝的也是被切过的版本 ——信息丢了 |
所以规矩是:要重新抛出,一律写 throw;,绝不写 throw e;。(而 catch 参数也应该用引用捕获,原因在 14.2.2。)
五、雷区一:析构函数里不要抛异常
原因在前一节说了,这里把它钉死:
栈展开的过程本身就是"正在处理一个异常"。如果在这个过程中,某个析构函数又抛出一个异常 ——两个异常同时在飞 ——标准规定这时调用
std::terminate(),程序直接终止。
C++11 之后的规矩更硬:
- 析构函数默认是
noexcept的("我保证不抛"); - 一旦异常从析构函数里"逃出去",不等它传播,程序立刻
terminate。
class Bad {
public:
~Bad() { throw runtime_error("析构里抛"); } // ☠️ 一旦真的抛出 → terminate
};
正确做法:析构函数自己做"最后一道防线" ——把可能抛的操作包起来,出错只记日志、不往外抛。
~Connection() {
try { socket.close(); } // close 可能抛
catch (const exception &e) { // 吞掉(最多记一笔)
cerr << "关闭连接失败:" << e.what() << endl;
}
}
六、雷区二:构造函数里抛异常 —— 析构函数不会被调用!
这是本节最容易被忽略、也最容易造成泄漏的一条。
class Holder {
public:
Holder() {
a = new int[100]; // ① 拿到资源
b = new int[100]; // ② 假设这一句抛了 bad_alloc
} // 那么 ~Holder() 永远不会被调用 → a 泄漏!
~Holder() { delete[] a; delete[] b; }
};
为什么不会调用析构:因为"这个对象从来没构造完成" ——析构的前提是"对象曾经完整地出生过"。
标准的确切规则(值得记准):
| 谁 | 在构造函数抛异常时会不会被析构 |
|---|---|
这个类自己的析构函数 ~Holder() |
❌ 不会(对象没出生) |
已经构造完成的成员对象(比如成员里放了个 vector) |
✅ 会(它们已经是完整对象) |
| 已经构造完成的基类子对象 | ✅ 会(11.1.3 的构造顺序:先父后子) |
所以有三条出路,从好到坏:
| 办法 | 说明 |
|---|---|
| ① 让成员对象去管资源(首选) | 成员写成 vector<int> / string 而不是裸 int * ——它们自己会在栈展开时把资源还回去(这就是"栈展开"最大的用处) |
| ② 用"函数 try 块" | Holder() try { … } catch (...) { /* 收拾 */ throw; } ——能写,但教材不展开,认得语法即可 |
| ③ 在构造函数里手写清理(Demo 用这个,为了看清机制) | 每个可能抛的地方之前,先 delete 掉已经拿到的资源,再抛 ——容易忘、容易漏,只在学习时用 |
一句话总结这一节的两个雷区:
析构函数是"清扫工",不能边清扫边报警。 构造函数是"还没建好的房子",塌了不算"拆除",所以它得自己把料搬走。
七、没人接的下场:terminate
异常一路飞出去、飞出 main 都没有 catch,标准规定调用 std::terminate() ——默认行为是打印一条信息并终止程序(GCC/MinGW 会打印 terminate called after throwing an instance of 'std::runtime_error' 之类的字样,具体文字由实现决定)。
这不是"崩溃",是"标准规定的收场方式" ——栈展开仍然会做完(局部对象照常析构),然后程序死掉。
八、动手 Demo:抛自定义异常 + 看构造函数失败
任务:把本节的四件事各看一遍 ——带信息的自定义异常、throw 之后的代码不执行、throw; 重抛、构造失败的清理责任。
#include <iostream>
#include <string>
#include <stdexcept>
using namespace std;
// ── 自定义异常:从标准库的 runtime_error 派生(14.2.4 的规矩)──
class FileError : public runtime_error {
public:
FileError(const string &file, const string &why)
: runtime_error("文件【" + file + "】" + why), file_(file) {}
const string &file() const { return file_; } // 额外信息:文件名
private:
string file_;
};
void openFile(const string &name) {
cout << " 试着打开 " << name << endl;
if (name.empty()) throw FileError(name, "名字是空的");
if (name != "data.txt") throw FileError(name, "不存在");
cout << " 打开成功" << endl;
}
// ── 一个"构造中途会失败"的类 ──────────────
class Resource {
public:
Resource(bool ok) {
cout << " Resource 构造开始" << endl;
buf_ = new int[10]; // 先拿到资源
if (!ok) {
delete[] buf_; // ★ 自己收拾:析构不会被调用
throw runtime_error("Resource 构造失败");
}
cout << " Resource 构造完成" << endl;
}
~Resource() { delete[] buf_; cout << " Resource 析构" << endl; }
private:
int *buf_;
};
int main() {
// ① 顺利的一路
try {
openFile("data.txt");
cout << "① 一切正常" << endl;
} catch (const FileError &e) { cout << "① " << e.what() << endl; }
// ② 出错:throw 之后的语句不会执行
try {
openFile("missing.txt");
cout << "② 这句永远不会执行" << endl;
} catch (const FileError &e) {
cout << "② 抓到:" << e.what() << "(文件名 = " << e.file() << ")" << endl;
}
// ③ 重抛:内层记一笔,原样交给外层
try {
try {
openFile("");
} catch (const FileError &e) {
cout << "③ 内层看一眼:" << e.what() << endl;
throw; // ★ 原样重抛(不切片)
}
} catch (const exception &e) { // 外层用基类接
cout << "③ 外层接住(基类视角):" << e.what() << endl;
}
// ④ 构造失败:看析构有没有被调用
try {
Resource bad(false);
} catch (const exception &e) {
cout << "④ 接住:" << e.what() << "(注意:上面没有出现 Resource 析构)" << endl;
}
// ⑤ 构造成功:对照看析构出现
try {
Resource good(true);
cout << "⑤ 这一行跑完,good 就该析构了" << endl;
} catch (...) { }
return 0;
}
看到什么算成功:
试着打开 data.txt
打开成功
① 一切正常
试着打开 missing.txt
② 抓到:文件【missing.txt】不存在(文件名 = missing.txt)
试着打开
③ 内层看一眼:文件【】名字是空的
③ 外层接住(基类视角):文件【】名字是空的
Resource 构造开始
④ 接住:Resource 构造失败(注意:上面没有出现 Resource 析构)
Resource 构造开始
Resource 构造完成
⑤ 这一行跑完,good 就该析构了
Resource 析构
读结果:
- ② 那句
cout没打印 ——throw一响,本层剩余代码立刻作废。 - ③ 里同一个异常被打印了两次(内层一次、外层一次)——
throw;交出的是同一个对象(外层用exception &接也能拿到完整信息)。顺便注意 ③ 的输出里,外层接住的内容和类型都没变 ——这就是"不切片"。 - ④ 和 ⑤ 是本节最值得留意的对照:
false那次,我们看到了"构造开始",但没看到"析构" ——因为对象没出生;true那次,构造完就在离开作用域时正常析构了。 - ④ 里那句
delete[] buf_;是"必须自己写"的 ——如果把这一句删掉再跑,程序不会报错、也不会有任何提示,只是那 40 个字节永远回不来了。 这种"安静的泄漏"比崩溃更难查。
30 秒实验
- 把 ④ 里那句
delete[] buf_;注释掉 → 程序输出一模一样(泄漏是"看不见"的)。这就是为什么"资源要交给成员对象管"(14.2 的第①条出路)。 - 把 ③ 内层的
throw;改成throw e;→ 结果看起来一样(因为这里是按引用捕获的)。把 ③ 的catch (const FileError &e)改成按值catch (FileError e),再对比throw;和throw e;→ 这就要到 14.2.2 才看得清了(切片)。 - 删掉所有
catch,只留openFile("missing.txt");→ 程序打印terminate called after throwing an instance of 'FileError'(具体文字由编译器决定)后终止。这就是"异常不能被忽略"。 - 写一个
~Bad() { throw 1; }的类,在try里构造再抛出看看 → 大概率以terminate收场 ——亲手确认"析构不能抛异常"这条规矩不是吓唬人。
常见误区
- "
throw抛出的是那个对象本身" ——是它的拷贝(拷贝到一个由运行时管理的异常对象里)。这带来两个后果:① 抛局部对象是安全的(拷贝已经飞出去了);② 异常类型必须能拷贝/移动。 - "
throw;和throw e;差不多" ——差很多:throw;重抛原对象(类型完整);throw e;会再拷一份e——如果e是按值捕获的(已经切片),拷出去的也是被切过的那个,外层接到的信息就少了。 - "抛
std::string或const char *也挺好,反正能带信息" ——能带,但不能分层捕获、不能挂额外字段。惯例是抛"从std::exception派生的类对象"(这样catch (const exception &e)能兜底,14.2.4)。 - "抛
new出来的指针,反正catch里能接住" ——"接住"和"谁来删"是两件事。抛裸指针 = 把内存管理的责任扔给一个可能不存在的人。 实在要抛指针,就抛智能指针(教材后续会讲)。 - "析构函数里抛异常是"更严格的检查"" ——是把程序送去
terminate。析构函数是"清理现场"的角色,它出事的唯一后果是"清理失败 + 程序终止"。 这一条比想象中严肃:C++11 起它默认就是noexcept。 - "构造函数抛异常时,对象会走一遍析构把内存还回去" ——不会。析构函数只在"对象完整构造过"的前提下才会被调用。 所以构造函数必须自己清理(或者干脆把资源交给成员对象管,让栈展开替你清理)。
- "抛异常会跳回
catch里继续执行,相当于一种goto" ——能理解成"往外跳",但落地方式完全不同:它会把沿途每一层的作用域清理干净(栈展开),而goto什么都不管(5.3.4 说过goto不能跨函数、还会绕过初始化)。异常是"带着清理的外跳"。
自测三问(点开对答案)
1. **`throw e;` 和 `throw;` 的区别是什么?该用哪个?** ——**`throw e;` 会把 `e` 再拷贝一份抛出去;`throw;` 重抛"当前正在处理的这个异常对象"(类型不变)**。**要转交给外层,一律写 `throw;`** ——**否则如果 `e` 是按值捕获的(已切片),信息就丢了一层。** 2. **为什么析构函数里不能抛异常?** ——因为**栈展开过程本身就是"正在处理异常"**。**析构函数在此时再抛,就有两个异常同时在飞**,标准规定只能调用 **`std::terminate()`**(程序终止)。**C++11 起析构函数默认是 `noexcept` 的**,**异常逃出去就直接终止。** 正确做法:**在析构里 `try/catch` 兜住,出错只记日志。** 3. **构造函数里抛异常时,谁的析构会被调用、谁不会?** ——**这个类自己的析构函数不会被调用**(对象从未构造完成);**但已经构造完成的成员对象和基类子对象会**(它们是完整对象,栈展开会负责)。**所以:① 优先把资源交给成员对象(如 `vector` / `string`)管;② 非要自己管,就必须在每个 `throw` 之前先释放已经拿到的资源。**- ☐ 下一节(14.2.2 异常捕获)