在C++语言中,我个人认为有没有掌握好这门语言关键你对虚函数和多态这两个话题理解多少。这两个问题要是回答不上,显然,说明你对C++的掌握还处于比较初级的阶段,另外,对于虚函数和多态的掌握还意味着在实际的开发过程中,你能否最大发挥C++面向对象编程中的多态技术,写出高扩展性,以及耦合度比较低的优质代码。在今天的分享中我们就深挖一下C++背后的秘密。
首先,我们可以思考这样几个问题,一个是虚函数表,虚函数表指针的概念是什么?另一个是当C++采用面向对象的程序设计方法时,面向对象的三大特性:封装、继承,多态中的多态特性是如何体现的?具体的实现细节是什么?
我认为,要想正确地回答前面提出的这些问题作答者必须要熟悉这几个概念。
虚函数
虚函数表
虚函数表指针
虚函数表在支持多态方面的工作原理
接下来,我将从这几个方面讲一下,首先我们观察一下虚函数引入之后类会产生什么变化,接着就会引出虚函数变的生成时机和生成原因,然后再讲一讲虚函数表指针被赋值的时机,当这些重要内容都说完以后就要看一看类对象在内存中的布局了。最后,把这些知识点串起来,深谈一下虚函数的工作原理和多态性的体现,相信当你了解清楚以后,关于虚函数表和多态的话题你也就有了进一步的理解和认识了。
首先我们来看第一个问题,观察一下虚函数引入后类会发生什么变化。
我们先来创建一个空类A,然后创建一个这个类的对象小a,计算一下对象a的sizeof值,可以看到,对象a的sizeof值是1,这里呢,千万不要以为一个空类对象的sizeof值是0,一个对象只要它占用内存空间,那么这个对象的sizeof值至少是1。哪怕是空类它也至少是1。
接下来,我们继续向类A中加入两个普通的成员函数func1和func2,再次执行程序:
这个时候发现这个对象a的sizeof值仍旧还是1,这说明这个类A的普通成员函数它并不占用类对象的内存空间。
接着我们继续再向类A中放入一个虚函数vfunc,这时候再次执行程序:
我们就会发现对象a的sizeof值突然变成4了,这里要额外说一下,我采用的是Visual Studio中的x86解决方案平台进行这些代码的演示,可能很多朋友问,在Linux下用GCC编译运行之后看到的结果值是8,不管这个sizeof值是4还是8都没关系,道理都是一样的,我们就以sizeof值为4来说明了,要想弄明白sizeof(a)的值为什么从1变成了4,我们可以分析一下:
其实正是由于这个虚函数vfunc的加入引起的sizeof值从1变到4,当一个或多个虚函数加入到一个类中之后,编译器会向类中插入一个看不见的成员变量,在类中,这个看不见的成员变量类似是下面这样的伪代码。
那这个看不见的成员变量有一个名字叫做虚函数表指针它的英文名称叫做Virtual table pointer简称vptr。
这个虚函数表指针正好是4个字节,那么这4个字节是占用类对象的内存空间的,因此sizeof(a)的值变成了4,等一会画对象布局图的时候大家可以看到虚函数指针的位置,这个不用着急,通过观察这些代码我们能够发现,虚函数引入后类会发生一系列的变化,比如说刚才的sizeof值改变了,这个其实就是属于C++对象模型知识的一小部分。
下面我们再来看第二个问题,咱们说一说虚函数表的生成时机和生成原因,刚刚在类A中增加了一个虚函数,那么,当类A中存在至少一个虚函数的时候,在编译期间编译器就会为类A生成一个虚函数表,这个虚函数表的英文称Virtual table,简称vtbl。
这个虚函数表会一直伴随着类A,后面也会把虚函数表一起绘制出来,在经过编译、链接直到生成一个可执行文件后,这个类A以及伴随类A的虚函数表都会保存到这个可执行文件中,在这个可执行文件执行的时候,也会被一并装载到内存中来。
了解了虚函数表的生成时机和生成原因我们再来看第三个问题,说一说虚函数表指针被赋值的时机,这里大家要区别开来,我们现在已经知道了虚函数表指针vptr,也知道了虚函数表vtbl,那么是时候谈一谈虚函数表指针vptr与虚函数表vtbl之间的关系了。
那么对于这种有虚函数的类A在编译的时候,编译器会向类A的构造函数中安插为vptr赋值的语句,这个是在编译期间做的,是编译器默默在背后为程序员所做的事情。
这里可以写几行伪代码,大概是这种感觉,程序运行起来之后,当创建一个类A对象的时候会执行类A的构造函数,因为构造函数中有给vptr赋值的语句,从而就能够使vptr指向类A的vtbl。
当然如果程序员没有书写自己的关于类A的构造函数的话,这个时候编译器就会默默地为程序员生成一个类A的构造函数,并会默默地在这个构造函数中安插vptr赋值的语句,只不过这种动作是背着程序员进行的,程序员看不到这个构造函数。
当这些概念都说完之后我们就看下第四个问题了,类对象在内存中的布局。当生成一个类A对象的时候,我们可以看到这个对象在内存中长什么样,是一个什么样的布局,这个布局可以帮助你进一步了解对象在内存中长什么样。目前,咱们现在这个类A里面的数据太简单,为了进一步说明问题,我向类A中又增加了两个虚函数vfunc2和~A。
~A是析构函数,它可以是一个虚函数,那么我就增加了两个虚函数vfunc2和~A,然后我再增加两个成员变量m_a和m_b,这样完整的类A代码就呈现出来了。当生成一个类A对象的时候,就可以看到类A对象在内存中的布局了。
可以看到,左侧就是类A的对象,这个内存布局中,编译器会向其中插入一个虚函数表指针vptr,当然还有成员变量m_a和m_b都是占内存的,并且这个vptr编译器会将这个vptr指向类A的虚函数表vtbl。这个是跟着类走的,现在vtr指向了vtbl,这个vtbl中包含三个指针,这三个指针分别指向三个虚函数vfunc、vfunc2以及~A,这个中间以及右侧虚函数表以及虚函数还有func1、func2这两个普通成员函数它们统统地属于类A的组成部分,并不占用l类A对象的内存空间,得到的值现在大家想想应该是多少?虚函数表指针是vptr,它是占4个字节,在x86平台,然后m_a和m_b各占4个字节,加起来应该是12个字节,这个才是对象a的sizeof值。
最后我们把这些知识和概念串一串,说一下第五个问题,也就是要深谈一下虚函数的工作原理以及多态性的体现。
提起多态性这个特性,凡是对C++有一定掌握的朋友都知道,简单来说,就是父类中有一个虚函数,子类中也有一个同名的虚函数,当通过父类指针new一个子类对象或者是通过父类引用来绑定一个子类对象的时候,如果用这个父类指针来调用这个虚函数,那么调用的其实是子类的虚函数,这个是常规的对于多态性的理解。
这里我再深入讲一下,关于多态性这个概念,可以分两个方面来说,分两个方面来说比较容易说清楚。
第一个就是从代码实现上来说;
第二个就是从表现形式上来说;
首先不管从代码实现上还是从表现形式上,我认为有一个观点是大家都比较认同的,就是说到多态必须要有存在虚函数,没有虚函数绝对不可能存在多态,那么类中定义了虚函数,并且我们要调用这个虚函数,那么才存在多态性的可能,仅仅是可能。
首先我们就从代码实现上来看一看多态性的体现,当调用一个虚函数的时候,可以看一下调用路线,什么调用路线呢?是不是利用vptr找到vtbl,然后通过查询vtbl来找到这个虚函数表入口地址,并去执行这个虚函数。如果调用这个虚函数的路线走的是这个路线,那么就是多态,如果调用虚函数走的不是这个路线,而是像调用一个普通函数一样直接调用,那么就不是多态。
所以从这个角度来讲就不用管有没有继承关系,也不用管有没有子类,现在我们一起来看一下有一个Base类,看看这个Base类以及对这个类的里边有一个虚函数叫myvirfunc,看一看对这个虚函数的调用代码,我们看代码,代码比较简单,生成一个父类对象调用这个myvirfunc,大家充分地看一下这个注释,那么第一个这是不是多态,第二个呢,直接生成一个Base类的对象,调用myvirfunc这个是不是多态。这个不是多态,虽然调用的是虚函数但是它不是多态,第三个ybase,这也是一个父类指针,那么它调用myvirfunc这个也是多态,那么这个代码中如何判断调用的这个myvirfunc这个虚函数,如何判断调用的是不是多态?我的判断理论依据其实是对应的汇编代码,这里就不再一一展示对应的汇编代码了。
使用Visual Studio开发的朋友可以在范例中调用虚函数代码行的位置,设置一下断点并对程序进行调试,当程序执行流程停到断点行的时候,你切换到汇编代码中,在汇编代码中我们就可以看到系统是如何对虚函数进行调用的了。跟普通函数你比较一下,肯定是完全不一样的。
好,另外我们在从表现形式上来看一看多态性的体现,那么要想从表现形式上看一看是否是多态,在写代码的时候就要遵照几点;
第一点就是程序中既要存在父类也要存在子类,父类中必须含有虚函数,而且子类中也必须要重写父类中的虚函数。
第二就是父类指针指向子类对象,或者父类引用去绑定,或者说去引用,或者说去指向子类对象都可以。
第三点是当通过父类的指针或引用来调用子类中重写了的虚函数时,就能看出多态性的表现了。
也就是说最终会发现调用的是子类的虚函数,接下来看一下示例代码:
可以看到在这些代码中对应虚函数myvirfunc的调用全部都是多态的,休息代码中的注释,注释好像比较详细,包括这个父类的指针子类的对象,父类的引用绑定或者指向子类的对象,在这个范例中都有演示。
我们来想一想,当有继承关系存在的时候,父类对象和子类对象它们的内存布局以及父类与子类的虚函数表又是个什么样子呢?这里抛开前面的代码,我们现在在这里重新做个假设,假设父类Base有f、g、h三个虚函数,子类Derive中重写了父类Base中的g虚函数,注意啊,重写了Base类中的g虚函数,这个时候,Derive类对象和Base类对象它们的对象布局以及Derive类和Base类的虚函数表,那它们应该是什么样呢?
那么就是这张图,咱们可以看一下,左边左上角它是一个Derive类的对象,那么左下角是一个Base类的对象,因为有虚函数存在,所以编译器就会向每一个类对象中插入一个vptr,然后Derive类对象它指向的是Derive类的虚函数表。Basr类对象它指向的是Basr类的虚函数表,这个虚函数表是归属于类的,然后这个Base类也就是这个父类它有三个虚函数f、g、h。
那么这个子类Derive它其实也有三个虚函数对不对,它也有从父类继承来的f、h、当然它也有g,但是g呢,它是重写了g这个虚函数,覆盖掉了父类的虚函数g、所以大家看,这个粉色的中间这个g,它就指向自己的虚函数,那么其余两个这个类Derive函数表的其余两个指针指向的分别是父类的f虚函数和父类的h虚函数这种布局关系。
好,这篇的分享到这里就结束了,相信你对虚函数的工作原理以及虚函数的执行过程都会有一个更深入的认识。
作者:TTcome
声明:转载此文是出于传递更多信息之目的。若有来源标注错误或侵犯了您的合法权益,请作者持权属证明与本网联系,我们将及时更正、删除,谢谢。