多重继承这东西。
一开始不是为了炫技。
是为了活下去。
那是 80 年代。
C++ 还叫 C with Classes。
项目开始变大。
文件多。
人也多。
复用的冲动特别真实。
你写了一套“通用能力”:能打印,能扫描,能记日志。
然后业务说:我想把它们拼起来。
你第一反应很朴素。
那就继承两个。
后来麻烦就来了。
不是逻辑。
是对象里那块内存。
当年没有“虚继承”的时候,菱形坑长这样
我们先把坑挖出来。代码真的很短,短到你会怀疑它怎么可能出事。
struct Device {
int id;
};
struct Printer : Device {
};
struct Scanner : Device {
};
struct Copier : Printer, Scanner {
};
Copier 看起来很合理:打印机 + 扫描仪 = 复印机。你甚至会觉得这就是继承最美好的样子。
但你一旦想用 id,就会卡住。不是运行时崩,是编译器当场问你:你到底想要哪一个?
Copier c;
c.id = 1;
因为 Copier 里其实有两份 Device。一份来自 Printer,一份来自 Scanner。
这就是所谓的“菱形继承”。Copier -> Printer -> Device 和 Copier -> Scanner -> Device 两条路径,画出来像菱形。
这个坑为什么会出现:C++ 的合同写得很硬
你在前面那篇里见过一句话:派生类对象里,真的住着一份基类子对象。多重继承一来,这句话就变得特别字面。
Copier 里住着两份“基类子对象”:两份 Device,两份 id,两块内存。编译器不帮你合并,因为它没资格猜你的设计意图。
所以这不是编译器找你麻烦。它只是在认真履约:你写了两条继承路径,它就给你两份基类。
先别急着“解决”:有时候两份基类其实是你想要的
顺手提醒一句:菱形并不一定是错。很多教材把它写得像禁术,但它其实只是一个很锋利的刀。
如果 Printer 和 Scanner 各自都需要一份独立的 Device 状态,那两份就两份,反而更清晰。麻烦出在你心里只有一个“共同身份”的时候,比如它们应该共享同一个 Device::id。
那虚继承是怎么“爬出坑”的
C++ 给了一个明确的说法:virtual 继承。这里的 virtual 不是虚函数那套“动态绑定”,它讲的是布局合同:共同祖先在最终对象里只放一份。
代码也很短。
struct Device {
int id;
};
struct Printer : virtual Device {
};
struct Scanner : virtual Device {
};
struct Copier : Printer, Scanner {
};
现在这句就不再含糊了。
Copier c;
c.id = 1;
因为 Copier 里只有一份 Device,所以 c.id 不再含糊。两条路径最终会“汇合”到同一个祖先。
关键结论
虚继承做的事很简单:让“共同祖先”只保留一份。
但是它也没那么“免费”
你可能会问:那我以后都用 virtual 继承不就好了?
别急。虚继承解决的是“状态重复”,但它带来的是“布局复杂”。
代价一:对象里要多放点“路标”
在普通继承里,向上转型很多时候就是固定偏移的地址调整。虚继承里,共享的 Device 不再有一个能写死的固定位置,因为不同的最终派生类组合会改变它的摆放。
所以实现通常会在对象里塞额外信息,用来在运行时找到那份共享的基类。你可以把它当成“路标”:对象可能更大,访问也可能更绕。
代价二:构造顺序更别扭,“最底下那个”负责把祖先生出来
虚继承还有个更容易让新人皱眉的点:谁来构造那份共享的 Device?答案是最终派生类,也就是最底下那个类型。
struct Device {
Device(int x) : id(x) {}
int id;
};
struct Printer : virtual Device {
Printer() : Device(1) {}
};
struct Scanner : virtual Device {
Scanner() : Device(2) {}
};
struct Copier : Printer, Scanner {
Copier() : Device(42) {}
};
你可能以为 Printer() 会把 Device(1) 用上,但在构造 Copier 的时候,这两句通常不会真的用来构造共享祖先。共享祖先只会被构造一次,由 Copier() 负责。
这不是语言在耍赖,是为了避免两条路径各自把共享祖先构造一遍。
关键结论
虚继承把“共同祖先”的构造权,交给最终派生类。
代价三:你更容易写出“看起来合理,但读起来费劲”的层次
虚继承一旦上了,继承树就不再只是“代码复用”,更像一张图。你需要不断回答一个问题:我到底想共享什么状态,哪些状态必须唯一?
我见过最常见的事故是:团队里只有一个人理解这棵树,其他人写改动时都在踩雷。代码不是不能用,只是维护成本开始悄悄变成账单。
现实里的例子:iostream 为什么经常被拿来当菱形教材
你在书里经常会看到 iostream 被拿来举例,不是因为它最简单,而是因为它真遇到过“共同祖先只该有一份”的需求。
输入流和输出流都要共享一套“流的共同状态”(格式、错误位之类),所以你想做一个既能输入又能输出的类型时,菱形就出现了。
于是虚继承出场:解决重复,也把复杂度留下来。
小结
菱形继承的问题,本质不是“名字冲突”。
是对象里出现了两份你本来只想要一份的状态。
虚继承能把它合并成一份。
但你会为此付出布局、指针调整、构造规则上的成本。
当你想写 virtual 的那一刻,先写一句中文:我到底要保证什么东西只有一份?