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

博文来源:博文原始地址

 

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

   

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

    说了一大堆,总是结论就是前面的规则,只要类有可能被继承,那么请加上一个虚的析构函数,这样大家都好。

你可能感兴趣的:(C++)