第十四章讲的是"运行期才知道的事怎么办"。 两条主线:
- 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 |
二、必须记住的十个结论
- RTTI 的前提是"多态类型" ——类里至少有一个虚函数(信息挂在 vtable 上)。基类没有虚函数时,
typeid悄悄给你静态类型,不报错 ——这是最会骗人的一条。 - 判断类型用
typeid(a) == typeid(b),永远不要用name()——name()的输出是"实现定义"的(g++/MinGW 给7Printer这种修饰名,MSVC 给class Printer)。 dynamic_cast的两种失败方式:指针版给nullptr(就地判断),引用版抛bad_cast(必须try/catch)。 用哪个看"转不过去算不算错"。- 多重继承下"一个对象好几个地址":
(void *)pa == (void *)pb会说"不相等" → 判断"是不是同一个对象"要用dynamic_cast<void *>;而"交叉转换"(Flyer *→Swimmer *)只有dynamic_cast能做。 - 四把转换扳手各管一件事,C 风格强转必须淘汰:
static_cast(数值/继承,不检查)、dynamic_cast(安全继承,要检查)、const_cast(只动const)、reinterpret_cast(按比特,最危险)。 能隐式转换就别写转换。 - 异常最值钱的不是"能报错",而是"栈展开" ——异常往上飞时,沿途每层的局部对象都会被正确析构(后构造先析构) ——这就是"把资源交给对象管"能成立的地基。
- 异常不当控制流用:不抛时几乎零开销(零成本异常模型),抛出并处理非常贵。 判据:"这件事多久发生一次 + 本层能不能处理"。 用户输错密码不是异常,内存不够才是。
- 两个雷区:① 析构函数里别抛异常(栈展开途中再抛 → 两个异常同时在飞 →
terminate;C++11 起析构默认noexcept);② 构造函数抛异常时,这个对象的析构函数不会被调用(所以要么把资源交给成员对象管,要么自己在throw前先释放)。 catch的三条铁律:一律const T &捕获(按值切片);顺序"从最具体到最笼统"(先到先得,基类写在前面会截胡);catch (...)只能放最后。 外加一条分寸:捕不了就别捕,空catch是"错误黑洞"。- 异常匹配比"能隐式转换"严得多:只允许 精确 / 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)。
加分实验:
- 把 ④ 的
typeid判断改成一个"虚函数isRect()" → 发现代码变干净了。这就是"能用虚函数就别用 RTTI"。 - 把 ① 里的
new换成"先建局部对象再push_back副本" → 要重新考虑vector<Shape>的切片问题(11.4.2:多态必须走指针/引用)。 - 让
Rect的构造函数"先new一块内存,再抛异常",并且故意不释放 → 看不出任何异常(安静的泄漏)。再加一个"用vector<int>成员管这块内存"的版本对照 ——去看 14.2.1 的第①条出路。 - 把
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)的样子。
- 重点看 ① 和 ⑪:它们"不报错、安静地错" ——① 输出错的类型,⑪ 直接
terminate。 "跑一遍看结果"比"记住规则"更重要。 - 重点看 ⑧:它连提示都没有(泄漏是看不见的)。所以"资源交给对象管"不是风格问题,是正确性问题。
四、常见误区(本章汇总)
- "有了 RTTI,就可以到处用
typeid分支了" ——那是在用"运行期开关"替代"多态",加一个子类就要回来改调度代码(违背 9.2.3 的判据)。RTTI 的正当用途:调试打印、接口探测、无法修改第三方类时的分类处理。 - "异常是现代 C++ 的标配,什么都该抛" ——异常是给"罕见且本层处理不了"的事用的。 用户输错密码、查到末尾、查找失败都是正常分支(用返回值/状态位)。"什么都抛"和"什么都不抛"一样糟。
- "到处
try/catch显得稳妥" ——捕不了就别捕;空catch是把 bug 藏起来。 正确的分层是:能在哪儿处理,才在哪儿捕。 - "
noexcept只是个优化提示,写了没坏处" ——它是"承诺":异常一旦逃出去就terminate(外层catch都救不了)。拿不准就别写。 - "
what()的字符串可以拿来判断错误类型" ——它是给人看的、实现定义的。要分类就catch类型;要字段就自己写异常类加成员。 - "RTTI 和异常开销大,所以都别用" ——都在小得可以忽略的量级上(正常路径上异常几乎零开销;RTTI 只是一次查表)。它们真正的代价在"设计"上:用得不当会让代码越来越难改。 该用时不用,是另一种损失。
- "
dynamic_cast慢,能不用就不用" ——回一句:那你是打算用static_cast撞运气,还是用reinterpret_cast撞大运? 在"探测"场合,dynamic_cast是唯一"错了有兜底"的方案。 性能焦虑要花在热点路径上,不是花在"初次写对"上。 - "教材学到异常了,说明异常很重要,我要在项目里大量用" ——恰恰相反:在真正的工程代码里,异常的正确用法是"少而准" ——在少数几个地方抛,在少数几个地方接,中间层完全不管。 "异常用得越少越清楚",这是老手的经验。
五、写完这一章,你会什么了
这一章之后,你的代码"敢出错"了:
- 不再写"每个调用点都检查返回值"的样板代码(异常会自己找到该处理它的地方);
- 能设计"带信息的错误类型"(从
runtime_error派生 + 自己的字段); - 知道"资源该交给谁管"(析构函数 + 栈展开);
- 能安全地在继承体系里"探明身份"(
dynamic_cast而不是硬转); - 知道四种转换各该用在哪(不再写
(Dog *)p); - 能一眼看出"这个
catch永远不会执行"(顺序问题); - 知道"哪些地方根本不抛异常"(
v[i]、atoi、流),不会想当然。
同时拿到三张能一直用的"判断表":
| 判断 | 出处 | 一句话 |
|---|---|---|
| "该用 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`**(头文件 `- ☑ 第十五章 文件操作 —— 已完结:小结(流 + 文件:五步走 / 打开方式 / 文本与二进制 / 随机访问)