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

第十四章讲的是"运行期才知道的事怎么办"。 两条主线: - RTTI(14.1):运行期"问类型" ——typeid 问、dynamic_cast 转、四种转换运算符各司其职; - 异常处理(14.2):运行期"按类型匹配" ——throw 抛、catch 接、std::exception 家族提供共同语言。 一句话串起来:RTTI 是"程序自己问、自己决定";异常处理是"程序自己报、交给别人决定"。 两者都建立在"运行期的类型信息"这个地基上(所以它们在同一章,而且 bad_cast / bad_typeid 这两个异常本身就是 RTTI 的产物)。 整章最重要的一条工程结论:RTTI 和异常都是"补丁",不是"主路" ——能靠虚函数表达的,别用 typeid 分支;能用返回值说清的,别抛异常。

📝 本节和前面几节的关系

收束整章。它是全课程里"从写法走向工程"的一章: - 第十一章的继承与多态在这里被"反着用"了一次(多态是首选,RTTI 是它管不到时的补充); - 第十章构造/析构在这里结了账(析构不能抛、构造抛异常要自己清理); - 第七章的指针与引用是整章的载体(void *、引用不能为空、不能返回局部变量地址,全都在这里再次出现)。 往前看:本章之后,"语法"层面基本齐了。 剩下的账:vector 中间插删要搬家(13.5 留的)、越界该抛异常而不是直接崩(13.5 留的)、以及"资源一定要交给对象管"这件事的正式名字(RAII) ——它是异常处理真正的落点,后面遇到再补。

一、本章知识地图

块 小节 一句话要点
RTTI 总览 14.1 运行期类型信息 = typeid(问)+ dynamic_cast(转)+ std::type_info(装);前提是"多态类型"(有虚函数) ——信息挂在 vtable 上
typeid 与 type_info 14.1.1 静态类型 vs 动态类型:多态类型上 typeid 给动态类型(否则给静态类型,还不报错);name() 是实现定义的,绝不能拿来判断;typeid(*p) 遇空指针抛 bad_typeid;type_info 不可拷贝(要当 key 用 type_index)
引用上的 RTTI 14.1.2 引用也能 typeid / dynamic_cast;dynamic_cast 到引用失败 → 抛 std::bad_cast(因为引用不能为空);指针版是"探测"(给 nullptr),引用版是"断言"(抛异常)
多重继承与地址 14.1.3 一个对象有多个合法地址(每个基类子对象在各自偏移处);(void*) 比较会骗你 → 用 dynamic_cast<void *>(给"最派生对象"的地址);交叉转换(cross-cast)只有 dynamic_cast 能做
四种转换运算符 14.1.4 static_cast(编译期、零开销、不检查)、dynamic_cast(运行期检查)、const_cast(只管 const,对真 const 对象动手是 UB)、reinterpret_cast(按比特重解释、最危险);C 风格强转 = 依次试这四把 → 必须全部换掉
异常处理总览 14.2 try / throw / catch;解决"返回值报错"的四个死穴;栈展开(异常传播时沿途局部对象被正确析构);不抛时几乎零开销、抛出并处理很贵 → 不当控制流用
抛出异常 14.2.1 抛出的是一份拷贝;惯例:抛类对象(别抛裸指针、别抛裸内置类型);throw; 原样重抛(不切片);雷区一:析构里别抛(默认 noexcept);雷区二:构造里抛 → 自己的析构不会被调用,要自己收拾
异常捕获 14.2.2 按"书写顺序"匹配,先到先得(不是最精确胜出);catch 一律写 const T &(按值会切片);catch (...) 只能放最后;捕获后从 catch 之后继续(不会回到 throw 处);别到处 catch,更别空着 catch
异常匹配 14.2.3 只允许四种转换:精确 / cv 限定 / 派生 → 基类 / 指针标准转换;不允许算术转换(throw 3 进不了 catch(double))、不允许用户定义转换(throw "abc" 进不了 catch(string));异常类型以 throw 那行的静态类型为准;noexcept 是承诺(逃出去就 terminate)
标准异常 14.2.4 std::exception 是根(只有 what());两大家:logic_error(我写错了)/ runtime_error(环境不配合);惯用写法 catch (const std::exception &e) { e.what(); };自定义异常从 runtime_error 派生 + 加字段;兜底后想拿具体信息 → dynamic_cast

