在开始我们的内容前,首先让我们看一道面试题,题目如下:
说出下段代码的输出:
class A { public: virtual void g() { cout<<"A::g()"<<endl; } private: virtual void f() { cout<<"A::f()"<<endl; } }; class B:public A { public: virtual void g() { cout<<"B::g()"<<endl; } private: virtual void h() { cout<<"B::h()"<<endl; } }; typedef void(*Fun)(void); int _tmain(int argc, _TCHAR* argv[]) { B b; Fun pFun; for(int i=0;i<3;i++) { pFun=(Fun)*((int*)*(int*)(&b)+i); pFun(); } };
思考几分钟,看一下程序输出:
如果你有疑问,或者不理解,我们就开彻底分析下这个程序。首先简单讨论一下虚函数在内存中与Class的关系。
4.1 虚函数的维护
带有一个虚函数的Class,以下3个操作会在编译期间发生:
1. 一个虚函数表(vtbl)会被编译器产生出来,内部存放Class的虚函数地址。(虚函数表是以Class维护的)
2. 每一个Class Object中,一个额外的虚函数表指针(vptr)会被编译器合成出来,内含vtbl的地址。(vptr是以Class Object维护的)
3. 对虚函数的调用会被改写,比如b.f(),(f()为虚函数)会被改写成:(*b.vptr[1])(&b),其中1表示f()在vtbl中的索引,&b代表调用f()的this指针。(可以看出虚函数的调用是需要以间接调用完成,效率相对普通成员函数要低)
此外带有一个虚函数的Class的默认合成构造函数以及拷贝构造函数不再是trivial,他需要为每个Object的vptr设定初值,使其指向适当的vtbl。
扩展:同理当一个Class直接或间接继承一个virtual base Class时,也不再表现“位逐次拷贝语义”,默认构造函数和拷贝构造函数也不再是trivial,因为需要正确的设置virtual base Class的偏移。
注:如图:两个B的对象之间或两个D的对象之间调用拷贝构造,“位逐次拷贝”不会发生问题,但是用D的对象给B的对象赋值(此时造成切割)就必须调整vptr和virtual base Class偏移。
4.2 虚函数表中的内容
那么虚函数表中的“虚函数”都包括哪些呢?总共有以下三类:
(1) 被当前Class覆盖(override)的base Class中的虚函数
(2) 继承自base Class的没有被override的虚函数。
(3) 纯虚函数(当前类定义的,或者从base Class继承来的)
4.3 虚函数的布局
说了这么多,我们基本可以推断出程序中Class A,Class B的内存布局情况,如下图。注:Type_info以后讨论,这里先忽略。
(a)Class A内存布局 (b)Class B 内存布局
从上图可以看出A,B的对象中没有数据成员,只有一个vptr,这个我们可以输出验证:
cout<<sizeof(A)<<" "<<sizeof(B)<<endl;(4,4)。
注:虚函数在vtbl中的顺序和虚函数在Class中的声明顺序一致。
4.4 pFun=(Fun)*((int*)*(int*)(&b)+i);
相信不少人对程序中这条语句都有迷惑,下面我们来逐步分析下这条语句的含义。
首先,由程序中typedef语句我们知道Fun是一个函数指针,也就是这句话是要将某个地址转换成函数地址(指针)。具体步骤如下:
(1) (Fun)*((int*)*(int*)(&b)+i)----------------------------取对象b的地址,由对象的内存布局我们知道对象b的地址和vptr的地址相同,即取vptr的地址。
(2) (Fun)*((int*)*(int*)(&b)+i)----------------------------将vptr的地址强转成int型地址(指针),即让编译器将vptr的地址当做int型指针对待。否则*(&b)会被当做对象b。
(3) (Fun)*((int*)*(int*)(&b)+i)----------------------------得到vptr地址中存放的内容,即vptr(或者说vptr的值,或者说vtbl的地址)。
(4) (Fun)*((int*)*(int*)(&b)+i)----------------------------将vtbl的地址转为int指针,为之后与整型类型i相加做准备。
(5) (Fun)*((int*)*(int*)(&b)+i)---------------------------- 我们知道指针与整数的相加,移动的是指针所指对象的大小,因为上一步已经转换为int型指针,所以会移动i*sizeof(int)个字节。
(6) (Fun)*((int*)*(int*)(&b)+i)-----------------------------其实上一步就是将指针移动到vtbl中相应表条目处(存放虚函数的地址),所以这里取出地址的内容就是对应虚函数的地址。
(7) (Fun)*((int*)*(int*)(&b)+i)------------------------------最后这一步将函数的地址转换成函数指针,以后后面调用。
综上,我们可以知道这句活就是根据i的递增逐个调用B中的虚函数。而B中的虚函数布局我们已经知道,所以输出就不难理解了。
注:通过这个例子我们还发现了一个问题,函数f()在基类A中是私有的,而我们却访问到了。其实我们将B中的h()声明为private,输出结果依然不变,并不会引起访问权限问题。也就是说可以外界访问到Class的私有虚函数。这是为什么呢?我个人的理解是:访问限定符只在编译检查时候起作用,而在程序执行期间没有作用,因为从C++的函数名称修饰规则来看,并没有将访问限定符纳入其中,所以我们只要通过了编译,找到对应的函数地址就能够调用私有函数,因为在内存中私有函数和公有函数并没有什么区别。