news 2026/10/10 6:06:45

多重继承与菱形继承:虚继承到底解决了什么

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多重继承与菱形继承:虚继承到底解决了什么

多重继承(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_caststatic_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,所以它适合「实现多个接口」,不适合「继承多份实现」,后者请改成组合。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 6:06:38

Linux --传输层协议 UDP

传输层是干什么的&#xff1f;传输层位于网络层之上、应用层之下&#xff0c;核心任务只有一个&#xff1a;负责把数据从发送端传输到接收端。网络层&#xff08;IP&#xff09;负责把数据送到目标主机&#xff0c;但主机上可能同时运行着很多程序&#xff08;浏览器、QQ、微信…

作者头像 李华
网站建设 2026/10/10 6:06:35

pprof 火焰图(Flame Graph)阅读与热点代码重构实战

pprof 火焰图&#xff08;Flame Graph&#xff09;阅读与热点代码重构实战 一、核心概念与架构设计 上一篇用 go tool pprof -top 看到了函数级的耗时排名&#xff0c;但排名有一个致命缺陷&#xff1a;它丢掉了调用关系。fmt.Sprintf 占 8% 的 CPU&#xff0c;这 8% 是谁调用它…

作者头像 李华
网站建设 2026/10/10 6:06:07

工业相机标定实战:内参模型选择与双目标定闭环

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/10 6:05:57

雪花 ID 传到前端就变了个数?我用全局 Long 转 String 一次收口

先说结论 MyBatis-Plus 默认的雪花算法&#xff08;IdType.ASSIGN_ID&#xff09;生成的是 19 位 Long&#xff0c;而 JS 的 Number 能精确表示的最大整数是 2^53 - 1 9007199254740991&#xff08;16 位&#xff09;。 19 位 > 16 位 → id 以数字形式进 JSON 就必然丢精度…

作者头像 李华
网站建设 2026/10/10 6:05:24

PHP科技企业模板部署与安全加固实战指南

简介&#xff1a;这是一套开箱即用的PHP科技企业官网模板&#xff0c;专为IT、软件、科技产品及工作室类网站快速建站设计&#xff0c;适合前端初学者与中小型技术团队进行二次开发与部署。模板采用淡绿色主色调&#xff0c;视觉高端大气&#xff0c;内置PHP后台管理系统&#…

作者头像 李华
网站建设 2026/10/10 6:05:12

Beyond Compare高效使用指南:从文本比较到文件夹同步与三路合并

Beyond Compare到底怎么用才叫“高效”——我把它翻来覆去用了一遍之后先说个我自己的经历。有次处理一个发布包&#xff0c;上一个版本和这个版本之间文件改了几十个&#xff0c;靠肉眼去翻目录、逐个看修改时间&#xff0c;折腾一晚上&#xff0c;最后还是漏掉了两个配置文件…

作者头像 李华