二、必须记住的十个结论

  1. RTTI 的前提是"多态类型" ——类里至少有一个虚函数(信息挂在 vtable 上)。基类没有虚函数时,typeid 悄悄给你静态类型,不报错 ——这是最会骗人的一条。
  2. 判断类型用 typeid(a) == typeid(b),永远不要用 name() ——name() 的输出是"实现定义"的(g++/MinGW 给 7Printer 这种修饰名,MSVC 给 class Printer)。
  3. dynamic_cast 的两种失败方式:指针版给 nullptr(就地判断),引用版抛 bad_cast(必须 try/catch)。 用哪个看"转不过去算不算错"。
  4. 多重继承下"一个对象好几个地址":(void *)pa == (void *)pb 会说"不相等" → 判断"是不是同一个对象"要用 dynamic_cast<void *>;而"交叉转换"(Flyer * → Swimmer *)只有 dynamic_cast 能做。
  5. 四把转换扳手各管一件事,C 风格强转必须淘汰:static_cast(数值/继承,不检查)、dynamic_cast(安全继承,要检查)、const_cast(只动 const)、reinterpret_cast(按比特,最危险)。 能隐式转换就别写转换。
  6. 异常最值钱的不是"能报错",而是"栈展开" ——异常往上飞时,沿途每层的局部对象都会被正确析构(后构造先析构) ——这就是"把资源交给对象管"能成立的地基。
  7. 异常不当控制流用:不抛时几乎零开销(零成本异常模型),抛出并处理非常贵。 判据:"这件事多久发生一次 + 本层能不能处理"。 用户输错密码不是异常,内存不够才是。
  8. 两个雷区:① 析构函数里别抛异常(栈展开途中再抛 → 两个异常同时在飞 → terminate;C++11 起析构默认 noexcept);② 构造函数抛异常时,这个对象的析构函数不会被调用(所以要么把资源交给成员对象管,要么自己在 throw 前先释放)。
  9. catch 的三条铁律:一律 const T & 捕获(按值切片);顺序"从最具体到最笼统"(先到先得,基类写在前面会截胡);catch (...) 只能放最后。 外加一条分寸:捕不了就别捕,空 catch 是"错误黑洞"。
  10. 异常匹配比"能隐式转换"严得多:只允许 精确 / cv 限定 / 派生→基类 / 指针标准转换。throw 3 接不进 catch(double)(算术转换)、throw "abc" 接不进 catch(string)(用户定义转换) ——这类错编译期不报,运行期直接 terminate。

三、动手 Demo:两关自测

第一关(写代码):一个"形状仓库"

要求:只用本章(+前面课程)的东西,做出一个能跑的小系统。

功能 该用什么 对应小节
一个抽象基类 + 两个派生类 纯虚函数 11.5
创建失败要报错、而且不能泄漏 构造函数里 throw 自定义异常 14.2.1
统一的"报名字 / 算面积" 虚函数(首选) 11.4
把"矩形"单独挑出来做特殊处理 dynamic_cast 探测 14.1.1
分类统计各类有几个 typeid 比较(只看不改) 14.1.1
带错误码的自定义异常 从 runtime_error 派生 14.2.4
统一兜底 + 取回错误码 catch (const exception &) + dynamic_cast 14.2.4 / 14.1.4

参考实现(自己先写,写不出来再看):

#include <iostream>
#include <string>
#include <vector>
#include <stdexcept>
#include <typeinfo>
using namespace std;

// ── 异常家族:从 runtime_error 派生,带错误码 ──
class ShapeError : public runtime_error {
public:
    ShapeError(const string &msg, int code) : runtime_error(msg), code_(code) {}
    int code() const { return code_; }
private:
    int code_;
};

// ── 形状家族 ──────────────────────────────────
class Shape {
public:
    virtual ~Shape() {}
    virtual double area() const = 0;             // 抽象类(11.5)
    virtual string name() const = 0;
};

class Rect : public Shape {
public:
    Rect(double w, double h) : w_(w), h_(h) {
        if (w <= 0 || h <= 0) throw ShapeError("矩形的边长必须为正", 1001);
    }
    double area() const { return w_ * h_; }
    string name() const { return "矩形"; }
    void stretch(double f) { w_ *= f; }          // 矩形独有
private:
    double w_, h_;
};

class Circle : public Shape {
public:
    Circle(double r) : r_(r) {
        if (r <= 0) throw ShapeError("半径必须为正", 1002);
    }
    double area() const { return 3.14159265 * r_ * r_; }
    string name() const { return "圆"; }
private:
    double r_;
};

