那些年,大家还没把“继承”当成一件很数学的事
80 年代的工程师很少一上来就讨论“对象模型”。
他们更常讨论的是。
这段代码能不能少复制一点。
这个模块能不能少改几处。
这个改动能不能别把整个项目带崩。
那时候 C with Classes 还很年轻。
你能写 class。
能把函数和数据绑在一起。
还能“复用”。
听起来像中了大奖。
然后现实很快就会提醒你。
复用不是免费的。
“复用两个东西”,尤其不是。
没有多重继承之前,复用通常长得很土
在 C 里,最常见的复用,就是把一个 struct 塞进另一个 struct。
你想要两份公共字段?那就塞两份。
struct A {
int a;
};
struct B {
int b;
};
struct D {
A a_part;
B b_part;
};
这时候你想“把 D 当成 A 用”,就拿 &d.a_part。
直观,但也很靠自觉。
因为你下一秒就可能想偷懒,直接把 &d 当成 A*。
D d;
A* pa = (A*)&d; // 反正 A 在最前面吧?
在你的小机器、小测试里,它可能还真跑得通,因为 A 那段恰好在开头。
但这其实是在赌“布局”,也就是字段在内存里怎么摆。
赌局一旦输了,你读到的就不是 A::a 了。
后来大家开始想:能不能让语言替人背锅
C++ 做的一个关键决定,是把“基类那一段”从约定变成语言合同。
你写 struct D : A, B 的那一刻,编译器就承诺:D 里面真的有一份 A,也真的有一份 B。
先把新名词掰开:什么叫“基类子对象”
“基类子对象”听着像论文,其实就是“那份真的住在对象里的 A / B”。
它们都在同一块内存里,但起点不一定相同。
这句话你先别急着记。 你只要先接受一个画面:一个对象里,可能有不止一个“门口”。
一小段代码,把“指针为什么会变”复现出来
#include <cstdio>
struct A { int a = 1; };
struct B { int b = 2; };
struct D : A, B {
int d = 3;
};
int main() {
D obj;
D* pd = &obj;
A* pa = &obj;
B* pb = &obj;
std::printf("pd=%p\n", (void*)pd);
std::printf("pa=%p\n", (void*)pa);
std::printf("pb=%p\n", (void*)pb);
}
你会看到一个很“违背直觉”的画面:pa 往往等于 pd,pb 往往不等于。
这不是玄学,只是 B 那份子对象不一定住在开头。
你可以把它粗暴画成这样。
| A 子对象 | B 子对象 | D 自己的成员 |
^pd/pa ^pb
不是所有编译器都长得一模一样。 但这个“pb 指向中间某一段”的直觉,一般是对的。
“指针调整”到底在干嘛
当你写 B* pb = &obj; 时,编译器会把 &obj 这个地址,加上一个偏移,走到 B 子对象的起点。
偏移这个词也不神秘,就是“从对象开头往后数多少字节”。
为什么这事会坑到人
因为你在 C 里学到的直觉是:“一个东西的地址,就是它的地址”。 多重继承把这件事拆成了两层:对象还是那个对象,但你拿到的是哪个“门口”,取决于你把它当成哪个基类看。
这就是所谓的“向上转型”。
意思是:我手里明明是 D,但我现在只想把它当成 A 或 B 用。
再用一段短代码,看看错误的写法会错到哪里
D obj;
B* good = &obj;
B* bad = (B*)&obj;
good 是编译器帮你算过的“B 门口”。bad 是你硬把“D 门口”当成“B 门口”。
区别不是“写法好不好看”。
区别是编译器有没有帮你走到正确的门口。
如果你后面读 bad->b,你读到的可能不是 b,而是别的字段。
它不一定立刻崩,这才最吓人。
关键结论
多重继承下,“向上转型”可能会改变指针值。
但它仍然指向同一个对象里的某一段。
顺手解释一个常见疑问:那我该用什么 cast
如果你在做“派生类指针转基类指针”,也就是 D* 变 A*、B*,正常写法就是让编译器自己做。
你甚至不需要写 cast;如果你非要写,static_cast<B*>(pd) 表达的意思是:请按类型关系做指针调整。
而 reinterpret_cast 或者 C 风格的强转,更像是在说:“别管类型了,你就把这个地址当成另一个类型看吧。”
多重继承下,这句话往往很危险。
一个老工程师的小洞见
很多人把多重继承的争议,吵成了“能不能用”。 但我觉得它真正的分水岭是:你到底是在复用实现,还是在复用接口。
如果你只是想要两段实现塞进一个类型里,组合通常更省心。 如果你真的需要“一个对象能被当成两种基类使用”,那你就得接受这份合同:对象里有两个门口。
小结
多重继承并没有发明什么神秘机制。
它只是把你在 C 里手工拼 struct 的做法,变成了编译器负责的合同。
而“指针转换时发生了什么”,说到底就一句话。
编译器帮你走到正确的门口。