深入浅出C++:聊一聊虚继承相关
各位好,我是回声,今天我们来聊一聊虚继承(也叫虚拟继承)相关的东西。
相较于不久之前我聊过的移动语义和完美转发,虚继承这一部分我认为会更加抽象和晦涩,并且在生产之中,它的应用还不是那么广泛。
虚继承的存在是为了解决菱形继承导致的内存重复问题,而引发这一问题的操作则是多重继承。要知道,多重继承这种操作在应用十分宽泛的JAVA与C#中甚至是不允许的,这就侧面说明了:咱们真的想解决什么问题其实是完全可以不去使用菱形继承的。
高级编程语言之所以好用是因为它能够以更接近人思维的方式来操作内存来进行数学处理,只要你不怕麻烦,其实C语言就已经能干几乎所有事情了,所谓面向对象就是对面向过程的封装,在语言机制上没有OOP的C语言更不存在虚继承和多重继承的概念,一切只是一种更便捷的以某种模型或是方式操作内存的手段。
之所以这么说是想表达:虚继承在实际应用中使用的场景少,且需要虚继承解决的问题也完全可以使用其他手段。
相较于很多C++的知识点我个人认为它真的是其中一个性价比比较低的概念,我甚至觉得哪怕掌握了它我都不会去使用。就像之前所说,遇到菱形继承能够解决的问题时,肯定也有其他的解决方案——谨慎的设计更加合理的类和数据结构,或是誊写一些文本量多但能让开发者与阅读者更容易去理解和把握的代码更加符合我的编码习惯。
但这并不意味着虚继承没有学习的必要。
C++是一门修炼内功的语言,它的设计理念几乎辐射到了计算机体系的所有领域,透过学习这些特性和设计,更是能助力对计算机科学这门学科的认知。对于虚继承而言,它是C++编译器为实现继承多态而设计的重要组成部分,它既是编译器设计思路的冰山一角也是深刻理解C++对象模型的重要一环。
说到这里,本文将采用我阅读的<<深度探索C++对象模型>>中学习得到的总结,它是<<C++ Primer>>的作者,Lippman大神的另一本传世经典书籍。
说实话,如果各位有幸看过我特别尊敬的另一位大神——Scott Meyers的Effective系列,再对比一下Lippman先生写的书,我估计你会和我一样的有苦难言。因为Lippman先生真的是从来不把他的读者当成智障,他描述的知识要更加晦涩和抽象,甚至充满跳跃性。在经过侯捷老师那么一译制,它的阅读体验真的是称不上愉快(但是侯捷老师译制的Effective是效果是很好的)。因此,我不能保证我的理解不出现偏差。我只能说,在我有限的实验以及和前辈朋友们的交流里,我的结论是可以有助于对虚继承内存理解的,当然,如果您有您自己的见解以及对我的一些观点有疑问和质疑,我欢迎您在评论区进行礼貌的交流。
好,那我们从现在,正式开始。
我在学习虚继承的时候,从网上找到的资料基本上大同小异。

咱们简单过一遍菱形继承在网上普遍都会讲到的概念,就是当B和C都同时以没有修饰的方式继承自A时,多重继承B、C的D内就会存在两个A的副本,这就是内存重复问题,它将导致程序运行的时候产生一些【在使用A时无法精准确定具体是哪个A】的情况。解决方案很简单,就是B和C继承A的时候誊写虚继承关键字virtual,让B与C以虚继承的方式继承自A,这样,多重继承自B、C的D内,就会只有一份A的实际副本,这样就不会因为在D中存在复数个A的副本而导致在使用资源的时候发生歧义的情况。我们简单看一下这种情况的代码1。
在这段代码中,我们使用了不一样的类名,其继承体系如下所示。