int main() {
    vector<Shape *> shapes;

    // ① 建造:成功的留下,失败的记一笔(异常安全的写法)
    const string kinds[5]   = {"rect", "circle", "rect", "circle", "rect"};
    double       params[5][2] = {{3, 4}, {2, 0}, {-1, 5}, {-2, 0}, {5, 5}};

    for (int i = 0; i < 5; i++) {
        try {
            if (kinds[i] == "rect") shapes.push_back(new Rect(params[i][0], params[i][1]));
            else                    shapes.push_back(new Circle(params[i][0]));
        } catch (const ShapeError &e) {
            cout << "第 " << i << " 个建不出来:" << e.what()
                 << "(错误码 " << e.code() << ")" << endl;
        }
    }

    // ② 统一报告(虚函数:多态的正路)
    cout << "-- 已有 " << shapes.size() << " 个形状 --" << endl;
    double total = 0;
    for (size_t i = 0; i < shapes.size(); i++) {
        double a = shapes[i]->area();
        cout << "  " << shapes[i]->name() << " 面积 " << a << endl;
        total += a;
    }
    cout << "总面积 " << total << endl;

    // ③ RTTI:把矩形挑出来单独"拉长"(探测能力)
    for (size_t i = 0; i < shapes.size(); i++) {
        if (Rect *r = dynamic_cast<Rect *>(shapes[i])) {
            r->stretch(2);
            cout << "第 " << i << " 个是矩形,拉长后面积 " << r->area() << endl;
        }
    }

    // ④ 分类统计(typeid 只读,不改任何东西)
    int nRect = 0, nCircle = 0;
    for (size_t i = 0; i < shapes.size(); i++) {
        if      (typeid(*shapes[i]) == typeid(Rect))   nRect++;
        else if (typeid(*shapes[i]) == typeid(Circle)) nCircle++;
    }
    cout << "统计:矩形 " << nRect << " 个,圆 " << nCircle << " 个" << endl;

    // ⑤ 统一兜底 + 用 dynamic_cast 取回具体信息
    try {
        throw ShapeError("测试用的错误", 9999);
    } catch (const exception &e) {
        cout << "兜底:" << e.what();
        if (const ShapeError *se = dynamic_cast<const ShapeError *>(&e))
            cout << "(码 " << se->code() << ")";
        cout << endl;
    }

    for (size_t i = 0; i < shapes.size(); i++) delete shapes[i];
    return 0;
}

看到什么算成功:

第 2 个建不出来:矩形的边长必须为正(错误码 1001)
第 3 个建不出来:半径必须为正(错误码 1002)
-- 已有 3 个形状 --
  矩形 面积 12
  圆 面积 12.5664
  矩形 面积 25
总面积 49.5664
第 0 个是矩形,拉长后面积 24
第 2 个是矩形,拉长后面积 50
统计:矩形 2 个,圆 1 个
兜底:测试用的错误(码 9999)

这一关把整章串成了一条线:构造失败用异常报(14.2.1)→ 统一行为用虚函数(11.4)→ 个别类型特殊处理用 dynamic_cast(14.1.1)→ 统计用 typeid(14.1.1)→ 兜底 + 取回信息用 catch(exception&) + dynamic_cast(14.2.4 / 14.1.4)。

加分实验:

  1. 把 ④ 的 typeid 判断改成一个"虚函数 isRect()" → 发现代码变干净了。这就是"能用虚函数就别用 RTTI"。
  2. 把 ① 里的 new 换成"先建局部对象再 push_back 副本" → 要重新考虑 vector<Shape> 的切片问题(11.4.2:多态必须走指针/引用)。
  3. 让 Rect 的构造函数"先 new 一块内存,再抛异常",并且故意不释放 → 看不出任何异常(安静的泄漏)。再加一个"用 vector<int> 成员管这块内存"的版本对照 ——去看 14.2.1 的第①条出路。
  4. 把 main 整个包一层 catch (const exception &e) { cerr << e.what(); } → 体会"最外层兜底"的写法(防止程序裸死)。

第二关(找错):十个判断,逐个说明对错与原因

