多重继承会让"一个对象"同时拥有好几个合法的地址 ——因为它的每个基类子对象都在这块内存里的不同偏移处。
于是发生三件事:
1. 用 == 比较两个基类指针会说"不相等",其实它们是同一个对象(要用 dynamic_cast<void *> 或转成共同基类);
2. static_cast 在基类指针之间转换时会悄悄调整地址(它不是"值不变");
3. dynamic_cast 拿到一项独门绝技 ——交叉转换(cross-cast):沿着"共同的后代"从一个基类指针转到另一个无关的基类指针,这是编译期做不到的事。
结论:多重继承本身就要慎用(11.3),一旦用了,指针比较就得改用 dynamic_cast<void *>。
11.3 讲过多重继承的两个独有麻烦:二义性与菱形(那是"取名字"和"取数据"的麻烦)。本节讲的是第三个麻烦:地址。
14.1.1 / 14.1.2 讲的 typeid 和 dynamic_cast 在多重继承下照样好用(typeid 完全不受影响);本节要单独拿出来讲的,是"指针值"这件事上出的岔子 ——typeid 是拿类型信息比较,不碰地址,所以它没事。
往回想:7.4.1 说"指针是存地址的变量"、7.4.4 的 void * 是"万能口袋"(能装任何地址、不能解引用) ——本节最后的解药就是 dynamic_cast<void *>。
顺带一个字面提醒:dynamic_cast<void *> 看着别扭("转到 void 是什么意思?"),它是标准专门为"求最派生对象的地址"设计的特殊形式*。
一、先看"一个对象好几个地址"是怎么来的
class Flyer { public: virtual ~Flyer() {} };
class Swimmer { public: virtual ~Swimmer() {} };
class Duck : public Flyer, public Swimmer { }; // 鸭子又会飞、又会游
一个 Duck 对象在内存里长这样(按"先父后子"的顺序,11.1.1):
&d(Duck 对象的起始地址)
│
▼
┌───────────────────────────┐
│ Flyer 子对象 │ ← (Flyer *)&d 指向这里(偏移 0)
│ ├ vptr → Flyer 的段 │
├───────────────────────────┤
│ Swimmer 子对象 │ ← (Swimmer *)&d 指向这里(偏移若干字节)
│ ├ vptr → Swimmer 的段 │
├───────────────────────────┤
│ Duck 自己的成员(如果有) │
└───────────────────────────┘
所以这三个指针里装的值并不都相同:
| 表达式 | 指向 |
|---|---|
&d |
整个 Duck 对象的起始地址 |
(Flyer *)&d |
Flyer 子对象的地址(偏移 0,通常和 &d 相同) |
(Swimmer *)&d |
Swimmer 子对象的地址(有偏移,和上面不同) |
它们都是"合法的、正确的、指向同一个对象的指针",但装的值不一样。
二、后果一:用 == 比较基类指针会骗你
Duck d;
Flyer *pf = &d;
Swimmer *ps = &d;
(pf == ps) // ⚠️ 直接比较:编译不过(两个指针类型无关,不允许比)
((void *)pf == (void *)ps) // 0 —— 同一个对象,却说不相等!
这就是多重继承给"指针比较"设的坑:把两个指针转成 void * 再比,本来是"判断是不是同一个对象"的常见手法,在多继承下会给出错误答案。
正确的三种办法:
| 办法 | 写法 | 说明 |
|---|---|---|
| ① 比"最派生对象"的地址(推荐) | dynamic_cast<void *>(pf) == dynamic_cast<void *>(ps) |
标准专门为此提供的:dynamic_cast<void *> 返回"指向最派生对象的指针" |
| ② 转到共同基类再比 | dynamic_cast<Common *>(pf) == dynamic_cast<Common *>(ps) |
前提是它们有一个共同基类 |
| ③ 别比地址 | 在基类里加一个 whoAmI() / 用 typeid 比类型 |
"是不是同一个对象"用别的办法回答(很多时候你真正想问的其实是"是不是同一类") |
dynamic_cast<void *> 的规则(记住这三点):
- 目标类型写
void *(这是它唯一的特殊用法); - 返回指向"最派生对象"的指针 ——也就是对象"从头开始"的那个地址;
- 只对多态类型有效(老规矩:要有虚函数)。
void *p1 = dynamic_cast<void *>(pf); // 指向整个 Duck 对象
void *p2 = dynamic_cast<void *>(ps); // 也指向整个 Duck 对象
cout << (p1 == p2); // 1 —— 这次对了
三、后果二:static_cast 会"调整地址"
这件事反直觉,但必须知道:
Duck *pd = &d;
void *v = pd; // 地址 = d 的起始
Swimmer *ps = static_cast<Swimmer *>(pd);
void *w = ps; // ⚠️ 地址 = d 的起始 + 偏移
两个 void * 不一样。 因为 static_cast 不只是"改个标签",它还会按继承布局算出正确的偏移、把地址调整过去。
这是"必要之恶":不调整的话,ps 指向的就不是 Swimmer 子对象,调 ps->swim() 就会拿到错误的 vptr、跳到错误的函数。 所以编译器必须调整。
推论:
- "
static_cast是零开销"这句话要打个折:它就是"编译期算好偏移、做一次加减" ——依然比dynamic_cast便宜得多(没有运行期查找),但确实动了值。 - 拿地址当"身份证"的代码(比如哈希表里用指针做 key、或者判断"是否同一对象")在多继承下必须走
dynamic_cast<void *>。
四、后果三:dynamic_cast 的独门绝技 ——交叉转换
回到上面的鸭子:
Flyer *pf = &d; // 现在我只剩一个 Flyer*,但我知道它其实是鸭子
Swimmer *ps = static_cast<Swimmer *>(pf); // ❌ 编译不过!Flyer 和 Swimmer 没关系
Swimmer *ps = dynamic_cast<Swimmer *>(pf); // ✅ 成功!而且 ps 指向正确的 Swimmer 子对象
这就是交叉转换(cross-cast):从一个基类指针,直接转到"另一个不相关的基类"指针 ——条件是"它们其实是同一个对象的两张脸"。
| 转换方向 | 例子 | 谁做得到 |
|---|---|---|
| 向上(子 → 父) | Duck * → Flyer * |
static_cast 就行(隐式转换也可) |
| 向下(父 → 子) | Flyer * → Duck * |
static_cast 能编过但不检查;dynamic_cast 会检查 |
| 横向 / 交叉(父 → 兄弟父) | Flyer * → Swimmer * |
只有 dynamic_cast 能做 |
交叉转换是"编译期信息不够"的典型例子:编译器只知道 pf 的静态类型是 Flyer *,它凭这个信息想不出 Swimmer 子对象在哪儿(可能它根本不是鸭子,是个纯 Flyer)。只有运行期问一句"你到底是谁",才能算出来。
所以:交叉转换是 RTTI 最"不可替代"的用途之一。(另一个是 14.1.4 会讲的 dynamic_cast 向下转型。)
五、菱形继承下会怎样
顺便把 11.3.2 的菱形接过来:
class A { public: virtual ~A() {} };
class B1 : public virtual A {}; // 虚继承
class B2 : public virtual A {};
class D : public B1, public B2 {};
| 情况 | A *pa = &d; |
dynamic_cast<A *>(pb1) 与 dynamic_cast<A *>(pb2) |
|---|---|---|
| 非虚继承(普通菱形) | 编译错误:ambiguous(d 里有两份 A 子对象,编译器不替你猜) |
各自给"自己那条路上的那一份 A",两个地址不同 |
虚继承(11.4.3) |
合法(A 子对象全对象只有一份) |
两个得到同一个地址(都指向那唯一的一份) |
一句话:虚继承就是"用运行期的绕路,换回'祖先只有一份'" ——它让地址重新变得可预测,代价是多一层间接(11.4.3 说过)。
六、动手 Demo:会不会飞的鸭子
任务:把"一个对象好几个地址"完整看一遍 ——包括"错误地比较"和"正确地比较"。
#include <iostream>
#include <typeinfo>
using namespace std;
class Flyer {
public:
virtual ~Flyer() {}
virtual void fly() const { cout << "(飞起来了)" << endl; }
};
class Swimmer {
public:
virtual ~Swimmer() {}
virtual void swim() const { cout << "(游起来了)" << endl; }
};
class Duck : public Flyer, public Swimmer { // 鸭子两样都会
public:
void fly() const { cout << "(鸭子飞起来了)" << endl; }
void swim() const { cout << "(鸭子下水了)" << endl; }
void quack() const { cout << "嘎嘎" << endl; }
};
int main() {
Duck d;
Flyer *pf = &d;
Swimmer *ps = &d;
cout << "-- (1) 三个指针的值 --" << endl;
cout << "(void *)&d = " << (void *)&d << " ← 整个对象" << endl;
cout << "(void *)pf = " << (void *)pf << " ← Flyer 子对象" << endl;
cout << "(void *)ps = " << (void *)ps << " ← Swimmer 子对象(不一样!)" << endl;
cout << "-- (2) 判断是不是同一个对象 --" << endl;
cout << "(void *)pf == (void *)ps → "
<< ((void *)pf == (void *)ps) << " ← 错了!" << endl;
cout << "dynamic_cast<void *> 比较 → "
<< (dynamic_cast<void *>(pf) == dynamic_cast<void *>(ps)) << " ← 对了" << endl;
cout << "-- (3) 交叉转换:只剩 Flyer*,也能变回 Swimmer* --" << endl;
Swimmer *back = dynamic_cast<Swimmer *>(pf); // ★ 横向转
if (back != nullptr) {
cout << "转换成功,鸭子:";
back->swim();
} else {
cout << "它不是鸭子,只是会飞" << endl;
}
cout << "-- (4) typeid 完全不受地址影响 --" << endl;
cout << "typeid(*pf).name() = " << typeid(*pf).name() << endl;
cout << "typeid(*ps).name() = " << typeid(*ps).name() << endl;
cout << "-- (5) 万一它不是鸭子 --" << endl;
Flyer onlyFly;
Flyer *pf2 = &onlyFly;
Swimmer *no = dynamic_cast<Swimmer *>(pf2);
cout << "对一只纯 Flyer 做交叉转换:"
<< (no == nullptr ? "给了 nullptr(安全)" : "成功了") << endl;
return 0;
}
看到什么算成功:
-- (1) 三个指针的值 --
(void *)&d = 0x62fdf0 ← 整个对象
(void *)pf = 0x62fdf0 ← Flyer 子对象
(void *)ps = 0x62fdf8 ← Swimmer 子对象(不一样!)
-- (2) 判断是不是同一个对象 --
(void *)pf == (void *)ps → 0 ← 错了!
dynamic_cast<void *> 比较 → 1 ← 对了
-- (3) 交叉转换:只剩 Flyer*,也能变回 Swimmer* --
转换成功,鸭子:(鸭子下水了)
-- (4) typeid 完全不受地址影响 --
typeid(*pf).name() = 4Duck
typeid(*ps).name() = 4Duck
-- (5) 万一它不是鸭子 --
对一只纯 Flyer 做交叉转换:给了 nullptr(安全)
读结果:
- 第 (1) 里的地址每次都不同(取决于操作系统给你哪块内存),要看的只是"
ps和另外两个不一样"。64 位下这个偏移常常是 8 字节(一个vptr的宽度);32 位下常常是 4 ——但这是实现细节,不要写进代码里依赖它。 - 第 (2) 是本节的核心:同一个对象,
(void *)比较说不相等 ——因为(void *)转换只是"保留当前地址",不会去找"对象头"。 - 第 (3) 是 RTTI 的独家本事:只剩
Flyer *的情况下,static_cast完全办不到这件事(编译错误),dynamic_cast却在运行期把它办成了。 - 第 (4) 是个安慰:
typeid比的是"类型信息",跟地址无关,所以多重继承完全不影响它。 要用"是不是同一类"来判断时,typeid比指针更好用。 - 第 (5) 是安全网:对象不是鸭子时,交叉转换给
nullptr,程序没事。 这正是dynamic_cast比static_cast值钱的地方 ——它敢说"我不知道",static_cast只会硬着头皮给你一个错的地址。
30 秒实验
- 把第 (3) 的
dynamic_cast<Swimmer *>(pf)改成static_cast<Swimmer *>(pf)→ 编译不过(invalid static_cast from type 'Flyer*' to type 'Swimmer*')。确认"交叉转换只有dynamic_cast能做"。 - 在第 (5) 把
dynamic_cast改成reinterpret_cast(14.1.4 会讲)→ 编过了,但no不是nullptr——它会给你一个"指错了地方"的指针,一用就崩。 这就是"带检查"和"不带检查"的区别。 - 加一行
cout << (void *)dynamic_cast<void *>(pf) << endl;→ 它和(void *)&d相同(都是"对象从头开始"那个地址)。 - 把两个基类改成"都虚继承自一个公共基类"(组成菱形),再跑一次:两个
dynamic_cast<A *>会给同一个地址(11.4.3 的虚继承在起作用)。
常见误区
- "'同一个对象'就是'指针相等'" ——在多继承下不成立。要用
dynamic_cast<void *>比,或者干脆别比地址。 - "
static_cast转指针不会改变值" ——会改变(它要按基类布局调整偏移)。"零开销"说的是"不需要运行期查找",不是"值不变"。 - "
dynamic_cast只能向下转(父 → 子)" ——还能横向转(交叉转换)。这是它独有的能力,也是 RTTI 最正当的用途之一。 - "
dynamic_cast到了非多态类型也能用" ——不能。两边都必须是多态类型(有虚函数)。多重继承里如果某一层忘了虚函数,交叉转换就会失败或编译不过。 - "指针比较的问题只有多继承才有" ——单继承里也有,只是更隐蔽:单继承下"基类子对象"常常在偏移 0,所以两个指针碰巧相等(看起来没事)。一旦加了第二个基类,偏移不再是 0,坑才浮出水面。 "碰巧对"不是"对"。
- "那我干脆不用多重继承" ——该不该用是 11.3 的事(能用组合就别继承)。本节要你记住的是一条"用了之后必须遵守的规矩":拿基类指针做"同一性判断"or 当哈希 key 时,必须走
dynamic_cast<void *>。 - "
typeid在多重继承下也会错" ——不会。typeid拿到的是最派生类型,和你在哪张"脸"上问无关(Demo 第 (4) 步:不管pf还是ps,都答Duck)。
自测三问(点开对答案)
1. **为什么多重继承下"一个对象会有多个地址"?** ——因为**对象的每个基类子对象都排在内存的不同偏移处**(按继承声明顺序),**指向不同基类子对象的指针,装的值自然不同**(偏移 0 的那个常常和对象起始地址相同)。 2. **要判断两个基类指针"是不是同一个对象",正确写法是什么?** ——**`dynamic_cast- ☐ 下一节(14.1.4 RTTI转换语法)
相关笔记
- RTTI与引用 —— 上一节:引用上的
dynamic_cast与bad_cast - RTTI转换语法 —— 下一节:四种转换运算符的全景
- 多重继承 —— 多重继承是什么样、有什么麻烦
- 多重继承的定义 —— 对象里"父类部分"的排列顺序
- 二义性 —— 菱形继承的两份祖先
- 虚继承 —— 让祖先进化回一份
- 指向空的指针与空类型指针 ——
void *是万能口袋 - 🔷 C++基础 · 索引 —— 全课路线图