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

标准库早就准备好了一整套异常类,根都在 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() 打开)

三个最常打交道的选手:

  1. out_of_range ——你写 v.at(i) 时它会来(13.1.1 说过:这是"要检查就检查"的代价换来的安全);
  2. bad_alloc ——内存不够时 new 会抛(所以"new 之后不用判空"是常规写法;要判空就写 new (nothrow));
  3. invalid_argument ——字符串转数字失败时来(stoi / stod)。

三、what():唯一的"共同语言"

virtual const char *what() const 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(...) 接住

读结果:

30 秒实验

  1. 把 ③ 的 dynamic_cast<const ServerError *>(&e) 换成 static_cast → 编不过(exception 和 ServerError 是"父到子",static_cast 其实允许父→子……这里能编过,但那就没有检查了)。正确做法就是 dynamic_cast + 判空。
  2. 把 ④ 的 stoi("abc") 换成 stoi("99999999999999999999") → 抛的不是 invalid_argument 而是 out_of_range(数字太长)。同一个函数,两种异常 ——catch 的顺序在这里也要想清楚。
  3. 在 ① 里把 v.at(10) 改成 v[10] → 不抛任何异常,可能打印垃圾值、也可能直接崩 ——"两种越界方式的差别"在这里看得最清(13.1.1 的表)。
  4. 写一句 catch (const logic_error &e) 放在 catch (const out_of_range &e) 前面,再跑 ① → out_of_range 会被 logic_error 截胡(因为 out_of_range 派生自 logic_error)。顺序错了,catch 就白写(编译器可能给警告)。

常见误区

  1. "异常都得自己定义" ——标准库已经有一整套,先看有没有合适的(out_of_range / invalid_argument / runtime_error 覆盖了绝大多数场景)。自己定义只在"要带额外字段"时才必要。
  2. "从 std::exception 派生最标准,所以都该从它派生" ——从 runtime_error / logic_error 派生更省事(what() 已经实现好了,你只管加字段)。从 exception 直接派生要自己存消息、自己重写 what(),容易踩"返回悬空指针"的坑。
  3. "what() 返回的字符串可以拿来判断错误类型" ——不可以:它的文字是"实现定义"的(同一份代码换个编译器/版本,文字就变了)。要区分就 catch 类型,要字段就自己加成员。
  4. "出错就会抛异常" ——很多地方根本不抛:v[i] 越界不抛、sqrt(-1) 返回 NaN、atoi 转不了返回 0、流默认用状态位。"用不用异常"是接口的设计决定,要看约定、不要靠猜。
  5. "logic_error 和 runtime_error 应该照着同一个思路处理" ——它们的性质不同:logic_error 表示"代码写错了",通常应该改代码、而不是在运行期兜住它;runtime_error 表示"环境不配合",是程序要正常应对的情形。
  6. "捕获到基类之后,就没法知道具体是哪一种了" ——可以知道:dynamic_cast 到具体类型(Demo ③)。这是"既想统一兜底、又想对个别类型特殊处理"时的标准姿势。
  7. "标准异常类只能装一句字符串,太单薄" ——可以自己加字段(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(&e)) …`。**这就是 RTTI 最正当的用途之一(14.1.1 说过:问一句、顺手做点附加的事)** ——**"统一兜底 + 对个别类型特殊处理"的标准写法。**

相关笔记