您的位置:首页 > 编程语言 > C语言/C++

C++父类,不能缺的虚析构函数

2014-12-14 18:49 274 查看
规则:如果一个类有可能被继承,那么请为它加上一个虚的析构函数,即使这个析构函数一行代码也没有。


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. 结论

说了一大堆,总是结论就是前面的规则,只要类有可能被继承,那么请加上一个虚的析构函数,这样大家都好。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: