多重继承(multiple inheritance)是 C++ 少数几个「别的语言基本不给」的特性。它本身不难用,真正的坑在菱形继承(diamond inheritance):当D同时继承B1和B2,而这两者又都继承自A时,D对象里会同时存在两份A子对象,d.value这种最普通的访问会直接变成编译错误。virtual继承能解决这个问题,但它不是免费的,这篇用sizeof实测和内存布局图,把「虚继承解决了什么、又付出了什么代价」算清楚。
1. 引子:多重继承的构造与析构顺序
先看一个不涉及菱形的干净例子:Radio同时继承Clock和Timer,两个基类各自在构造/析构函数里打印一行。
// demo1.cpp — 编译: g++ -std=c++17 -Wall -O2 demo1.cpp -o demo1#include<iostream>classClock{public:Clock(){std::cout<<"Clock 构造\n";}~Clock(){std::cout<<"Clock 析构\n";}};classTimer{public:Timer(){std::cout<<"Timer 构造\n";}~Timer(){std::cout<<"Timer 析构\n";}};classRadio:publicClock,publicTimer{// 基类声明顺序:Clock 在前public:Radio(){std::cout<<"Radio 构造\n";}~Radio(){std::cout<<"Radio 析构\n";}};intmain(){Radio r;std::cout<<"--- 对象存活期间 ---\n";}Clock 构造 Timer 构造 Radio 构造 --- 对象存活期间 --- Radio 析构 Timer 析构 Clock 析构规则只有两条,但必须记牢:基类按「声明顺序」构造(与初始化列表里的书写顺序无关),析构顺序与构造完全相反。所以对象的内存布局也按声明顺序排:Clock子对象在前,Timer子对象在后。
构造:Clock ──► Timer ──► Radio(自身成员) 析构:Radio(自身成员)──► Timer ──► Clock Radio 对象内存布局(声明顺序 = 内存顺序): ┌────────────────┐ 低地址 │ Clock 子对象 │ ├────────────────┤ │ Timer 子对象 │ ├────────────────┤ │ Radio 自身成员 │ └────────────────┘ 高地址2. 菱形继承:为什么D里有两份A
把上面的结构改一下:B1和B2都继承自同一个A,D再同时继承B1和B2,这就是菱形继承。
// demo2_bad.cpp — 片段:菱形继承,编译不过classA{public:intvalue=7;};classB1:publicA{};// B1 里嵌了一份 AclassB2:publicA{};// B2 里又嵌了一份 AclassD:publicB1,publicB2{};// D 里于是有了两份 Avoiduse(D&d){d.value=9;// 编译错误:不知道你指的是哪一份 A}上面是片段(不单独运行),它真实触发的编译错误是这样的:
prog.cc: In function 'void use(D&)': prog.cc:12:7: error: request for member 'value' is ambiguous 12 | d.value = 9; // 编译错误:不知道你指的是哪一份 A | ^~~~~ prog.cc:4:9: note: candidates are: 'int A::value' 4 | int value = 7; | ^~~~~ prog.cc:4:9: note: 'int A::value'注意编译器的措辞:candidates are后面列了两遍int A::value,它在告诉你「有两个同样好的候选,我选不出来」。这不是 bug,而是值语义的必然结果:D就是由B1和B2两个子对象拼起来的,每个子对象都完整包含一个A,所以内存里真的躺着两份value。
A (value = 7) / \ / \ B1 : A ◄ ► B2 : A 普通继承:各带一份 A \ / \ / D D 对象的内存布局(普通继承): ┌──────────────┐ 低地址 │ A 子对象 #1 │ value = 7 ├──────────────┤ │ A 子对象 #2 │ value = 7 ← 完全独立的第二份 └──────────────┘ 高地址 d.value → 编译器看到两个候选 → error: ambiguous想绕过去也可以,显式写d.B1::value = 9;,但这只是在向「我有两个 A」这个事实妥协。真正合理的诉求是「D只应该有一个A」,那正是虚继承要解决的。
官方文档:cppreference · Derived classes —— 「Virtual base classes」一节说明虚继承的语义与构造规则。
3. 虚继承:让A只保留一份
把B1、B2对A的继承改成virtual public:
// demo3.cpp — 编译: g++ -std=c++17 -Wall -O2 demo3.cpp -o demo3#include<iostream>classA{public:inta_=1;};// 普通继承用的祖先classB1:publicA{};classB2:publicA{};classD:publicB1,publicB2{};// 两份 AclassVA{public:inta_=1;};// 虚继承用的祖先classVB1:virtualpublicVA{};// 关键:virtualclassVB2:virtualpublicVA{};classVD:publicVB1,publicVB2{};// 只剩一份 VAintmain(){std::cout<<"sizeof(int) = "<<sizeof(int)<<'\n';std::cout<<"sizeof(A) = "<<sizeof(A)<<'\n';std::cout<<"sizeof(D) = "<<sizeof(D)<<'\n';std::cout<<"sizeof(VA) = "<<sizeof(VA)<<'\n';std::cout<<"sizeof(VB1) = "<<sizeof(VB1)<<'\n';std::cout<<"sizeof(VD) = "<<sizeof(VD)<<'\n';}sizeof(int) = 4 sizeof(A) = 4 sizeof(D) = 8 sizeof(VA) = 4 sizeof(VB1) = 16 sizeof(VD) = 24这组数字里有两个信息,第二个特别容易被忽略:
| 类型 | 大小 | 含义 |
|---|---|---|
sizeof(A)/sizeof(VA) | 4 | 祖先只有一个int,本身不因虚继承变大 |
sizeof(D) | 8 | 普通继承:4 + 4,两份A各占 4 字节 |
sizeof(VB1) | 16 | 虚继承的中间类:多出 8 字节虚基类指针 + 对齐填充 |
sizeof(VD) | 24 | 最终类:8 + 8 两个虚基类指针 + 唯一一份 4 字节VA,补齐到 24 |
第一个信息是「虚继承确实去重了」:VD里只有一份VA,不再是两份。第二个信息是代价:VD反而比普通继承的D(8 字节)大得多:去重省下的 4 字节,远远抵不过为「运行期定位共享子对象」而增加的指针。
VD 对象的内存布局(VB1、VB2 都 virtual 继承 VA): ┌──────────────────────┐ 低地址 │ VB1 的虚基类指针 │ 8 字节 ──┐ 记录「共享 VA 距我多远」 ├──────────────────────┤ │ │ VB2 的虚基类指针 │ 8 字节 ──┼──► 指向同一份 VA ├──────────────────────┤ │ │ VA 子对象(唯一一份) │ 4 字节 ──┘ ├──────────────────────┤ │ padding(对齐到 8) │ 4 字节 └──────────────────────┘ 高地址 sizeof(VD) = 24对比一下就很直观:普通继承把A摊平在固定偏移(编译期常量,一次加法就能定位);虚继承的共享VA不在固定偏移上,VB1想访问a_必须先读自己的虚基类指针,再算出偏移,这是多出来的一次内存间接访问。
官方文档:C++ Core Guidelines · 类层次结构 ——
C.hier一节给出了继承层次的取舍原则。
4. 虚继承的代价,比想象中多
4.1 构造顺序特殊:虚基类只由「最派生类」初始化
虚继承改变了构造顺序。虚基类先于非虚基类构造,而且只由最派生类负责初始化,中间类初始化列表里对虚基类的调用会被静默忽略。
// demo4.cpp — 编译: g++ -std=c++17 -Wall -O2 demo4.cpp -o demo4#include<iostream>classA{public:explicitA(intv){std::cout<<"A("<<v<<")\n";}};classB:virtualpublicA{public:explicitB(intv):A{v}{std::cout<<"B\n";}// 非最派生时,这个 A{v} 会被忽略};classC:virtualpublicA{public:explicitC(intv):A{v}{std::cout<<"C\n";}};classD:publicB,publicC{public:D():A{42},B{1},C{2}{std::cout<<"D\n";}// 虚基类 A 只由最派生类 D 初始化};intmain(){D d;}A(42) B C D输出里A(42)而不是A(1):B{1}传入的参数 1 根本没被用到,A用的是D给的 42。这意味着中间类无法保证自己的虚基类处于什么状态:B单独构造时A是A(1),作为D的基类时A是A(42)。设计中间类时必须假定「虚基类由别人初始化」,别把状态假设写死在虚基类构造函数里。
4.2 从虚基类向下转换:不能用static_cast
虚基类子对象的位置依赖运行期上下文,编译器没法把它算成编译期常量,所以static_cast直接拒绝:
// demo5_bad.cpp — 片段:从虚基类向下转换classA{public:virtual~A()=default;};classB1:virtualpublicA{};classD:publicB1{};voiddowncast(A&a){auto*p=static_cast<D*>(&a);// 编译错误:虚基类不能 static_cast 向下转(void)p;}prog.cc: In function 'void downcast(A&)': prog.cc:7:33: error: cannot convert from pointer to base class 'A' to pointer to derived class 'D' because the base is virtual 7 | auto* p = static_cast<D*>(&a); // 编译错误:虚基类不能 static_cast 向下转 | ^想在这里做向下转换,只能用dynamic_cast<D*>(&a)。
dynamic_cast需要 RTTI,它会沿继承链查一次,又是一次运行期开销。这正是虚继承的真实成本:一次空间(虚基类指针)、一次访问间接、一次转换查表。
| 代价 | 具体表现 | 这里实测 |
|---|---|---|
| 空间 | 每个虚继承分支多一个虚基类指针 | sizeof(VD)=24,而普通继承的sizeof(D)=8 |
| 成员访问 | 定位共享子对象要先读指针再算偏移 | 多一次内存间接访问(可在 godbolt 看汇编) |
| 构造 | 虚基类只由最派生类初始化,中间类的话被忽略 | 输出A(42)而非A(1) |
| 转换 | 虚基类向下转必须dynamic_cast | static_cast编译失败 |
| 拷贝/赋值 | 编译器生成的版本处理虚基类时语义复杂 | Core Guidelines 建议多态类显式控制拷贝 |
5. 多重继承到底该不该用:决策表
把上面的结论压缩成一张表,写代码时先扫一眼:
| 你的需求 | 建议 | 理由 |
|---|---|---|
| 一个类要实现多个「纯接口」 | 用多重继承 | 接口无数据成员,不产生状态冗余,语义清晰 |
| 想复用多个已有类的实现 | 改用组合 | 避免状态冗余、成员歧义、构造顺序被搅乱 |
| 确实需要「唯一一份」共同祖先状态 | ️ 虚继承,谨慎 | 能去重,但对象变大、构造规则特殊、访问更慢 |
| 只想给某个类加一种能力 | 用一个接口 + 组合成员 | 见 Core Guidelines 对「接口继承 vs 实现继承」的区分 |
一句话概括:多重继承用于「实现多个接口」是合理的,用于「继承多份实现」通常应该改成组合。
官方文档:C++ Core Guidelines · 多重继承 —— 明确指出多重继承只应该用来表达「多个彼此独立的接口」。
6. 完整示例:多接口 + 组合各司其职
下面这个程序把所有要点收在一处:Document用多重继承实现两个纯接口(无数据冗余、无歧义);Report需要复用Encoder的实现,用的是组合而不是继承。
// demo6.cpp — 编译: g++ -std=c++17 -Wall -O2 demo6.cpp -o demo6#include<iostream>#include<memory>#include<string>#include<utility>classPrintable{// 接口 1:只有纯虚函数public:virtualstd::stringprint()const=0;virtual~Printable()=default;};classSerializable{// 接口 2:只有纯虚函数public:virtualstd::stringserialize()const=0;virtual~Serializable()=default;};classDocument:publicPrintable,publicSerializable{// 多重继承 = 实现两个接口std::string title_;public:explicitDocument(std::string t):title_{std::move(t)}{}std::stringprint()constoverride{return"print: "+title_;}std::stringserialize()constoverride{return"json:{\"title\":\""+title_+"\"}";}};classEncoder{// 提供实现,不是接口public:std::stringencode(std::stringconst&s)const{return"<"+s+">";}};classReport{Encoder encoder_;// 组合:复用实现,不用多重继承Document doc_;public:explicitReport(std::string t):doc_{std::move(t)}{}std::stringrender()const{returnencoder_.encode(doc_.print());}};intmain(){autodoc=std::make_unique<Document>("notes");Printableconst&p=*doc;// 通过接口 1 使用Serializableconst&s=*doc;// 通过接口 2 使用std::cout<<p.print()<<'\n';std::cout<<s.serialize()<<'\n';Report rep{"weekly"};std::cout<<rep.render()<<'\n';}print: notes json:{"title":"notes"} <print: weekly>注意Document同时被当成Printable和Serializable使用,两个接口各有一张虚表指针,这就是多重继承在「多接口」场景下的全部成本,没有数据冗余、没有歧义、没有虚基类指针。
7. 延伸阅读
- cppreference · Derived classes —— 基类列表、构造顺序、虚基类规则的权威说明。
- C++ Core Guidelines · C.hier: Class hierarchies —— 继承层次的总体原则,含「区分接口继承与实现继承」。
- C++ Core Guidelines · 多重继承 —— 多重继承的适用边界。
- Compiler Explorer —— 把虚继承与普通继承的成员访问贴进去,对比汇编里是否多了一次指针解引用。
本知识库内的相关篇目:
- 《对象切片(object slicing):多态失效的隐形杀手》 —— 把派生类对象按值赋给基类对象、按值传给基类参数、塞进 std::vector
- 《纯虚函数与抽象类:接口该怎么设计》 —— 纯虚函数定义抽象类、强制派生类实现接口,但两个反直觉点常被忽略
- 《虚函数与虚函数表:一次调用到底跳了几次》 —— 虚函数的动态绑定靠每个对象隐藏的 vptr 和每类一份的 vtable。
8. 一句话总结
多重继承按声明顺序构造、逆序析构;菱形继承会让D里出现两份A子对象,成员访问直接ambiguous编译报错;virtual继承让A只保留一份,代价是多出虚基类指针(sizeof从 8 涨到 24)、成员访问多一次间接寻址、虚基类只能由最派生类初始化、向下转换必须用dynamic_cast,所以它适合「实现多个接口」,不适合「继承多份实现」,后者请改成组合。