在其中,我们是用A_Base作为基类,两个虚继承的子类分别是A_l1和A_r1,l代表left,r代表right,后面的数字体现着咱们所誊写的继承体系中的继承深度。实验的代码中我使用了先声明再定义的编码,为的是各位可以不那么关注于函数定义,每一个类只有两个函数,构造函数和一个打印函数,用来输出相应的信息,其中打印的信息在标注了函数名称以外,打印他们基类A_base中的成员变量base的值和地址。当我贴出输出的截图的时候,各位大可以不那么关注信息具体是什么,只需要看在整个信息里面有几个不一样的地址输出即可。

可以看到,无论怎么打印,地址只会存在一个,说明base这个成员变量只存在一个,即A_Base这个基类产生的对象副本在对象a2里只存在一个。
这段代码中需要注意,A_2的构造函数中,需要在初始化列表里对A_Base的构造函数进行显式的调用,哪怕两个A_r2和A_l2的构造函数都对如何调用A_Base的构造函数进行了显式的定义,但是在菱形继承的体系里,A_Base的实例只存在一个,从语境的角度上说,没法说A_l2和A_r2这两个类哪个对A_Base的构造规则定义拥有更高的权重,那么索性编译器对它们各自A_Base的构造规则都不使用,而是要求程序员在A_2中显式定义A_Base的构造规则,否则编译器会抛出错误。
这段代码跟网上普遍对虚继承使用的例子基本相同,区别主要是因为定义了构造函数后,程序员需要自己在最下层的子类中规定最上层基类的构造规则。(指在初始化列表里定义调用的构造函数)
那么,虚继承是对对象的内存进行了什么样的编制,才能让基类和谐的在对象内存里只存在一个副本呢?这个也是本篇文章重点讨论的问题,让我们在代码1的基础上修改一下,改成如下的代码2。
代码的改动看起来会比较麻烦,我用图来说明一下这段代码的继承体系。

l1与r1(我省略了前面的A_,后面出现此符号都是指A_l1和A_r1两个类名,如果出现了l2和r2同理)不再虚继承自A_Base,而是由A_2虚继承l1和r1,结果输出会变得大相径庭。

输出之中存在两个不一样的地址,说明A_Base实例化的基类对象在a2这个对象中存在两份副本,菱形继承出现的内存重复问题在这段代码里得到了复现,virtual关键字在继承之中并没有起到它该有的作用。
暂且搁置着问题,我们接着看代码3。
这段代码的继承体系如下图所示:

它的继承体系其实与没有l2和r2是一样的,核心思路就是一边虚继承,另一边不使用虚继承。

代码3的输出结果如下:

可以看到,a2的内存中仍然存在两个【A_Base产生的对象】的副本,内存重复的问题也没有得到解决。
原因是这样的:编译器为虚基类对象构造内存的方式与普通基类构造内存的方式是不一样的,它需要依托一个重要的【虚基指针】(也叫【虚基表指针】)。
需要注意,【虚基指针】(vbptr)与【虚表指针】(vfptr)是两个不一样的名词,虚基表和虚表也是两个不一样的表。前者主要用于虚继承,而后者则主要用于多态的实现。为了方便讨论,我没有为类内任何函数标注virtual关键字,类内没有任何虚函数,那么对象实例化的时候它的内存中就不会存在【虚表指针】。本文的场景中我们只探讨【虚基指针】。
当咱们在定义一个类时,我们对一个类的所有编辑都会体现在内存上。因为不同编译器对如何分配一个对象的内存有不一样的实现,我们暂时不去考虑编译器是以怎样的顺序去放置对象内的数据的。就比如,可能编译器A会把基类放在整个对象的起始内存里,而编译器B则会把基类放在对象的末尾内存里。我们也暂时不要考虑内存对齐的情况,只需要知道,我们定义的类实例化的对象中,完整的拥有自己的成员数据,也完整拥有它所继承的父类对象数据,我们不考虑它们安置的顺序,也不考虑它们的内存对齐情况,它们肯定客观的摆在对象的内存中。
就像这个内存简图1中,咱们不要考虑顺序的对错以及内存对齐的情况,A实例化的对象a中一定有一个B的部分,C的部分以及A自己的成员变量memberA,自然而然,B的部分中是拥有类B的成员memberB的,C的部分中也有memberC。这样说并不标准,但是意思大致如此。

