博文来源:博文原始地址
规则:如果一个类有可能被继承,那么请为它加上一个虚的析构函数,即使这个析构函数一行代码也没有。
0. 引子
为什么这样说?先看一个例子。先定义3个类:
class CBase
{
public:
long m;
long n;
long o;
CBase()
{
m = 1;
n = 2;
o = 3;
}
void Do(int y);
~CBase();
};
void CBase::Do(int y)
{
int x;
x = n;
printf("%s, m\n", __FUNCTION__, x + y);
}
CBase::~CBase()
{
printf("%s\n", __FUNCTION__);
}
class CChild: public CBase
{
public:
virtual ~CChild();
};
CChild::~CChild()
{
printf("%s\n", __FUNCTION__);
}
class CGrandChild:public CChild
{
public:
~CGrandChild();
};
CGrandChild::~CGrandChild()
{
printf("%s\n", __FUNCTION__);
}
接着声明变量: CBase *b; CChild *c; CGrandChild *g;
然后执行代码:
c = new CChild;
b=c;
printf("b=%08XH, c=%08XH\n", (unsigned)b, (unsigned)c);
delete b;
会有什么结果呢?在笔者的计算上执行结果如下:
b=00340F84H, c=00340F80H //注意两者不相等, b=c+4
CBase::~CBase
接着出现b指针内存释放错误。
1. 父类没有虚函数(不一定要析构函数)造成赋值差异
出现内存释放错误的原因当然是由于b和c的值不相等造成的,但问题是他们为什么会不相等呢?
上文中b=c的反汇编代码是(32Bits系统,编译器vc2003, Debug版,Release版做了优化,有些东西看不到,其它的编译器很可能也是这样,以下同):
00411FED cmp dword ptr [c],0 //如果c是0
00411FF1 je main+71h (412001h) //跳到412001地址
00411FF3 mov eax,dword ptr [c] //把地址c放到eax寄存器
00411FF6 add eax,4 //注意这个地方加了4,造成b比c大4的
00411FF9 mov dword ptr [ebp-148h],eax
00411FFF jmp main+7Bh (41200Bh)
00412001 mov dword ptr [ebp-148h],0 //当c是0的时候,第2句汇编会直接跳转到这地方,这种情况就不会加4了
0041200B mov ecx,dword ptr [ebp-148h]
00412011 mov dword ptr [b],ecx //把eax寄存器的值赋值给b
这是因为CBase中没有虚函数造成的。由于CBase中没有虚函数,那么CBase就不会存在虚函数表,也就没有了指向虚函数表的指针:
如果new一个CBase的对象,则内存是这么分配的(表1):
---------表1---------
b+0: &m
b+4: &n
b+8: &o
b是this的值
------------------
而CChild有虚函数,则new一个CChild,内存是这样分配的:
---------表2---------
c+0: 虚函数表vtable
c+4: &m
c+8: &n
c+12: &o
vtable[0]: CChild::~CChild
c+4为this的值
------------------
这就好理解为什么b会等于c+4,这是为了使指针b可能正常的访问成员变量,比如调用b->Do(5),汇编代码(Code 1)是:
00412163 push 5 //把参数压入栈,内存操作
00412165 mov ecx,dword ptr [b] //b放到ecx寄存器
00412168 call CBase::Do (4116F4h)
顺便提一点,this用ecx寄存器传递给类方法,而非压栈,寄存器是在CPU内部,访问速度要比访问内存快一些。
接下来在CBase::Do()中的执行代码,比如(Code 2):
int x;
x = n;
00411E13 mov eax,dword ptr [this] //this(也就是b的值)放到eax
00411E16 mov ecx,dword ptr [eax+4] //参考表1的,eax+4就是n的地址
00411E19 mov dword ptr [x],ecx //赋值给x
那么c->Do(4)又是什么样呢(Code 3)?
c->Do(5);
0041216D push 5
0041216F mov ecx,dword ptr [c] //指针c给ecx寄存器
00412172 add ecx,4 //注意这个地方
00412175 call CBase::Do (4116F4h)
注意上面的有一个add运算加了4(与b->Do(5)比较),为什么要加4,是因为如果不加4,那么在call CBase::Do的时候,就不能得到正确的n的地址,上面CBase::Do中的代码是(Code 4):
int x;
x = n;
00411E13 mov eax,dword ptr [this] //this,这里是c+4的值
00411E16 mov ecx,dword ptr [eax+4] //参考表1的,eax+4就是c+8,参考表2,是n的地址
00411E19 mov dword ptr [x],ecx //赋值给x
由上面的分析可以得出:
1. b=c结果造成b==c+4,是为了保证能够正常的访问成员变量
2. 使用b来调用类成员方法,成员方法中的this与b相等;而使用c来调用,为了跳过vtable,this==c+4,4是一个指针的大小。
为CBase加上一个函数:
CBase *GetThis()
{
return this;
}
再次执行:
printf("b=%08XH, c=%08XH\n", (unsigned)b, (unsigned)c);
printf("b this=%08XH, c this=%08XH\n", b->GetThis(), c->GetThis());
结果也表明了这一点:
b=00342EDCH, c=00342ED8H
b this=00342EDCH, c this=00342EDCH
如何解决b!=c和c!=this的问题呢?当然在CBase中有虚函数(不一定要析构),产生一个vtable就可以了。
2. 父类加虚函数避免赋值差异问题
按照上面说的,给CBase加一个虚函数,当然仍然保持析构函数为非虚,那么为CBase随意加一个虚函数,比如:
virtual void Do2(void){}
执行代码:
c = new CChild;
b = c;
b->Do(5);
c->Do(5);
printf("b=%08XH, c=%08XH\n", (unsigned)b, (unsigned)c);
printf("b this=%08XH, c this=%08XH\n", b->GetThis(), c->GetThis());
delete b;
结果如下:
CBase::Do, 7
CBase::Do, 7
b=00342ED8H, c=00342ED8H
b this=00342ED8H, c this=00342ED8H
CBase::~CBase
由于b==c,之后也就没有释放b的内存的错误了,而且c也等于this了,看看汇编代码(Code 5):
b->Do(5);
00417B42 push 5
00417B44 mov ecx,dword ptr [b] //没有+4
00417B47 call CBase::Do (411708h)
c->Do(5);
00417B4C push 5
00417B4E mov ecx,dword ptr [c] //也没有+4
00417B51 call CBase::Do (411708h)
注意上面c->Do(5)的代码中没有+4,与(Code 3)比较。这是怎么回事呢?按照前面的分析,如果不+4,访问成员变量可能会不正确呀,如 (Code 2)的形式访问n:
---------表2---------
c+0: 虚函数表vtable
c+4: &m
c+8: &n
c+12: &o
vtable[0]: CChild::~CChild
c+4也是this的值
------------------
(Code 2)
int x;
x = n;
00411E13 mov eax,dword ptr [this]
00411E16 mov ecx,dword ptr [eax+4] //查上面的表2,c+4不是m的地址吗?
00411E19 mov dword ptr [x],ecx
查上面的表2,c+4是m的地址而不是n的地址,岂非要出错?如果不出错,就不能是[eax+4],而是[eax+8]。新代码的反汇编表明果真如此(Code 6):
int x;
x = n;
00411E13 mov eax,dword ptr [this]
00411E16 mov ecx,dword ptr [eax+8] //加8了,与Code2不同
00411E19 mov dword ptr [x],ecx
由此可以得知:
1.如果一个类或者其父类有虚函数,也就是this==vtable,访问任何一个成员变量都要加上vtable的大小
2.如果一个类与它的所有父类都没有虚函数,则this==最顶层父类第一个成员变量的地址
3.如果父类都没有虚函数,但子类有虚函数,则子类实例访问父类的方法,会将子类实例地址+4做为this传给父类的方法
3. 父类没有虚析构函数造成子类不能析构
虽然父类加上了虚函数解决了赋值差异问题,但从第2节的代码执行结果只发现了CBase::~CBase,而没有发现CChild::~CChild的打印,这表明子类没有析构。如果把父类的析构函数改为虚的,再次运行则有:
CBase::Do, 7
CBase::Do, 7
b=00342ED8H, c=00342ED8H
b this=00342ED8H, c this=00342ED8H
CChild::~CChild
CBase::~CBase
执行得很好,先是子类析构,再是父类析构。这是怎么回事呢?先要解释子类的虚函数是如何被父类指针调用到的,这与虚函数有关,看这样一个例子:
class CAnimal
{
public:
virtual Walk(){};
virtual Eat(){};
~CAnimal(){}; //非虚,不在vtable中。
};
class CDog: public CAnimal
{
public:
virtual Walk(){CAnimal::Walk();};
virtual Shout(){};
virtual ~CDog(){};
};
CDog Dog;
CAnimal *pAnimal = pDog;
pAnimal->Walk();//调用到CDog::Walk,而不是CAnimal::Walk
那么在内存中如何组织呢?伪代码是这样的:
对于CAnimal有:
struct AnimalVTable
{
F[0] = CAnimal::Walk;
F[1] = CAnimal::Eat;
};
class CAnimal
{
AnimalVTable *vtable;
};
对CDog有:
struct DogVTable
{
F[0] = CDog::Walk;
F[1] = CAnimal::Eat;
F[2] = CDog::Short;
F[3] = CDog::Destructor //即:析构CDog::~CDog;
};
class CDog
{
DogVTable *vtable;
};
注意虚函数表是每个类一份,而不是每个实例一份。就是一个类new了100个实例,虚函数表还是只有一份,有点像类的static类型成员变量。
当执行pAnimal->Walk()的时候,由于Walk是vtable中的第一个元素,这行代码将执行pAnimal地址(实际上Dog的地址)是所指向第一个因素,相当于pAnimal->vtable->F[0]();由于pAnimal==&Dog,所以执行到了CDog::Walk。同样的道理,如果执行pAnimal->Eat,实际上是pAnimal->vtable->F[1](),在CDog中,这是CAnimal::Eat。
对于析构函数,在delete的时候会自动调用,当delete pAnimal的时候,由于析构函数不是虚函数,则只会执行CAnimal中的析构函数。就好像一个方法在父类中非虚,在子类中虚,用父类指针去调用的时候,不会访问到子类。
如果把~Animal改为虚的,则:
对于CAnimal有:
struct AnimalVTable
{
F[0] = CAnimal::Walk;
F[1] = CAnimal::Eat;
F[2] = CAnimal::Destructor //即:CAnimal::~CAnimal
};
class CAnimal
{
static AnimalVTable *vtable;
};
对CDog有:
struct DogVTable
{
F[0] = CDog::Walk;
F[1] = CAnimal::Eat;
F[2] = CDog::Destructor; //即CDog::~CDog,注意,与声明的次序不同,因为要保持与父类中同名虚函数的相同位置。
F[3] = CDog::Short;
};
class CDog
{
static DogVTable *vtable;
};
这里有一个小问题,虚函数的覆盖是以函数名与参数判断的,每个类的析构函数的函数名是明显不同的,怎么能覆盖呢?实际上编译器处理的时候,把析构函数都当作一个名字处理,比如都叫做Destructor。所以析构看上去名字不一样,但对编译器就是一样的名字,也能运用虚函数的处理规则了。因此,如果我们手动调用pAnimal->~Canimal(),也能执行由子类到父类的整个析构过程。
这时候,delete pAnimal;的时候,先执行了pAnimal->vtable->F[2],由于pAnimal==&Dog,这样便执行到了CDog::~CDog。那么为什么父类的析构函数也被执行了呢?这就是编译器做的手脚了。实际上编译器对虚的析构函数做了特殊处理,在子类的虚析构函数执行完后,自动调用父类的析构函数,代码都是编译器处理的,它当然知道一个类的父类是谁,父类的析构函数是哪个。当然,对于一般的虚函数自然没有这样的处理了,需要代码中手动写上一句,如在CDog::Walk中,手动的调用一下CAnimal::Walk,以执行父类的该方法。特别地,如果直接父类没有实现,则再往上调用一级到爷爷类^_^,依次类推。
4. 结论
说了一大堆,总是结论就是前面的规则,只要类有可能被继承,那么请加上一个虚的析构函数,这样大家都好。