# 代码 / 说法 对/错 为什么
① class Animal {};(无虚函数);Animal *p = new Dog; cout << typeid(*p).name(); 错 Animal 不是多态类型 → typeid 给的是静态类型 Animal(而且不报错)。给基类加个虚析构就好了
② Dog *d = dynamic_cast<Dog *>(a); d->fetch(); 错 没判空。失败时 d 是 nullptr,解引用就是未定义行为。要写 if (d != nullptr)
③ try { Dog &r = dynamic_cast<Dog &>(a); } catch (...) {} ——"失败会给个空引用" 错 没有"空引用"。必须靠 catch (const bad_cast &) 接住
④ if ((void *)pa == (void *)pb) …(pa/pb 是多继承下同一对象的两个基类指针) 错 多继承下它们的值不同 → 判断同一对象要用 dynamic_cast<void *>
⑤ Swimmer *s = dynamic_cast<Swimmer *>(flyerPtr); ——"不可能,两个类型没关系" 错 交叉转换是 dynamic_cast 的独门本事(前提:它们是同一个对象的两张脸)。static_cast 才真的做不到
⑥ int n = (int)3.99; 不推荐 C 风格强转看不出意图、类型关系一变就换了含义。写 static_cast<int>(3.99)
⑦ ~Bad() { throw runtime_error("析构出错"); } 错 栈展开途中再抛 = 两个异常同时在飞 = terminate。C++11 起析构默认 noexcept
⑧ 构造函数里 a = new int[100]; b = new int[100];(第二条抛了)——"析构函数会释放 a" 错 对象没构造完成 → 这个类的析构不会被调用 → a 泄漏。要么交给成员对象管,要么在 throw 前自己 delete
⑨ catch (AppError e)(接 NetError 异常) 错 按值捕获会切片(typeid(e) 退化成 AppError)。写 catch (const AppError &e)
⑩ catch (const AppError &e) { … } catch (const NetError &e) { … } 错 顺序反了:NetError 派生自 AppError,会被前面的截胡,后面那个是死代码。要"从具体到笼统"
⑪ try { throw 3; } catch (double d) { … } 错 算术转换不在允许清单里 → 接不住 → terminate。抛什么类型就 catch 什么类型
⑫ try { … } catch (...) { } 错 "错误黑洞":接住了、什么都不做、连痕迹都不留(比不写 try 更糟)。实在要忽略,也要写明理由
⑬ catch (const exception &e) 能接住 throw 42; 错 int 不是 exception 的派生类。只有 catch (...) 能兜住非标准异常

30 秒实验:把 ① ② ⑦ ⑧ ⑨ ⑪ 逐个在编译器里敲一遍,认一下每种报错(或崩溃、或 terminate)的样子。

四、常见误区(本章汇总)

  1. "有了 RTTI,就可以到处用 typeid 分支了" ——那是在用"运行期开关"替代"多态",加一个子类就要回来改调度代码(违背 9.2.3 的判据)。RTTI 的正当用途:调试打印、接口探测、无法修改第三方类时的分类处理。
  2. "异常是现代 C++ 的标配,什么都该抛" ——异常是给"罕见且本层处理不了"的事用的。 用户输错密码、查到末尾、查找失败都是正常分支(用返回值/状态位)。"什么都抛"和"什么都不抛"一样糟。
  3. "到处 try/catch 显得稳妥" ——捕不了就别捕;空 catch 是把 bug 藏起来。 正确的分层是:能在哪儿处理,才在哪儿捕。
  4. "noexcept 只是个优化提示,写了没坏处" ——它是"承诺":异常一旦逃出去就 terminate(外层 catch 都救不了)。拿不准就别写。
  5. "what() 的字符串可以拿来判断错误类型" ——它是给人看的、实现定义的。要分类就 catch 类型;要字段就自己写异常类加成员。
  6. "RTTI 和异常开销大,所以都别用" ——都在小得可以忽略的量级上(正常路径上异常几乎零开销;RTTI 只是一次查表)。它们真正的代价在"设计"上:用得不当会让代码越来越难改。 该用时不用,是另一种损失。
  7. "dynamic_cast 慢,能不用就不用" ——回一句:那你是打算用 static_cast 撞运气,还是用 reinterpret_cast 撞大运? 在"探测"场合,dynamic_cast 是唯一"错了有兜底"的方案。 性能焦虑要花在热点路径上,不是花在"初次写对"上。
  8. "教材学到异常了,说明异常很重要,我要在项目里大量用" ——恰恰相反:在真正的工程代码里,异常的正确用法是"少而准" ——在少数几个地方抛,在少数几个地方接,中间层完全不管。 "异常用得越少越清楚",这是老手的经验。

五、写完这一章,你会什么了

这一章之后,你的代码"敢出错"了:

同时拿到三张能一直用的"判断表":

