虚继承是什么意思_闲话C++ (5) ——虚函数:一切梦想和野心开始的地方

闲话系列差不多两年没更新了,很多同学问小杜老师下一篇闲话系列的公号文章啥时候出。说实话小杜老师也想更新,可是整个去年的时间真的是忙到要哭了。所以,这个闲话系列就被放下了很久,而另一个重要的原因是,即将要写的这一段,也是整个闲话系列的文章计划中,大概最难读的一篇。而最近这段由于肺炎疫情,小杜老师也被禁足在家,在远程讲C++课以及哄神兽的间隙,确实还可以在深夜有点时间,所以就慢慢的开始更新这个差不多应该是闲话系列中空前绝后最玄乎的一篇。

开篇仍然是万年不变夜间开车原创声明哦~

小杜老师夜间出品,均属原创精品哦。转载请注明出处,欢迎分享关注发票圈哦。同样欢迎各位前辈同行朋友批评指正,因为写的仓促,并且写的时候小杜老师很困很疲劳,所以搞错事情简直无可避免。

虚函数,这是个在一般的大学C++教材中会说到,但是通常不会说太多的概念。然而,这个概念的关键程度,在一个学生成长为程序员的过程中,是至关重要的。尤其是虚函数不仅仅是一般教材上所说的:实现了函数的动态绑定这么简单。虚函数这个机制,实现了将本来在编译时期应该进行的一系列工作,延迟到了程序运行时期进行,所以它也叫延迟绑定。而这个延迟,就顺带产生了把本来应该在程序员机器上编译过程中实现的调用绑定,转移到了用户机器上在运行过程中进行绑定。而再借用操作系统提供的动态链接机制,就允许程序员把函数的调用和函数本体分离,并且可以以二进制的形式分别部署给用户机器,从而实现"动态类装载(DynamicClass Loading)",将面向对象的代码复用从源代码层次推进到二进制层次。而动态类装载只要再向前走很有限的几步,就可以得到组件对象模型(Component Object Model, COM) 的原始版本了。组件对象模型虽然现在看来已经很老了,但现代软件的部分更新机制,发布后的热修复机制,动态链接库的二进制版本兼容,各种可以运行期装入的插件,还有图形用户界面上的各种控件,很大程度上利用了以COM为基础的各种后续技术。这就是为啥小杜老师要说虚函数是一切梦想和野心开始的地方。可以说,虚函数这个概念连接了教科书上的看上去没啥用的那些内容和生产环境中的现代实际软件开发技术。

但是,今天这篇闲话,要说的是虚函数的调用机制,这吃水可不是一般的深。读过以后,希望不要觉得太过硬核。

小杜老师相信,能看到这一篇的读者,必然是都已经学过了虚函数的,因此必然是知道如下的几个基础知识点的 (1) 虚函数定义在基类里头,需要加上virtual关键字修饰;(2)虚函数在继承基类的子类中可以被覆盖;(3) 使用基类类型的指针发起对虚函数的调用的话,是根据这一指针所指向的对象的类型去调用对应类型中的虚函数覆盖;(4)如果不使用虚函数,则上述调用只会调用基类中对应函数,而与指针实际指向对象的类型无关。(5) 如果定义虚函数的时候,只声明,不实现,还要尾巴上加“=0” 则这个函数叫做“纯虚函数”,这会导致它所在的类成为抽象类,抽象类不能被实例化。

但是,少有C++程序设计的基础教科书里头会仔细的告诉你:(1)为啥加了virtual和不加virtual有这些区别?(2) 这些区别会对对应的对象的实际存储内容有啥影响?(3)在程序运行过程中,基类类型的指针是怎么“知道”它指向对象的类型的?(如果你上小杜老师的课,就会知道程序运行时期,光看对象本身的话,其实是木有类型信息的哦) 以及,如果你看了小杜老师的上一篇闲话C++的话,问题就会是:虚函数的调用,到底是如何发起的,函数的入口地址是怎么被找到的呢?

首先,先来看看这段教科书级别的程序:

9f0e2dbb99b6a369b7e06fdc106ca6e6.png

程序中有一个基类Base,两个子类Hi和Hello,均公有继承并覆盖了Base中的虚函数Disp。主程序分别定义三个对象,一个指针,根据用户输入,让指针分别指向不同对象,调用虚函数。程序执行结果与教科书例题和条款完全一致。

