1978 年的小机房
那会儿卡带得自己翻面。
贝尔实验室的人盯着 PDP-11。
代码堆得像墙。
你想改一个小功能。
得先把一堆 .c 翻出来。
然后复制。
粘贴。
再祈祷别漏改。
最要命的是。
你还真会漏改。
他们先有组合,才敢谈继承
在“没有继承”这个词还没流行的时候。
大家已经在做一件事。
把一个结构体塞进另一个结构体里。
今天我们叫它“组合”。
意思很直白:把它当零件用。
你有一个 Engine,车里装一台 Engine。这就是 has-a,人话版是:“我有一个”。
Simula 那帮人早就这么干了。Bjarne 看到也挺淡定:这不就很自然嘛。
C with Classes 也先把这条路走稳了——能用成员塞进去解决的问题,就别整花活。
继承是后来才慢慢放开的,但它不是为了少写几行。它解决的是另一件更“硬”的事:让一个类型在接口上能“顶替”另一个类型。
is-a 就是这个意思,人话版:“我就是那个东西”。你一旦这么说,别人就真敢把你当那个东西用。
能塞进 Base& / Base* 的地方都会塞你。也就难免要扯到虚函数、调用规则、替换后还能不能跑。
继承不是语法糖。
它更像一种承诺。
复用冲动最容易把继承玩坏
很多坑都来自同一个动机:“我就想复用一点代码。”于是顺手写了下面这一行。
class Derived : public Base
这一行写下去,其实等于在说:Derived 可以当 Base 用。任何吃 Base&、Base* 的老代码,都能把你塞进来。
可你当初只是想复用几行实现,并没有想“签这个合同”。
坑就从这儿开始。
你可能见过这种剧情:打印机驱动里,所有设备都继承 Device。有人为了省事,把 Device 里某个 protected 成员拿来当缓存。
那会儿跑得挺好。直到几年后来了“虚拟打印机”,语义一变,缓存一套上去就翻车。
Bug 从机房一路打到客服那边。
最后通常落在一个无辜的新人头上。
记住这句话就够了
继承是在说“身份”。
组合是在说“零件”。
复现场景:车不是发动机
看一段很短的代码,短到你会觉得“这能出事?”。但它真的能。
struct Engine {
void start() { puts("engine start"); }
};
struct Car : Engine {
void drive() {
start();
}
};
问题不在 start()。
问题在 Car : Engine。
因为这一句会让 Car 在类型系统里变成“也是一个 Engine”。于是只要有人写了个函数——“给我一个 Engine&,我来检查发动机”——他就能把 Car 直接塞进去。
你站在车厂角度想想,这像不像把整辆车抬上手术台,说“我来修发动机”。听着就不对劲。
换成组合,就老实多了:
struct Car {
Engine engine;
void drive() {
engine.start();
}
};
现在 Engine 是车里的零件,Car 不会再对外宣称“我就是发动机”。逻辑一样复用,但误用的路,被你顺手堵上了。
判断准则别背,拿来问一句就行
你想用继承的时候,先别急着写 public Base。停两秒,问自己一句:如果我把这个派生类丢给一段只认识 Base 的老代码,它还能稳稳当当地干完活吗?
如果能,继承就有机会成立。
如果不太能,那多半是在骗自己。
能不能替换。
这句想不清楚,就先用组合。
说到这里,还有两个很现实的坑:生命周期和改动成本。先说生命周期——当两个类型的构造、析构顺序不一致时,继承很容易踩雷。
组合反而更直观:你能清楚地看到每个成员什么时候建,什么时候拆。调试也更好查。
再说改动成本:你一旦把继承体系公开出去,尤其是带虚函数那种,很多东西就像“对外承诺”了。想拆想改,会越来越痛。
组合更灵活:要换零件就换零件,要加零件就加零件。
再强调一次
想清楚“能不能替换”。
再去写 public Base。
只是想复用实现。
就用组合。
别硬继承。
一个小洞见:画两张图,问题就暴露了
很多团队会同时画两张图:一张是“接口树”,回答“谁能冒充谁”;一张是“组件图”,回答“谁里面装了谁”。
当这两张图打架的时候,别硬拗。把继承降级成组合,或者成员引用,你会突然轻松很多。
因为你会发现:
继承 vs 组合的选择,本质就一句话——
你到底要不要让它名正言顺地“冒充老前辈”。
继承像签合同。
组合像装零件。