判断 出处 一句话
"该用 RTTI 还是虚函数?" 14.1 能加虚函数就加 ——RTTI 只留着"问一句、顺手做点附加的事"
"该用哪种转换?" 14.1.4 数值与继承用 static_cast;安全的继承用 dynamic_cast;const 的事归 const_cast;看比特找 reinterpret_cast;永远别用 C 风格强转
"这个错误该抛异常吗?" 14.2 问两句话:这事多久发生一次?本层能不能处理? ——罕见 + 本层处理不了 → 抛;天天发生 或 本层能处理 → 不用异常
自测十问(点开对答案) 1. **RTTI 是什么的缩写?包含哪几个零件?前提是什么?** ——**Run-Time Type Information(运行期类型信息)**。零件:**`typeid`、`dynamic_cast`、`std::type_info`**(头文件 ``)。**前提是"多态类型"**(**类里至少有一个虚函数** ——**信息挂在 vtable 上**)。 2. **`typeid(*p)` 什么时候给动态类型?`name()` 能拿来判断类型吗?** ——**当 `*p` 的静态类型是多态类型时给动态类型**,否则给静态类型(**不报错**)。**`name()` 绝对不能用来判断**(**输出是实现定义的**:g++ 给 `7Printer`,MSVC 给 `class Printer`)——**要比较就用 `typeid(a) == typeid(b)`。** 3. **`dynamic_cast` 到指针和到引用,失败时分别怎样?该选哪个?** ——**指针版给 `nullptr`(就地判断);引用版抛 `std::bad_cast`(必须 `try/catch`)。** 判据:**"转不过去算不算错"** ——**不算错(探测可选能力)用指针版;算错(前置条件)用引用版。** 4. **多重继承下"判断两个基类指针是不是同一个对象"怎么写?为什么?** ——**`dynamic_cast(p) == dynamic_cast(q)`**。因为**每个基类子对象在对象内存的不同偏移处,指针值本来就不一样**,而 **`dynamic_cast` 返回"指向最派生对象的指针"**(**对象从头开始那个地址**)。**`(void *)` 比较会给出错误答案。** 5. **四种转换运算符各管什么?为什么必须淘汰 C 风格强转?** ——**`static_cast`(数值/继承/`void*`,编译期、不检查)、`dynamic_cast`(继承转换,运行期检查)、`const_cast`(只动 `const`/`volatile`)、`reinterpret_cast`(按比特重新解释)。** **C 风格强转是"依次试这四把、哪把能用用哪把"**:**看不出意图、类型关系一变就悄悄换含义(还编得过)、而且搜不到。** 6. **什么是"栈展开"?为什么说它是异常处理最值钱的部分?** ——**异常向上传播时,沿途每一层被路过的局部对象都会按"后构造先析构"的顺序被析构。** 它的价值:**只要把资源交给"析构函数负责释放"的对象,出错路径和正常路径都能自动释放** ——**这是 C++ 资源管理(RAII)的地基。** 7. **为什么析构函数里不能抛异常?构造函数里抛异常又该怎么办?** ——**析构**:**栈展开途中再抛 → 两个异常同时在飞 → `terminate`**(**C++11 起析构默认 `noexcept`**)→ **在析构里 `try/catch` 兜住,出错只记日志。** **构造**:**这个类的析构不会被调用**(**对象没构造完成**)→ **① 优先把资源交给成员对象(`vector`/`string`)管;② 否则在每个 `throw` 前先释放已经拿到的资源。** 8. **`catch` 的三个必守规矩是什么?** ——**① 一律 `const T &` 捕获**(**按值会切片**);**② 顺序"从最具体到最笼统"**(**先到先得,基类在前会截胡,后面的 `catch` 变成死代码**);**③ `catch (...)` 只能放最后**(**它什么都接,是兜底网**)。 **另外:捕不了就别捕,空 `catch` 是错误黑洞。** 9. **异常匹配允许哪些转换?举两个"接不住"的例子。** ——**允许:精确匹配、cv 限定转换、派生类 → 基类、指针标准转换。** **接不住:`throw 3;` → `catch (double)`(算术转换不允许);`throw "abc";` → `catch (const string &)`(用户定义转换不允许)。** **这类错误编译期不报,运行期直接 `terminate`。** 10. **标准异常家族的两大分支是什么?自定义异常该怎么写?为什么还要 `dynamic_cast`?** ——**`logic_error`(程序写错了)/ `runtime_error`(环境不配合)**。**自定义异常从 `runtime_error`(或 `logic_error`)派生,构造函数把消息交给基类、自己只加字段**(**这样 `what()` 已经有了,还能被 `catch (const exception &)` 兜住**)。**`dynamic_cast` 用在"统一兜底之后还想对个别类型特殊处理"** ——**`if (auto *se = dynamic_cast(&e)) …`。**

相关笔记