如此的内存是最基本的继承体系,但如果某一个继承的类被用virtual修饰了的话,情况就不一样了,被virtual修饰的类将不再以传统的形式存在在对象的内存中,而是【虚基指针】+数据的形式。

这个从【虚基指针】指向【B的成员部分】的画法并不准确,此处以及接下来我用这样的画法是为了简化一下咱们考虑的情况。对于真实的情况而言,其实是【B的虚基指针】指向的【虚基表】记录了【B的成员部分】在类内存中的位置。在下面的一段中这个区别会有所体现。
假如class A中的B是虚继承来的,那么A对象的内存中就会出现一个【虚基指针】,这个【虚基指针】指向一个【虚基表】,而【虚基表】则以某种数学手段记录着【B实际的成员部分】存放在内存中的哪个地方。【虚基表】记录成员数据具体位置的数学手段无关紧要,这个东西编译器说了算,它可能维护一个指针,也可能维护一个偏移offset,一般来说是后者。但是总的说,【虚基指针】的存在能够提供【该虚类产生的对象数据实际存放在哪一部分内存中】的信息,进而让计算机找到实际数据部分。
在内存简图2的基础上,我们假设C也虚继承了B,于是就有了内存简图3。

C的部分中,除了拥有C自己的成员数据外,拥有一个B的【虚基指针】,这个【虚基指针对应的虚基表】记录了外面B的成员部分,最终导致该对象可能拥有复数个【虚基指针】,但实际数据只有一份。
!!!但是,这样理解并不准确!!!
负责任的说,我使用visual studio的调试功能打印一下如此设计的类的内存构造,可以发现它只拥有一个【虚基指针】,并不存在两个,而这部分之所以如此的原因我目前没有从权威的资料中找到说法,只能说有自己的理解。
将内存简图3的情况与代码1进行一下对比,有点类似,但代码1的内存结构能够明显的看到存在2个【虚基指针】。
我猜测一下原因:【虚基指针】是可以合并的。
毕竟,【虚基指针】指向了【虚基表】,【虚基表】存放着某个虚基类在对象内存中的【存储位置信息】,那么编译器就可以通过某种算法、某种规则来扩充【虚基表】的内容,最终让一个【虚基表】可以承载复数个【虚继承类对象的位置信息】,而不必在对象中通过添加复数个【虚基指针】来完成虚继承的实现。
要知道64位系统中,一个指针8字节,且内存分配【虚基表】的数量与【被定义的类】的数量直接相关(而且类不实例化为对象,也就不会分配虚基表的空间),【虚基指针】的数量却是与程序中产生对象的数量直接相关,扩充【虚基表】对内存的消耗要远远低于在每个对象里放置【虚基指针】的消耗。这个思想感觉与智能指针的设计思路有异曲同工之妙。
这大概可以理解为,所有虚继承的部分,都会产生一个坑,这些坑或是反应在【虚基表】里,或是反应在【虚基指针】上,编译器分析类的定义后,确定了该对象虚基类的成分,在分配内存的时候保证被virtual修饰过的父类在内存中只占有一份拷贝,而整个继承体系下该父类出现了多少次则会由【虚基指针】和【虚基表】的内容共同体现。
假如,在内存简图3的基础上,C直接继承自B。就会产生大致如内存简图4的情况。B的成员部分重复出现在了A实例化的对象中,这也是代码3所产生的问题,即针对一个类,菱形的两个分支父类一个采用了虚继承一个采用了直接继承所造成的内存重复问题。不过如果你设计出来了这样的代码,编译器大概率会给你一个警告,亦或是在编译时产生错误。