如果你仔细看了小杜老师上一篇闲话,你就会知道GNU g++编译器对成员函数的调用实际上是用了GNU thiscall调用约定,这玩意跟cdecl约定类似,只是偷偷的塞进了一个this指针。而具体到调用哪个成员函数,那是根据发起调用对象的类型在编译过程中决定的。第36行的这个成员函数调用,要不是因为Display是个虚函数的话,编译器只能根据v_obj的类型,去产生对基类Display函数的调用。然而,由于虚函数的机制,Display函数的调用实际上利用了在运行时期才得到的信息,根据用户输入,调用不同子类的Display函数。那么编译器究竟在第36行做了怎样的处理,居然能够根据运行时期的信息产生函数调用?要知道:(1) v_obj指向的对象的实际类型,在编译的时候,是不可知的,这依靠运行时期的输入;(2) v_obj只是个基类指针,并且它的类型信息在运行时期也已经丢失了;(3) 无论是基类虚函数还是子类的覆盖,其函数签名完全一致,无法用于区分调用,这跟重载不是一回事。因此,剩下的唯一的可能是:C++编译器在对象里头偷偷塞进了一些东西,从而让程序运行的时候,根据这些信息进行函数调用。然而,我们知道对象在内存里头,应该只有数据成员才对,那么,这是编译器偷偷增加了数据成员吗?差不多,这个C++编译器“偷偷塞进去的东西”,叫做虚表(Virtual Function Table)指针,它指向内存中的一块“神秘区域”,那块区域被叫做虚表

那么,虚表在哪里,我能看见它吗?对象里头的虚表指针长啥样呢?虚表里头放的都是什么东东呢?为啥有了虚表,基类指针就在运行时期知道自己指向啥类型的对象了呢?

为了解决这些问题,小杜老师先送上两把大刀,用来对运行时期的对象,进行二进制级别的“解剖”。前面三个类不变,只增加/修改如下内容:

db85dfab5dfdec03b7730efe6a370fca.png

fetchData和fetchArray这两个模板函数就是可以在运行时期,直接把某个地址上的二进制值作为给定类型的数据返回。相信凡是上过小杜老师的C++课的同学,那是很容易看懂这两个函数是啥意思的,因为这是某次作业题标准答案的模板化版本啊(是不是有些同学已经开始后悔选课了,欢迎明年再见哦~)。基本原理都是利用C++中指针类型差不多可以随便用reinterpret_cast来回转换。然后利用模板的能力,利用行指针的能力,利用返回数组引用的函数方法。

而main函数中首先输出了Base类型的长度,8个字节(这是32位程序哦),所以,除了数据成员x,一个整数之外,还多出了4个字节,这4个字节,就是 Base类的虚表指针。而输出结果也明白无误的指出,Base类对象的头四个字节是一个指针,而后四个字节是数据成员x,值为15。那么,要是多来几个Base对象会怎样呢,试试就好了:

32eb76966674fe5d778e4b42ecd305ee.png

很明显,不同的Base对象中虚表指针,是一样的,但是数据成员,是不一样的。就此可以看出,虽然虚表指针被每一个对象都自己存储了一份,但是他们取值相同,因此,不同的Base类的对象,使用完全相同的虚表。相对于一般的非静态数据成员,在不同的对象中不同,虚表是由类决定的运行期静态数据。

