1979 年前后,写 C 的人碰到的不是“语法”,是日子
那会儿大家写 C。
写在一堆 .c 和 .h 里。
项目开始变大。
但工具和语言,还是那个工具和语言。
你想复用一段逻辑。
第一反应不是“继承”。
第一反应是:复制粘贴。
然后祈祷别漏改。
再然后。
你就会开始讨厌自己。
我也干过。
没有“继承”的年代,大家怎么做“长得像”的东西
在 C 里,常见做法很朴素。
把一段“公共字段”单独放进一个 struct,再塞进别的 struct 里。
struct Base {
int id;
};
struct Derived {
Base base;
int extra;
};
你要“当成 Base 用”,就拿 base 那一段的地址。
Derived d{Base{1}, 2};
Base* pb = &d.base;
pb 不是指向新对象。
它只是指向同一块内存里“公共那一段”的起点。
一个会让人上头的坑:以为地址总是一样
这套写久了,很容易产生一种错觉。
既然 base 经常放在开头,那我能不能直接把 &d 当成 Base*?
Derived d{Base{1}, 2};
Base* pb = (Base*)&d; // 看起来省事
很多机器上,这段“看起来没出事”。
因为 base 恰好是第一个成员,&d 和 &d.base 恰好一样。
但你其实是在赌“布局”。 布局就是:这些成员在内存里怎么摆。
只要你多加一个字段,赌局就翻车。
struct Bad {
int tag;
Base base;
};
Bad b{7, Base{1}};
Base* pb = (Base*)&b;
这时候 pb 以为自己在看 Base::id,实际上它在看 tag。
更难受的是:它不一定立刻崩。
这就是当年“C with Classes”想解决的那类现实
时间倒回 1979 年前后。 Bjarne Stroustrup 在贝尔实验室做 C with Classes。
他面对的经常不是“写得更优雅”,而是“能不能在真实工程里活下去”。 新东西要和 C 共存,最好还能继续用当时的工具链。
他后来有句很出名的口头禅:
“你没用到的东西,就不该让你付出成本。”
所以问题变得很具体。 能不能让“公共那一段”变成语言承诺,别再靠人脑记布局。
C++ 选择了一个很硬的做法:把“那一段”变成对象本身
你在 C++ 里写:
struct Base {
int id;
};
struct Derived : Base {
int extra;
};
这里有个新名词:基类子对象。
别紧张。
它的意思很直白:Derived 里面,真的住着一份完整的 Base。
不是“像 Base 的字段”。
就是 Base 本人。
于是这句看起来像魔法的代码,突然就有了落脚点
Derived d;
Base* pb = &d;
这叫“向上转型”。
意思是:同一个对象,我现在只看它的 Base 那一部分。
pb 指向的是 d 里面那份 Base(也就是那份“基类子对象”)。
关键结论
向上转型不是“创建新对象”。
只是“换个角度看同一块内存”。
顺手就能解释:对象切片到底在切什么
再看一行更容易让新人皱眉的代码。
Derived d;
Base b = d;
这里 b 是一个独立的 Base 对象。
它只从 d 里拷走了那份 Base,extra 没地方放,就丢了。
这就叫“对象切片”。
不是多态失灵。 是你主动把一个大对象,拷成了一个小对象。
不止一种继承:有时指针真的需要“搬家”
单继承很顺手。 但多重继承下,“那份基类在对象里的位置”不一定在开头。
struct B1 { int x; };
struct B2 { int y; };
struct D : B1, B2 {
int z;
};
D d;
B2* p = &d;
p 仍然指向 d 里面那份 B2。
只不过编译器可能需要把指针值调整一下,带你走到正确的起点。 你可以把它理解成:同一个对象里有好几段房间,门口不一定在同一个位置。
小结
早年的 C 项目里,大家用“把 struct 塞进去”来模仿复用。
能用。
但太靠约定。
C++ 做的关键一步,是把这件事写进语言合同里:派生类对象里确实有一份基类对象。
后面你再看指针转换、对象切片、甚至多重继承的各种“指针调整”,就不会像黑魔法了。
我自己最喜欢的洞见是:
继承最硬的部分,其实不是语法。
是布局这份合同。