回过头来看代码2,因为A_2虚继承自l1和r1,则A_2内的【虚基指针所指向的虚基表】会承载l1和r1两个对象的偏移,【虚基表】的信息各自引导向r1和l1的实际数据位置,而r1和l1各自的内存里,就存在着互为重复的Base部分,毕竟,virtual修饰的不是Base,而是A_l1和A_r1。
文章的最后,让我们看最后一个情况。
它的继承体系如下所示:

在该继承体系中,l1虚继承了Base,而r1没有,可是最下层的子类A_2则虚继承了r1。再这样的情况下,从r1到A_2的Base并没有被直接使用virtual修饰,那么A_2中的内存会是什么样呢?
在这样的情况下,GCC编译器能够成功的编译,而MSVC编译器则报出了对A_Base访问不明确的错误。

无论是MSVC的报错还是GCC的输出都很明显的看出,A_Base在A_2中有两份拷贝,也就是说,通过virtual修饰【子类】并不能保证【父类】在【最终子类】中只存在一份拷贝。
让我们看一下visual studio对A_2类输出的内存图。
我来为各位简单解释一下,首先这个内存里只有一个【虚基指针】(vbptr),编译器依旧使用了【某种咱们暂不需要考虑的算法和机制】将两个虚基类的信息合并在了一个虚基表上。这一个vbptr指向的【虚基表】记录着A_Base和A_r1两个虚基类的位置信息。
用图来画大概是这个样子的:

这个问题并非不好理解,我记得好像是Effective系列的某本书上有提到过,让我们尽可能的不要在类内定义【类型转换操作符】,在那里有体现过编译器面对类型转换操作符的一些机制,就比如如下代码:
在main函数中的函数调用中,SomFuncB的调用没有问题,而SomeFuncC的调用则会产生错误。那是因为当传入参数为TypeA类型的a时,编译器会查看类内TypeA的定义中有没有【类型转换操作符】的定义,它拥有转向TypeB的定义,则SomeFuncB的调用便可以成功,可是SomeFuncC不行,哪怕从咱们的角度上说,TypeA转向TypeB后,TypeB能够转向TypeC,但是编译器无法进行二阶的类型转换。
想想看,这其实完全可以理解,如果编译器能支持多阶的类型转换操作,那么意味着编译器的实现会产生一个可以无视展开层数的递归逻辑,这固然会降低编译器在编译时的性能。哪怕这个递归逻辑最终实现了,但是在你的目标代码里,也会存在一个庞大量的递归函数调用,让目标程序的性能大打折扣。所以自然而然,细化这部分的设计的责任理应落在程序员的身上而非编译器上,否则就会加重C++历史上的一个通病:编译器干了很多程序员不知道的事情,而这更是绝绝对对的负面操作。
好,让我们回过头来看虚继承这部分,编译器的设计与【类型转换操作符】其实是有异曲同工之妙的,即编译器只保证有【virtual直接修饰的类】不会重复出现在【最终子类】的内存中,并不能保证【virtual直接修饰的类的某一个父类】不会重复出现在【最终子类】的内存里,否则那也是需要在编译器中实现一个递归查看【虚继承类中父类】的功能,这不难想象是一个低效的解决方案。
到此为止,有关虚继承的讲解就结束了,在这篇文中我书写了我自己对于虚继承机制的理解,我不敢百分百的确定说它是准确的,我建议各位也可以通过代码以及visual studio的调试工具自己做实验,我欢迎各位与我一同沟通和辩论。
当然,在虚继承的体系上,如果考虑到多态和【虚表】,内存的模型便可能更加复杂,在没有实际准备着一部分的情况下,我就不单独展开了。但其实,【虚表】与【虚基指针】的存在我认为是可以分开来看待以简化相应的内存模型的,如果有一天我有灵感,我再尝试着书写这一部分的内容。
十分感谢您的阅读,欢迎您在评论区留下自己的注解与感受,祝愿您在新的一年里生活更加美好,朋友们,我们有机会再见。