标准库早就准备好了一整套异常类,根都在 std::exception ——它只提供一个虚函数 what()(返回一段人能看的说明文字)。
这张家族树分两大家:logic_error("程序自己写错了") 和 runtime_error("环境不配合");再用两个 catch 就能兜住几乎所有标准异常:catch (const std::exception &e) { cout << e.what(); }。
自己写异常类的规矩也只有一条:从 std::runtime_error(或 logic_error)派生,再往里加自己的字段(错误码、文件名……)——这样既能带信息,又能被 catch (const exception &) 兜住。
14.2.1 的 Demo 里已经"偷偷"用了一次 public runtime_error,本节给这个写法一个正式解释。
它同时把前面所有内容收成一条可执行的规范:
- 14.1.1 的 bad_typeid、14.1.2 的 bad_cast ——它们都是这个家族的成员(一个在 <typeinfo> 里);
- 13.1.1 的 at() 抛 out_of_range ——"为什么 [] 不抛、at() 抛" 在这里能看见它的"户口";
- 13.3.4 的 accumulate 初值写错 ——那种"安静的错误"标准库不抛异常(它认为那是你的逻辑问题),该自己抛的就得自己抛。
一、std::exception 家族树
std::exception ← 根:只有一个虚函数 what()
│
┌───────────────┼────────────────┬──────────────┬───────────────┐
│ │ │ │ │
logic_error runtime_error bad_alloc bad_cast bad_typeid
(我写错了) (环境不配合) (<new>) (<typeinfo>) (<typeinfo>)
│ │
┌────┼────┬─────┐ ├──────┬─────────┐
│ │ │ │ │ │ │
invalid domain length out_of range overflow underflow
argument error error range error error error
两大家的分界线(很好用的一条判据):
logic_error |
runtime_error |
|
|---|---|---|
| 意思 | "这是程序写错了" ——理论上能在写代码时避免 | "这是运行环境的问题" ——写代码时防不住 |
| 例子 | 数组下标越界(自己没判)、传了个非法参数、算出了一个不存在的量 | 文件打不开、内存不够、网络断了 |
该不该在代码里 catch |
一般不该(它意味着 bug,应该去改代码) | 该(它意味着环境变化,程序要能应对) |
这个区分不是"纯学术":它决定了你 catch 的优先级 ——runtime_error 是"要处理的事",logic_error 往往"宁可让它炸出来"(因为掩盖 logic_error 等于把 bug 藏起来)。
二、家族成员速查表
| 异常类 | 头文件 | 谁抛它 / 什么情况 |
|---|---|---|
std::exception |
<exception> |
根类,别人都从它派生 |
std::logic_error / std::runtime_error |
<stdexcept> |
两大分支的基类(也能直接用,构造函数接受字符串) |
std::invalid_argument |
<stdexcept> |
参数不合法:std::stoi("abc")、bitset 构造 |
std::domain_error |
<stdexcept> |
数学上不成立的输入(如对负数求某些函数) |
std::length_error |
<stdexcept> |
超过最大长度:vector::reserve 要得太大、字符串太长 |
std::out_of_range |
<stdexcept> |
越界:vector::at / string::at / map::at、stoi 数字太长 |
std::range_error |
<stdexcept> |
计算结果超出可表示范围 |
std::overflow_error / underflow_error |
<stdexcept> |
数值上溢 / 下溢 |
std::bad_alloc |
<new> |
new 分配内存失败(new (nothrow) 则不抛,返回 nullptr) |
std::bad_cast |
<typeinfo> |
dynamic_cast 到引用失败(14.1.2) |
std::bad_typeid |
<typeinfo> |
对空指针做 typeid(*p)(14.1.1) |
std::ios_base::failure |
<ios> |
流操作失败(默认不抛,除非你调了 exceptions() 打开) |
三个最常打交道的选手:
out_of_range——你写v.at(i)时它会来(13.1.1 说过:这是"要检查就检查"的代价换来的安全);bad_alloc——内存不够时new会抛(所以"new之后不用判空"是常规写法;要判空就写new (nothrow));invalid_argument——字符串转数字失败时来(stoi/stod)。
三、what():唯一的"共同语言"
virtual const char *what() const noexcept;
- 它是虚函数:派生类可以重写,给出更具体的说明;
- 返回值是
const char *(不是std::string)——因为异常抛出的时刻要尽量避免"再分配内存、再抛异常"(what()的设计原则是"必须能安全地说明问题"); - C++11 起它标了
noexcept:调它不会抛(否则"查错误原因时又出错"就没完没了)。
两个 catch 就能兜住标准异常:
catch (const std::exception &e) { // 标准库全家(包括 bad_alloc / bad_cast)
std::cerr << e.what() << std::endl;
}
catch (...) { // 非标准异常(int、const char*……)
std::cerr << "未知异常" << std::endl;
}
四、自己写异常类
推荐写法:从 runtime_error / logic_error 派生,然后加自己的字段。
#include <stdexcept>
#include <string>
class ServerError : public std::runtime_error {
public:
ServerError(const std::string &msg, int code)
: std::runtime_error(msg), // ★ 把说明文字交给基类去存
code_(code) {}
int code() const { return code_; } // 自己的字段
private:
int code_;
};
为什么"从 runtime_error 派生"而不是"从 exception 派生":
| 选择 | 你要做的事 |
|---|---|
从 runtime_error / logic_error 派生(推荐) |
构造函数里把消息交给基类,what() 已经实现了 ——你只管加字段 |
从 exception 直接派生 |
你得自己存消息、自己重写 what() ——多写代码,还容易踩下面的坑 |
从 exception 直接派生时的经典坑(知道就行):
class MyError : public std::exception {
public:
MyError(const std::string &m) : msg_(m) {}
const char *what() const noexcept override {
return msg_.c_str(); // ★ 必须返回"成员内部"的指针
}
private:
std::string msg_; // ★ 必须让消息"活到 catch 结束"
};
what()返回的指针,必须指向"异常对象活着期间一直有效"的内存。 所以它必须是成员(或者静态字符串),绝不能是指向局部变量的指针。 ——返回局部变量的地址,catch里e.what()就是悬空指针(7.6.3 那个"不能返回局部变量地址"的老坑,在这里重演)。
还有一条工程习惯:尽量不要"从零造异常类",先看标准库有没有合适的 ——能用 out_of_range / invalid_argument 说清楚的事,没必要自己命名一个类型(多一个类型,多一份要记的东西)。
五、catch 的分层策略
有了家族树,"分层兜底"才有意义:
try {
doWork();
} catch (const std::out_of_range &e) { // ① 最具体:我知道怎么处理越界
fixIndex();
} catch (const std::logic_error &e) { // ② 次一层:程序逻辑问题
log("程序逻辑问题:" + std::string(e.what()));
throw; // 交给上层(或直接终止)
} catch (const std::exception &e) { // ③ 兜住所有标准异常
log("标准异常:" + std::string(e.what()));
} catch (...) { // ④ 最后的网(非标准异常)
log("未知异常");
}
顺序依然是"从最具体到最笼统"(14.2.2)——只不过这里"具体/笼统"是按继承树的深浅来的。
一个细节:用基类 catch 住之后,怎么拿回派生类的信息? ——靠 dynamic_cast(14.1.4 的使用场景在这里出现了):
catch (const std::exception &e) {
cerr << e.what() << endl;
if (const ServerError *se = dynamic_cast<const ServerError *>(&e)) // ★ 探测
cerr << "(错误码 " << se->code() << ")" << endl;
}
这就是 14.1 说的"RTTI 最正当的用途" ——"接住一堆异常,其中某几种我想额外做点事"。
六、别搞错:有些地方"根本不抛"
学了异常之后最容易犯的错,是以为"出错就会抛"。 实际上有很多"沉默的错误处理方式":
| 场合 | 出错时 |
|---|---|
vector[i] / arr[i] |
不检查、不抛 ——直接越界(未定义行为)(13.1.1 / 7.1.2) |
C 风格数学函数(sqrt(-1)) |
不抛 ——返回 NaN、设置 errno |
new |
抛 bad_alloc;new (nothrow) 返回 nullptr |
流(cin / ifstream) |
默认不抛 ——用状态位(fail() / eof())表示(13.4.2) |
atoi |
不抛 ——转不了就返回 0("错误"和"结果就是 0"分不清) |
stoi / std::stod(C++11) |
抛 invalid_argument / out_of_range ——对比上一行,这就是"老接口 vs 新接口"的差别 |
这张表的意义:"该不该用异常"是接口设计者决定的,你要按接口的实际约定去处理错误 ——不能凭感觉觉得"出错就该抛"。
七、动手 Demo:标准异常 + 自定义异常 + 分层兜底
任务:把标准异常、自己写的异常、分层 catch、以及"从基类拿回派生信息"各来一遍。
#include <iostream>
#include <string>
#include <vector>
#include <stdexcept>
using namespace std;
// ── 自定义异常:从 runtime_error 派生,加一个错误码 ──
class ServerError : public runtime_error {
public:
ServerError(const string &msg, int code)
: runtime_error(msg), code_(code) {}
int code() const { return code_; }
private:
int code_;
};
int main() {
// ── ① 标准异常:vector::at 越界 ─────────
vector<int> v = {1, 2, 3};
try {
cout << "① " << v.at(10) << endl; // 越界
} catch (const out_of_range &e) {
cout << "① 越界了。what() = " << e.what() << endl;
}
// ── ② 分层捕获:具体 → 笼统 ─────────────
try {
throw ServerError("数据库连接失败", 5003);
} catch (const out_of_range &e) {
cout << "② 不会走这里(out_of_range)" << endl;
} catch (const logic_error &e) {
cout << "② 不会走这里(logic_error)" << endl;
} catch (const runtime_error &e) {
cout << "② 被 runtime_error 接住:" << e.what() << endl;
}
// ── ③ 被基类接住后,用 dynamic_cast 取回信息 ──
try {
throw ServerError("数据库连接失败", 5003);
} catch (const exception &e) { // 用最根上的基类接
cout << "③ 被 exception 接住:" << e.what() << endl;
if (const ServerError *se = dynamic_cast<const ServerError *>(&e))
cout << " 用 dynamic_cast 取回错误码:" << se->code() << endl;
}
// ── ④ 字符串转数字:stoi 会抛 ───────────
try {
cout << "④ " << stoi("abc") << endl;
} catch (const invalid_argument &e) {
cout << "④ stoi 抛了 invalid_argument,what() = " << e.what() << endl;
}
// ── ⑤ 非标准异常用 catch(...) 兜 ───────
try {
throw 42; // 抛了个 int
} catch (const exception &e) {
cout << "⑤ 这句不会执行(int 不是 exception)" << endl;
} catch (...) {
cout << "⑤ 非标准异常(int),被 catch(...) 接住" << endl;
}
return 0;
}
看到什么算成功:
① 越界了。what() = vector::_M_range_check: __n (which is 10) >= this->size() (which is 3)
② 被 runtime_error 接住:数据库连接失败
③ 被 exception 接住:数据库连接失败
用 dynamic_cast 取回错误码:5003
④ stoi 抛了 invalid_argument,what() = stoi
⑤ 非标准异常(int),被 catch(...) 接住
读结果:
- ① 和 ④ 的
what()文字是"实现定义"的(上面是 GCC/MinGW 的样子)——① 的信息相当详细(连"要 10、实际只有 3"都说了),④ 就只有一句stoi。 所以别指望what()的格式统一:它的定位是"给人看的说明",不是"给机器解析的字段"(要字段就自己写异常类加成员,像ServerError::code())。 - ② 三个
catch里只有第三个进了 ——ServerError派生自runtime_error,不派生自out_of_range/logic_error(runtime_error和logic_error是兄弟,不是父子)。 - ③ 是本节最实用的一幕:用
catch (const exception &)兜住一切标准异常,再用dynamic_cast把"我特别关心的那一种"捞出来。 RTTI 和异常在这一行里握了手(这也是本章把两者放一章的最好注脚)。 - ⑤ 说明两条通道是分开的:
exception &接不住int;catch (...)才是终极兜底。
30 秒实验
- 把 ③ 的
dynamic_cast<const ServerError *>(&e)换成static_cast→ 编不过(exception和ServerError是"父到子",static_cast其实允许父→子……这里能编过,但那就没有检查了)。正确做法就是dynamic_cast+ 判空。 - 把 ④ 的
stoi("abc")换成stoi("99999999999999999999")→ 抛的不是invalid_argument而是out_of_range(数字太长)。同一个函数,两种异常 ——catch的顺序在这里也要想清楚。 - 在 ① 里把
v.at(10)改成v[10]→ 不抛任何异常,可能打印垃圾值、也可能直接崩 ——"两种越界方式的差别"在这里看得最清(13.1.1 的表)。 - 写一句
catch (const logic_error &e)放在catch (const out_of_range &e)前面,再跑 ① →out_of_range会被logic_error截胡(因为out_of_range派生自logic_error)。顺序错了,catch就白写(编译器可能给警告)。
常见误区
- "异常都得自己定义" ——标准库已经有一整套,先看有没有合适的(
out_of_range/invalid_argument/runtime_error覆盖了绝大多数场景)。自己定义只在"要带额外字段"时才必要。 - "从
std::exception派生最标准,所以都该从它派生" ——从runtime_error/logic_error派生更省事(what()已经实现好了,你只管加字段)。从exception直接派生要自己存消息、自己重写what(),容易踩"返回悬空指针"的坑。 - "
what()返回的字符串可以拿来判断错误类型" ——不可以:它的文字是"实现定义"的(同一份代码换个编译器/版本,文字就变了)。要区分就catch类型,要字段就自己加成员。 - "出错就会抛异常" ——很多地方根本不抛:
v[i]越界不抛、sqrt(-1)返回NaN、atoi转不了返回 0、流默认用状态位。"用不用异常"是接口的设计决定,要看约定、不要靠猜。 - "
logic_error和runtime_error应该照着同一个思路处理" ——它们的性质不同:logic_error表示"代码写错了",通常应该改代码、而不是在运行期兜住它;runtime_error表示"环境不配合",是程序要正常应对的情形。 - "捕获到基类之后,就没法知道具体是哪一种了" ——可以知道:
dynamic_cast到具体类型(Demo ③)。这是"既想统一兜底、又想对个别类型特殊处理"时的标准姿势。 - "标准异常类只能装一句字符串,太单薄" ——可以自己加字段(Demo 的
ServerError就带了code)。但要克制:每加一个自定义异常类型,调用方就多一份要记住的约定 ——std::exception家族能表达的,就别另起炉灶。
自测三问(点开对答案)
1. **标准异常家族的两大分支是什么?判据是什么?** ——**`logic_error`("程序写错了",理论上写代码时能避免)** 和 **`runtime_error`("环境不配合",写代码防不住)**。**判据是"该不该在运行期兜住它"**:**`runtime_error` 要处理;`logic_error` 往往宁可让它炸出来(因为掩盖它等于藏 bug)。** 2. **自己写异常类,该从哪个类派生?为什么?** ——**从 `std::runtime_error` 或 `std::logic_error` 派生**:**`what()` 它们已经实现好了(你把消息交给基类构造函数),你只需要加自己的字段**(错误码……)。**从 `std::exception` 直接派生要自己存消息、自己重写 `what()`** ——**而且必须保证 `what()` 返回的指针指向"成员"(活到 `catch` 结束),返回局部变量的指针就是悬空指针。** 3. **"用 `catch (const exception &e)` 兜住之后,我怎么知道具体是哪个异常?"** ——**用 `dynamic_cast` 探测**:`if (auto *se = dynamic_cast- ☐ 下一节(14.3 小结)