那么,这个虚表的地址上,究竟放着什么东西呢?按照Wikipedia(https://en.wikipedia.org/wiki/Virtual_method_table)的说法,虚表里面放着的,应该是虚函数的入口地址了。请看下面的程序(小杜老师顺带整理了一下代码,以便让图片变得更短)

841170e9e744c576b91eae1e1d80bf35.png

从结果来看,我们猜对了,也就是说虚表指针所指的位置上,有两个连续的指针,而这两个指针的值,恰恰是Display和f1两个基类函数的入口地址。这似乎看上去很完美哈。目前能从网上找到的绝大多数论坛博客以及绝大多数涉及到虚表的C++教材,基本上说到此为止的,然后就要进入子类如何改写虚表,然后编译器如何利用虚表里的地址值,产生对应的函数调用了。不过,在此之前,有一件非常值得担心的事情,那就是41和42两行报出了两个诡异的warning。

a20cc12b60cde6651d0d4f56e6cffc2c.png虽然最后编译通过了,但是这两个warning非常令人担忧啊,因为我们用的是reinterpret_cast,这玩意转换指针类型,为啥会有warning呢,指针类型,就是个地址,这玩意总应该是二进制兼容的吧?为了解决这个问题,小杜老师做了个小小的实验,输出了成员函数名类型的sizeof和对应的void*的sizeof值: c03756e20f4d52813afd169b14db9a24.png成员函数名类型的长度居然是8,而同时void*的大小是4!这是个足以把认真看懂了上一篇闲话C++的读者吓出噩梦的结果。这也就是说, 成员函数的地址,需要8个字节存储,而并不是一般指针的4个字节。因此,成员函数名类型并不是一个简单的地址。并且可以猜测:成员函数指针的长度,跟一般指针的长度,也不会是相同的。的确是这样的: 9cc8683d1fe5657008fd56dc22c5ac63.png从这个结果里看:一般的函数指针类型,长度确实是4,而成员函数指针类型,长度确实是8。也就是说, 成员函数指针类型和一般函数指针类型并不是二进制兼容的类型。而且这件事情在C++标准里并没有给出任何明确的规则。直接把成员函数指针给转成void*甚至一般函数指针这个操作,正如小杜老师在上一篇闲话C++中说的:那是相当的如履薄冰的。说到这里,我们刚才用reinterpret_cast转换出来的void* 就不一定是这个成员函数名中的信息了,最多只是其中一部分信息。但另一方面,fetchData和fetchArray这两个函数并不会报错,也没有warning,因此虚表数据的读取应该是正确的。而虚表中的函数入口地址值确实是4个字节的,而且是连续存放的,中间没有其他信息。从虚表的数据来看,确乎是成员函数名转成void*的结果就是虚表里的地址。那么,成员函数名(成员函数指针)这个类型为啥要有8个字节呢?多出来的4个字节是用来干嘛的?可以肯定的是,C++编译器在这个地方又偷偷做了“邪恶”的事情了,并且,这种“邪恶”那是相当的厉害: e6aed4e2b79342298b0d1a6ce4181a84.png上面这段程序小杜老师第一次写出来的时候,面对这个结果几乎抓狂了。我们仅仅是把Base::Display这个符号的值用两种不同的方式显示出来而已啊。reinterpret_cast方式,以及复制到一个成员函数指针的方式,显示结果却完全不在一篇上啊!复制到成员函数指针之后,Base::Display实际变成两个整数了,一个1,一个0,而Base::f1更扯,一个5,一个0。最要命的是,这为啥跟reinterpret_cast不一样啊,为啥跟虚表内容毛都不沾啊?为了说清这个问题,就得回到这两种方式的显示机制上去说了。C++标准明确的解释了 reinterpret_cast的行为特点:它不产生任何CPU指令进行类型转换。reinterpret_cast的转换是在编译时期完成的,因此,42,43两行的输出,在运行时期实际输出的是两个void*类型的常量值。然而,赋值这个事情则是在运行时期完成的,因此,45,46两行,要对等号右侧进行表达式求值,而对于含有成员函数名的这样的表达式,其求值规则并不是简单的返回地址。这两种输出方式的区别可以非常容易的用调试器的反汇编功能看出来。如果有读者学过汇编,只要在第43行和第46行给断点,然后反汇编出这段程序就能看出来了。为了不再进一步跑题,这里就不再秀反汇编图了。那么,这个结果究竟啥意思呢?以及,这跟虚函数有啥关系呢,小杜老师你是不是又跑题了。答案是:关系很大,请看下面的程序: 8465726dab68f34cf1172681909d31bd.png这段程序的代码跟上一张图一个字节都没变,但是,结果就很不一样了。程序的区别是,Base::f1的virtual关键字被去掉了。也就是说Base::f1目前是一个普通非静态成员函数,而不是一个虚成员函数。从结果来看,普通非静态成员函数指针虽然也需要8个字节存储,但是低位的4个字节确实就是函数入口地址,而不是其他看上去不着边的数字。就是说: (1) 在运行时期对于非静态成员函数来说,无论是否是虚函数,其成员函数名的求值结果都需要8个字节存储。(2) 如果不是虚函数,其成员函数名的求值结果低位4字节是成员函数入口地址;如果是虚函数,其成员函数名的求值结果低位四字节目前不知道是啥。高位4字节目前全是0。这里说不知道是啥,的确有点开玩笑,因为,这确实很容易看出来。虚函数的成员函数名求值结果的低位4字节的整数减去1,就是对应的函数入口地址数据在虚表中的偏移量。把virtual关键字放回去,然后用如下的代码,就能从复制之后的虚成员函数指针的值里,拿到实际的成员函数入口地址了,注意红框的部分: cba4a54a882c3be1e058b16e3b5183e7.png到目前为止,我们已经看到了虚成员函数和非静态普通成员函数在其指针类型和包含其函数名的表达式求值过程中的显著区别了,也看到了reinterpret_cast的作用,同时还知道了成员函数指针和普通函数指针的区别,再加上上一篇闲话C++中讨论过的函数调用约定的知识,我们可以大概猜测,虚函数的实际实现机制是:编译器对于每一个虚函数实际上都在虚表中占用着一个固定位置,而每一个子类,要继承基类的虚表,并产生自己的虚表,如果发生了对基类虚函数的覆盖,那么就从自己的虚表上对应的位置上写入它自己的函数入口地址。而每一个虚函数的调用,都要先去查找发起调用的指针所指对象的虚表,找到虚表中对应的那个位置,读出那个指针,然后用那个指针作为成员函数入口,进行函数调用。这样对于所有的虚函数调用,编译器就可以产生相同的调用代码,而让调用的具体函数在程序运行时期去决定了。那么,我们可以从子类的情况,验证一下这个猜测。下面把基类和子类对象的内部信息全都输出出来看看。 9a611a70a7e675a314af6cb684623c73.png结果表明,不论是Hello还是Hi,这两个类实际上继承了Base的虚表,并且用他们自己覆盖的Display函数的地址,取代了继承来的虚表中的Base::Display函数地址所在位置的那个地址。综合前面的结果,对于以Base类指针发起的对于Display虚函数的调用,实际上产生的调用应该类似于如下的过程:(1) 从Base::Display的运行时求值结果,低位4个字节,获得其入口地址在虚表中的位置(下标f = (低位4字节– 1)/sizeof(void*));(2) 从发起调用的Base类指针获取其指向的对象;(3) 从其指向的对象中得到虚表指针vtable(这是一个指针构成的数组的首地址);(4) 采用vtable[f]的方式,获取子类覆盖的Display函数的入口地址;(5) 利用这个入口地址,发起thiscall调用,完成对虚成员函数的实际执行过程。但是,问题是,真的就只有这么简单吗?大多数现行的基础教材,即便涉及到虚表,也不会提及虚函数名字的运行时期求值的意义,而对以下的这个简单而又恐怖的问题,基本完全保持沉默,或者语焉不详。这个问题就是:在这种情况下, this指针在传递给子类覆盖的虚函数的时候,到底应该取啥值?(因为最后是用的thiscall约定,所以this指针必须入栈)如果仅仅考虑单继承的情况,就是任何类的基类都只有一个,那么在“继承链”上任何一个类型的对象,其地址都可以被赋给其继承链上的任何一个基类类型的指针(这就是赋值兼容规则所说的原话,看你的教科书去)。并且,更重要的是,这些基类类型的指针其取值是完全一致的(这就不是赋值兼容规则保证的事情了,这是单继承,以及 大多数编译器处理继承的时候实际上是把基类放在子类新成员的前头做连续的内存排布造成的,并且对于这种涉及二进制排布的事情,C++标准从来都保持最大限度的沉默)。因此,如果只有单继承,无论从继承链上哪一个基类类型发起调用,都只要把发起调用的指针直接作为this指针传递给子类中的虚函数覆盖就成了,因为那个指针的值,就是子类对象内存排布的起始地址。但是,不幸的是, C++支持多继承。就是说,一个类的基类,可以有好几个。这在虚函数这事上,就会把上面这个简单的this指针的计算方法搞得一团糟烂。为了说明多继承所带来的糟烂情况(这也是后续的很多语言如Java等等只允许单继承的一个重要原因),请看下面的程序: c1008f87a19418ae3eec1db8ad76a7cf.png这个程序中Hello类,有两个基类,一个叫Magic,一个叫Base,Magic和Base类都分别具有虚函数,以及一个数据成员。当我们把Hello,Magic和Base的大小输出出来以后,就会看到Base和Magic都是8字节长的,就是我们前面已经知道的一个虚表指针加上一个数据成员的大小。而Hello,则具有16个字节,注意到Hello除了覆盖了Base中的虚函数Display之外,并未提供任何新的数据成员,因此Hello在内存中的存储内容,完全是继承自Magic和Base。这看上去没啥大不了的对吧,继承基类的所有数据成员,把虚表指针当个数据成员就能理解了。问题是:我们前面所看到的所有的虚表指针,都是那个类的第一个数据成员,也就是虚表指针所在的位置,永远是对象本身的地址。当有两个虚表指针的时候,那么,这两个虚表指针究竟是怎么放进Hello里面的?我们继续看下面的程序: 21afbc261edd901d3e6e6e940c39f503.png我们用一个叫objectDump的函数(这是fetchArray函数的一个变种版本)把对象在内存中存储的数据,全部用void*格式输出出来,从数据成员很容易看到,Hello类的内存排布,就是先有一个Magic类,然后紧接着一个Base类。Base类和Magic类的虚表指针在Hello中并没被照抄下来。也就是说,现在内存里,有4个虚表,一个是Magic,一个是Base,还有两个虚表,是Hello继承了Base类和Magic类的同时,重新产生的。位于Hello类开始处的虚表指针,很容易理解它指向了Hello的虚表,这跟前面已经分析过的单个基类的情况完全一致。但是,中间处于+8偏移量处的那个虚表指针,是用来干嘛的呢?为了解决这个问题,先来回忆一下关于赋值兼容规则的相关内容,那就是Hello类的指针,可以直接赋值给Magic类和Base类,那么如果真的这么赋值了,这个指针的值会是怎样的情况呢?看下面的程序: 5ac64c04a52770538bfd8f256241b0ba.png程序中仅仅是对Hello对象h取了地址,然后把这个地址分别赋值给Hello,Base,Magic三个类型的指针,然后,奇迹发生了:三个地址输出出来以后,Hello类型和Magic类型值是一样的,而Base类型的指针则不同。换言之,当将子类类型的指针赋值给基类类型的指针的时候,指针的值,在二进制上是可能发生变化的。编译器可以利用赋值操作两侧的操作数类型,计算出满足赋值兼容规则的指针偏移量,从而在赋值过程中对指针值进行调整。所以,第60行的那个等号,必然不能只是把hp中的4个字节复制到bp中这么简单。但另一方面,这种变化在语义上是合理的,从输出结果上看Base类型的指针比Magic或者Hello类型的指针值+8,这个增加的数字,恰好是Magic类的大小。也就是说,从内存排布上看,Base类指针所指的位置,是Hello类对象中Base基类开始排布的位置。因此,从Base类的指针看过去,它所指的位置上,就是一个Base类的对象了。这一条“看上去就是”,同样适用Magic类,只不过它是第一个基类,和Hello类的开始排布位置是一样的。这虽然解决了前面,两个虚表是怎么放进对象里的问题。但是这两个虚表是否还和单继承的时候一样,遵守同样的规则呢?为了进一步搞清楚这两个虚表里头究竟有什么东东,我们把所有四个虚表内容输出出来看看 54a176b3e42d234f6748a2cb835290a0.pngMagic类和Base类的虚表,有了前面的实验结果,这是很容易理解的。但是,对于Hello类来说,却很值得琢磨,Hello的第一个虚表,继承自Magic,而Hello并未覆盖CastASpell函数,所以第一个虚表中的CastASpell函数在第一下标位置,其值和Magic类一致,都是Magic::CastASpell的入口地址。而这一个虚表,也是Hello类自己的虚表,所以其后紧跟的是Hello::Display的入口地址。而麻烦出在Hello类的第二个虚表上,这个虚表里面的第一个地址,按我们前面的实验结果,这应该是利用Base类指针调用Hello里面的Display函数的入口,因此,应该是Hello::Display的入口地址才对,而这里却出现了一个跟下面四个函数入口地址都不沾边的0x4b59f0,这到底是啥玩意呢?而其后的0x438818,也确实就是Base::f1的地址,这更说明了前面那个看着不着边的0x4b59f0应该是Hello::Display的入口才对啊。难道我们前面的分析有哪不对了吗?前面的分析其实是对的,只不过,现在得回到那个简单而又恐怖的问题上去了,那就是当用Base类的指针调用Hello对象中的Display虚函数覆盖的时候,this指针到底应该传什么进去?下面的程序通过修改了Hello::Display的内容,显示了它实际接收到的this指针值,以及调用方所具有发起调用对象的指针值: 2eb85fde870ee640f927a2ae90a21315.png这里的Hello::Display,不但输出了从Magic中继承的y,还输出了this指针的值。而这里的this指针的值是对象h的地址,并不是发起调用的bp的值。这是如何做到的呢?这点粗略看上去确实不是个问题,但是必须想到的是,Display是个虚函数覆盖,bp是个Base型的指针,在51行这个时候,编译器并不能得到bp所指的真实对象的类型信息,是无法知道正确的this指针和这个现有的bp指针之间到底差距是多少的。或者说,编译时期只通过bp是不可能了解bp所指的对象的实际类型的,也无法了解Base类作为基类到底是子类的第几个基类以及子类的其他基类的内存排布状态的。唯一有保证的事情是,bp所指的地方肯定看上去是个Base类型的对象。所以,编译器对于51行只能产生出对Base::Display的虚函数调用,就是只能利用Base::Display的运行期求值结果去获取在bp指针所指的Base类型对象中的虚表中的位置,从而获得函数入口地址。并且,也只能将bp自己的值作为this指针送进这个入口地址。因此,如果上述虚表中对应位置上真的是Hello::Display的地址的话,那么传入的this指针的值就是错误的。但另一方面,被调用方的任何从Base类继承了的类,是知道自己的结构的,因此,Hello类的编译过程中可以计算出,如果以基类Base类型的指针发起函数调用,那么传入的this指针到底应该从Base类的排布位置前移多少才能得到真实的this指针。所以,大部分现有的C++编译器在处理这个地方的时候偷偷干了件“邪恶”的事情,就是把覆盖的虚函数,例如Hello::Display进行了一次“包裹”,生成了一个小小的“包裹”函数,用来给覆盖虚函数的成员函数调整this指针。这个小小的函数一般被叫做 this指针的形参-实参调整函数(adjustorthunk for this pointer)。而这个小小的“包裹”函数的入口地址,就代替原来的虚函数覆盖,被填入了虚表里,覆盖了原来的地址了。而调用方并不需要知道这个情况,只要根据虚表内容进行调用,按照跟单继承一样的规则进行调用,就成了。因此,虚表中的函数入口地址,并不一定是真实的虚函数覆盖的地址,也可能是一个adjustor thunk的地址。前面所说的那个简单而又恐怖的this指针问题,到这里算是至少解决一部分了。但是,请看下面的程序: 46354f4bb595ec03856e69602e71e91c.png这个程序足够简单,请注意,我们用了Hello类型的指针,发起了一个对于f1函数的调用,请注意,f1函数在Hello中并没有覆盖,而是直接继承了来自Base类的f1函数。但是,这能有啥问题,这不是教材上说的就应该这样吗?参考前面的图,仔细把过程过一遍:发起调用的指针是Hello类型的,因此,这里实际上是用Hello::f1的做了虚函数调用,所以,我们需要找到Hello虚表里头的Hello::f1的下标,可Hello的虚表里头,根本就没有那个Base::f1的地址啊,Base::f1的地址在Hello继承的Base的虚表里头啊,不在Hello开头的那个虚表里头啊。这又是怎么回事呢?我们稍微做个简单的实验: 2de6c3df52ecaf400aec6e7baa05acf6.png我们把Hello::f1这个函数成员指针的求值结果给输出出来,神奇的事情发生了,原来总是0的虚函数成员指针的高位,现在是8,不是0。这8是啥意思呢?如果各位读者有兴趣,可以去把Magic类后面增加一个int型的数据成员,这个8还能变12。这是为什么呢?编译器对待Hello::f1的时候,会检查出Hello类并没有对f1进行定义(所以不能产生普通非静态调用),也没有对f1进行覆盖(所以不能用Hello类的虚表执行虚函数调用),f1是Hello继承Base类得到一个虚成员函数,因此,应该用Hello类中的Base基类部分所在的地址进行这个调用。也就是说,这个Hello::f1,并不能简单的用Hello类对象作为发起调用的的对象,得用Base类型的。但是,现在调用形式是Hello::f1,也就是说,发起调用对象指针指向的是一个Hello对象(或者Hello做基类的子对象),而不是Base对象,所有这些信息在编译时期都可在48行上得到,所以Hello::f1就提供了一个求值的高4字节的8,这个8是用来找到从Hello类中用于发起这个调用的基类的起始位置的。也就是说, 当从子类指针发起对继承自基类的而又未被覆盖的函数的调用的时候,调用方根据要调用的成员函数指针的求值,先行对发起调用时的this指针进行调整。这个调整,跟虚函数其实没关系。所以,综上所有,如果发起调用的指针类型是T*,名字叫t,被调用函数叫f()的话,也就是t->f()的形式的话,那么一个虚函数的调用会是这样的: (1) 运行时期求值T::f,得到结果的高4字节(H)和低4字节(L)。 (2) 将t 偏移 H字节,从(void*)t+H地址上获取指针vtable (类型可认为是void**) (3) 以vtable[(L – 1)/sizeof(void*)]作为入口地址,以(void*)t + H为this指针,执行thiscall调用。需要注意到的的是: (1) 当子类继承并覆盖了基类中的虚函数的时候,以上述方式获得的子类对象中的vtable[(L – 1)/sizeof(void*)]被对应的改写,从而实现对子类中的覆盖函数的调用。 (2) vtable[(L –1)/sizeof(void*)]并不一定是要调用的覆盖函数的直接入口地址,在多重继承的条件下,有可能是一个adjustor thunk的地址。 (3) adjustor thunk的作用是当多重继承出现的时候,以基类类型指针发起虚函数调用时,由被调用函数所在的对象执行对传入的this指针的调整。 (4) this指针可能会被调用方和被调用方同时调整。说了这么多了,不知道有没有读者还是觉得,这好像还是有哪里不太对劲对吧?是的,还有一个很不对劲的地方,那就是:面对一个t->f()调用,这不一定非得是对虚函数的调用对吧,如果f()不是虚函数,那么怎么办?这的确是个完美的问题。对于GNU g++来说,编译器做了一个巧妙的优化,那就是所有的成员函数的入口地址,都被对齐到偶数地址上,或者说,所有函数的入口地址都是偶数,二进制的最低位都是0。因此,对于前面提到的普通非静态成员函数,T::f的求值结果的低4字节就是f()的入口地址,一定是偶数,而对于虚函数来说T::f的求值结果其实是虚表偏移量+1,因为虚表偏移量从0开始,每4个字节一个元素,所以虚表偏移量+1必定是奇数。这就是为啥上面一定要vtable[(L– 1)/sizeof(void*)]。 上述的(1) (2)两步之间,还有一个补充判断步骤:如果T::f的求值结果低4字节是奇数,继续(2),对虚函数调用,否则,f根本不是虚函数,就跟虚表没关系了。好吧,那么现在结束了吗?远远没有,关于虚函数表和this指针调整,还有一个更大的坑,那就是因为允许多重继承,所以可以产生菱形继承,所以我们需要虚基类和虚继承避免公共基类带来的成员混乱,所以使得基类结构不再连续,从而不能简单的构造子类,并且只能在运行时期做复杂的this调整,以及这对于虚函数的调用过程所带来的问题。不过小杜老师不打算再接着写虚基类和虚继承的事了,这篇已经太长了(1万+字数了啊)。而且计划中后面的内容跟虚基类和虚继承也没啥关系。所以那个部分就看谁有兴趣自己去玩好了。大部分可用的探测分析工具都已经列在这一篇里面了。 另外,特别提醒的是,这一篇的内容绝大部分都不是C++语言标准所规定的行为,而是依赖特定编译器实现,甚至是特定的硬件体系结构的行为。小杜老师所用的编译器是GNU g++ 5.1的MinGW移植版,是在x86计算机Windows10系统上做的上面的实验。如果想要用微软的VC++之类的编译器复现这些内容,或者是想要在arm/arm64/mips等硬件平台上做这些实验,那可是很多都不行的哦。那么下一篇会是什么呢?这里稍微预报一下,下一篇中,我们将开始与操作系统功能进行交互,实现在运行时期,动态加载函数库,这是进行系统交互编程的一项重要基本技术,也是编写大规模软件无法回避的一个基础技术。也是对计划中最后一篇闲话C++文章内容的预热。所以,下一篇就叫: 闲话C++(6)——动态链接:召唤操作系统的“秘咒”
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值