问题一:
(1)一个类只有包含虚函数才会存在虚函数表,同属于一个类的对象共享虚函数表,但是有各自的vptr(虚函数表指针),当然所指向的虚函数表是同一个。
(2)父类中有虚函数就等于子类中有虚函数。只要父类中有虚函数,子类中即便不写virtual,也依旧是虚函数。
(3)不管是父类还是子类,都会只有一个虚函数表。不能认为子类继承父类,就认为子类的虚函数表就是,父类的虚函数表+子类的虚函数表。这样的想法是不正确的。子类就只会只有子类的虚函数表这一个表。那么子类中是否可能存在多个虚函数表呢?当然可以存在的,比如子类存在多个父类,然后这个父类也继承于另外一个父类(这个类中存在一个虚函数)。
(4)如果子类中完全没有新的虚函数,则我们可以认为子类的虚函数表和父类的虚函数表内容相同,但,仅仅是内容相同,这两个表在内存中处于不同的位置。换句话来说,这是内容相同的两张表。虚函数表中的每一项,保存着一个虚函数的首地址,但如果子类的虚函数某项和父类的虚函数表某项代表同一个函数。(这表示子类没有覆盖父类的虚函数)
(5)超出虚函数表部分内容不可知。
问题二:
(1)虚函数表指针(vptr)是什么时候创建出来的?vptr跟着对象走(虚函数表跟着类走),所以对象什么时候创建出来,虚函数表指针就什么时候创建出来。实际上,对于这种有虚函数的类,在编译的时候,编译器会往相关的构造函数中增加为vptr赋值的代码,这是在编译期间编译器为构造函数所做的事情。(这个就解释了为什么构造函数和析构函数中不能发生多态,因为在执行构造函数的时候,对象没有诞生,虚函数表指针也没诞生。在执行析构函数的时候,虚函数表指针已经被摧毁,所以更不可能发生多态。)
(2)虚函数表是什么时候创建的?虚函数表是编译器在编译期间(不是运行期间)就为每个类确定好了对应的虚函数表vtbl的内容。
问题三:
下面我们来看一个程序例子。
#include <iostream>
using namespace std;
class X
{
public:
int x;
int y;
int z;
X() //:x(0), y(0), z(0)
{
memset(this,0,sizeof(X));
cout << "构造函数被执行" << endl;
}
X(const X& tm) //:x(tm.x), y(tm.y), z(tm.z)
{
memcpy(this,&tm,sizeof(X));
cout << "拷贝构造函数被执行" << endl;
}
};
int main()
{
X x0;
x0.x = 100;
x0.y = 200;
x0.z = 300;
X x1(x0);
cout << "x1.x=" << x1.x << endl;
cout << "x1.y=" << x1.y << endl;
cout << "x1.z=" << x1.z << endl;
return 0;
}
分析上述程序:在构造函数中用memset(this,0,sizeof(X))代替构造函数中的初始化列表方式进行初始化。以及在拷贝构造函数中采用memcpy(this,0,sizeof(X))来代替初始化列表进行初始化。这样的代替方式看似没有任何问题。但是如果类不单纯,那么在构造函数中使用memset或者在拷贝构造函数中使用如上的memcpy方法,就会出现程序崩溃。
不单纯的类:在某些情况下,编译器会往类内部增加一些我们看不见但真实存在的成员变量(隐藏成员变量)。有了这种变量的类,就不单纯了。同时这种隐藏的成员变量的增加(使用)或者赋值的时机,往往都是在执行构造函数或者拷贝构造函数的函数体之前进行。那么这个时候如果使用memset,memcpy,很可能把编译器给隐藏变量的值你就给清空了,要么覆盖了。比如,在类中增加了虚函数,系统默认往类对象中增加虚函数表指针,这个虚函数表指针就是隐藏的成员变量。(本来虚函数表指针是要正确的指向类的虚函数表的,但是这个时候用memset把它清零,这个时候肯定会发生程序崩溃。)
下面来看一个比较有意思的问题:
#include <iostream>
using namespace std;
class X
{
public:
int x;
int y;
int z;
X() //:x(0), y(0), z(0)
{
memset(this,0,sizeof(X));
cout << "构造函数被执行" << endl;
}
X(const X& tm) //:x(tm.x), y(tm.y), z(tm.z)
{
memcpy(this,&tm,sizeof(X));
cout << "拷贝构造函数被执行" << endl;
}
virtual ~X()
{
cout << "析构函数被执行了" << endl;
}
virtual void virfunc()
{
cout << "虚函数virfunc()被执行了" << endl;
}
void ptfunc()
{
cout << "普通函数ptfunc()被执行了" << endl;
}
};
int main()
{
X x0;
/*x0.x = 100;
x0.y = 200;
x0.z = 300;*/
x0.virfunc();
/*X x1(x0);
cout << "x1.x=" << x1.x << endl;
cout << "x1.y=" << x1.y << endl;
cout << "x1.z=" << x1.z << endl;*/
//X* px0 = new X();
//px0->ptfunc();//普通成员函数可以正常调用
//px0->virfunc(); //无法正常调用,产生异常
//delete px0; //无法正常调用,产生异常
new出来的对象,所有的虚函数无法正常调用
return 0;
}
//在G++和VS2017下都能正常的输出为:
构造函数被执行
虚函数virfunc()被执行了
析构函数被执行了
分析上述代码:
1.此时的虚函数表指针已经为nullptr,但是在main()函数中在栈上面定义了一个局部对象,x0,然后通过x0去调用虚函数居然正常的输出。是不是感觉很奇怪呢?我们知道虚函数的调用过程是:通过对象的虚函数表指针->找到类的虚函数表->在虚函数中找到对应的虚函数首地址->最后去调用相应的虚函数。但是此时,对象的虚函数表指针已经为nullptr,又怎么能够调用相应的虚函数呢?奇怪!!!
2.如果我们不在栈上面创建对象,而是在堆上创建对象上述例子中有对应的代码:
X* px0 = new X();
px0->ptfunc();//普通成员函数可以正常调用
px0->virfunc(); //无法正常调用,产生异常
delete px0; //无法正常调用,产生异常
这个时候就发生异常了,只要涉及到虚函数就不能够正常调用了,是不是很奇怪呢?在解释这个原因之前,我们在来看一个例子。
#include <iostream>
using namespace std;
class X
{
public:
int x;
int y;
int z;
X() //:x(0), y(0), z(0)
{
memset(this,0,sizeof(X));
cout << "构造函数被执行" << endl;
}
X(const X& tm) //:x(tm.x), y(tm.y), z(tm.z)
{
memcpy(this,&tm,sizeof(X));
cout << "拷贝构造函数被执行" << endl;
}
virtual ~X()
{
cout << "析构函数被执行了" << endl;
}
virtual void virfunc()
{
cout << "虚函数virfunc()被执行了" << endl;
}
void ptfunc()
{
cout << "普通函数ptfunc()被执行了" << endl;
}
};
int main()
{
//X x0;
/*x0.x = 100;
x0.y = 200;
x0.z = 300;*/
//x0.virfunc();
/*X x1(x0);
cout << "x1.x=" << x1.x << endl;
cout << "x1.y=" << x1.y << endl;
cout << "x1.z=" << x1.z << endl;*/
//X* px0 = new X();
//px0->ptfunc();//普通成员函数可以正常调用
//px0->virfunc(); //无法正常调用,产生异常
//delete px0; //无法正常调用,产生异常
//new出来的对象,所有的虚函数无法正常调用
int i = 9;
printf("i的地址=%p\n",&i);
X x0;
printf("ptfunc()的地址=%p\n",&X::ptfunc);
//当如果不用memset的时候,可以打印出虚函数的地址
//long* pvptrpar = (long*)(&x0);
//long* vptrpar = (long*)(*pvptrpar);
//printf("virfunc的地址=%p\n", vptrpar[1]);//虚函数virfunc的地址,vptrpar[0]是虚析构函数~X()的地址
x0.ptfunc();
x0.virfunc();
return 0;
}
分析上述程序:
(1)运行上述程序,我们可以看出每次运行之后i的地址都会发生变化,但是普通成员函数ptfunc(如果没有memset的时候,虚函数virfunc)都不变,大家深入思考一下这个是什么原因呢?为了验证这个原因,我们来看一下,x0.ptfunc();x0.virfunc();这两句代码的汇编代码,如下所示:
x0.ptfunc();
00BD6593 lea ecx,[x0]
00BD6596 call X::ptfunc (0BD14D8h)
x0.virfunc();
00BD659B lea ecx,[x0]
00BD659E call X::virfunc (0BD14D3h)
调用普通成员函数或者调用虚函数,是直接在对应的函数地址处调用执行。所以这个函数ptfunc()和virfunc()函数,是在编译期间就是确定的。这个就是我们常说的静态联编。所谓的静态联编:在程序编译期间就能够确定调用哪个函数,给每个函数都分配了确定的地址,把调用语句和调用函数绑定了到一起。而动态联编:是与静态联编相反,是在程序运行时,根据实际情况,动态的把调用语句和被调函数绑定到一起,也就是说只有到了程序运行期间,才能够确定调用哪个函数。动态联编一般只有在动态和虚函数的情况下才存在。所以这个就解释了为什么在虚函数表指针被清0之后,还能够成功的调用虚函数了。就是因为这个是静态联编,在程序编译的期间就可以确定每个函数的调用地址,并不需要虚函数表指针,虚函数表等。
我们在来看一下另外一种情况,在堆空间中new出来的对象。
X* px0 = new X();
px0->ptfunc();//普通成员函数可以正常调用
px0->virfunc(); //无法正常调用,产生异常
delete px0; //无法正常调用,产生异常
以上四行程序对应的汇编:
X* px0 = new X();
01132A6D mov dword ptr [px0],ecx
px0->ptfunc();//普通成员函数可以正常调用,静态联编
01132A70 mov ecx,dword ptr [px0]
01132A73 call X::ptfunc (011314D8h)
px0->virfunc(); //无法正常调用,产生异常,动态联编
01132A78 mov eax,dword ptr [px0]
01132A7B mov edx,dword ptr [eax]
01132A7D mov esi,esp
01132A7F mov ecx,dword ptr [px0]
01132A82 mov eax,dword ptr [edx+4]
01132A85 call eax
01132A87 cmp esi,esp
01132A89 call __RTC_CheckEsp (011312E9h)
delete px0; //无法正常调用,产生异常
01132A8E mov eax,dword ptr [px0]
01132A91 mov dword ptr [ebp-104h],eax
01132A97 mov ecx,dword ptr [ebp-104h]
01132A9D mov dword ptr [ebp-0F8h],ecx
01132AA3 cmp dword ptr [ebp-0F8h],0
01132AAA je main+101h (01132AD1h)
01132AAC mov esi,esp
01132AAE push 1
01132AB0 mov edx,dword ptr [ebp-0F8h]
01132AB6 mov eax,dword ptr [edx]
01132AB8 mov ecx,dword ptr [ebp-0F8h]
01132ABE mov edx,dword ptr [eax]
01132AC0 call edx
01132AC2 cmp esi,esp
01132AC4 call __RTC_CheckEsp (011312E9h)
01132AC9 mov dword ptr [ebp-10Ch],eax
01132ACF jmp main+10Bh (01132ADBh)
01132AD1 mov dword ptr [ebp-10Ch],0
分析以上汇编程序:px0->virfunc(); 这行程序是动态联编,通过虚函数表指针,找到虚函数表,然后从虚函数表中找到virfunc虚函数的地址并调用。但是不幸的是我们在构造函数用用memset把虚函数表指针清0了,所以程序就会崩溃。