前言

    本节讨论构造函数,析构函数和虚析构函数。可能很多人都有这样的经历,面试时经常被问到:什么情况下要使用虚析构函数,为什么要使用虚析构函数?本文将试图对编译器的实现机制进行分析,来回答这个问题。

C++对象的内存分析(6)_面试 构造函数和析构函数的调用链

    我们从例子来分析,首先我们来看下面这个继承链:

C++对象的内存分析(6)_休闲_02

    代码如下:

class CBasic
{
public:
    int b;
    CBasic()
    {
        cout<<"Construct CBasic"<<endl;
    }
    virtual void X()
    {
    }
   ~CBasic()
    {
        cout<<"delete CBasic"<<endl;
    }
};
 
class CMiddle:public CBasic
{
public:
    int m;
    CMiddle()
    {
        cout<<"Construct CMiddle"<<endl;
    }
     ~CMiddle()
    {
        cout<<"delete CMiddle"<<endl;
    }
};
 
class CSenior:public CMiddle
{
public:
    int s;
    CSenior()
    {
        cout<<"Construct CSenior"<<endl;
    }
     ~CSenior()
    {
        cout<<"delete CSenior"<<endl;
    }
};

 

    为了实现完整的构造和析构过程,编译器会自动的往构造函数和析构函数中加入很多代码。为了说明方便起见,我把我们写在构造函数和析构函数中而不是由编译器 添加的代码为“自定义代码”。下面2个图中描述了编译器生成的CSenior::CSenior()和CSenior::~CSenior()的代码流 程:

 C++对象的内存分析(6)_内存_03

CSenior::CSenior()

 

    我们先来看CSenior的构造函数,编译器总是在构造函数的开头插入调用基类的构造函数和其他一些代码(我们用……表示,这部分代码将在后文中讲到), 然后再调用我们的“自定义代码”,这意味着,实际的调用顺序是先调用CBasic::CBasic,再CMiddle::CMiddle(),最后 CSenior()::CSenior()。 构造的过程就好像包粽子,从基类到子类,层层构造,这个顺序是不能改变的,因为子类的构造函数可能倚赖基类的数据。

 

 C++对象的内存分析(6)_编译器_04

CSenior::~CSenior()

 

    我们再来看CSenior类的析构函数,和构造函数相反,基类的析构函数总是插在函数的结尾处(图中用……表示的省略的代码,后文我们将会讲到),这意味 着实际的调用顺序是:先调用CSenior()::~CSenior(),再 CMiddle::~CMiddle() ,最后 CBasic::~CBasic() 。 这就像剥鸡蛋,从子类到基类,层层析构,由于数据倚赖的关系,这个顺序也是不能改变的。

    要证明我所说的很简单,可以把断点设在CBasic类的构造和析构函数中,观察堆栈中的函数的调用顺序,并观察控制台上的文本输出的顺序。更直接的办法是 在调试状态下,右击鼠标,选择“Go To Disassembly”来查看汇编代码,后文中很多内容我们会通过反编译来进行分析。

 

C++对象的内存分析(6)_面试 对象内存是如何分配和销毁的

    构造函数和析构函数的调用链看起来很合理,不过它们的代码中并不包含分配和销毁对象内存空间的代码。那么,对象的内存地址是何时分配和回收的呢(本节讨论 在堆(Heap)上创建对象的情况,在堆栈(Stack)上创建对象的情况在后文中分析)?首先我们来看对象的构造:

CSenior *s=new CSenior;

    反编译这行代码,我们来看看汇编:

0123162D  push        10h 
0123162F  call        operator new (1231230h)
... ...
0123164D  mov         ecx,dword ptr [ebp-0F8h]
01231653  call        CSenior::CSenior (1231208h)

 

    我们看到,程序先把要分配的地址大小10h压入堆栈,它将作为参数传递给operator new方法,由operator new为对象分配地址,然后略过n行我们看到,程序把指向刚刚分配的对象地址的指针(也就是this指针)传递给ECX寄存器(回忆我们第二节讲到的 ECX的作用)然后调用CSenior::CSenior。

    这里我们看到,为对象分配内存的工作是调用者(Caller)来完成的,请记住这一点,因为析构函数的做法有些不同。我们来看代码:

delete s;

    查看汇编,我们发现它被编译成下面的语句:

012316A6  mov         ecx,dword ptr [ebp-0ECh]
012316AC  call        CSenior::`scalar deleting destructor' (123101Eh)

   

    CSenior::`scalar deleting destructor'函数的名字看起来真的很奇怪,很明显它是编译器自动生成的,我们来看它的代码里面做了什么:

... ...
01231B53  mov         ecx,dword ptr [this]
01231B56  call        CSenior::~CSenior (1231109h)
... ....
01231B63  mov         eax,dword ptr [this]
01231B66  push        eax 
01231B67  call        operator delete (12310C8h)

 

    略过前面和中间n行代码,我们看到,在这个所谓的CSenior::`scalar deleting destructor'的函数里面,先调用了CSenior::~CSenior ,然后把this指针压入堆栈传递给operator delete方法,operator delete方法销毁对象,回收内存。(在这段汇编中,我们看到this这个符号,这是由于VC编译器自动把地址解释为符号名造成的)

    我们或许可以把编译器自动生成的CSenior::`scalar deleting destructor'叫做超级析构函数。为什么编译器不能像构造函数那样,让调用者(caller)直接调用CSenior::~CSenior和 operator delete,而是要自动生成一个这样的超级析构函数来做这2件事?这个问题我们留给后面的章节来讨论。

    现在,让我们先停下来,综合前2节讲到东西,理一理思路:在构造对象时,调用者将首先调用operator new方法来为对象分配地址,然后它将调用该类的构造函数,在构造函数体内中,编译器自动生成了一个调用链,按照先基类后子类的顺序调用继承链上的所有构 造函数。在销毁对象时,调用者(caller)将调用一个由编译器自动生成的,名为[Class]::`scalar deleting destructor'的类成员函数,析构的所有过程将由这个函数完成。在这个成员函数体内,首先调用该类的析构函数,析构函数体内也有一个由编译器生成 的调用链,将按照先子类后基类的顺序,调用继承链上的所有析构函数;最后,Class::`scalar deleting destructor'函数调用operator delete方法销毁对象、回收内存。

 

C++对象的内存分析(6)_面试虚析构函数

    前面2节中,我们探讨的构造和析构的机制,看起来很完美。那么我们为什么要使用虚析构函数,什么情况下使用呢?现在我们终于可以讨论这个问题了。

    来看下面的代码:

CSenior *s=new CSenior;
CMiddle *m=(CMiddle*)s;
delete m;

    查看汇编代码,我们发现"delete m;”被编译成下面的代码:

00C216B2  call        CMiddle::`scalar deleting destructor' (0C212ADh)

 

    看到这行汇编你是不是已经有所领悟了呢。是的,编译器并不知道对象m是从CSenior对象转换而来,它只是简单地根据对象类型,在编译时把delete m;编译成CMiddle::`scalar deleting destructor' ,而不是CSenior::`scalar deleting destructor'!这意味着析构函数的调用链将从CMiddle::~CMiddle开始执行,而CSenior::~CSenior将不会被执 行!后果显而易见。

    怎么避免这种情况呢?我们希望在调用”delete m”时,调用的是CSenior::`scalar deleting destructor'而不是CMiddle::`scalar deleting destructor'。让我们回忆一下虚函数的多态机制:当某个虚函数在子类中被重写(override),把子类对象转换为基类类型,再调用该函数, 调用的还是子类的实现而不是基类的。说到这里,你是不是已经想到答案了呢?

    请看下图:

C++对象的内存分析(6)_编译器_07

    图中,红色的线条1表示析构函数为非虚函数时的调用路径,前面我们已经说明了,这样做会有明显的问题。蓝色线条2表示在CBasic类中把析构函数定义为 虚析构函数时的情况:当调用delete m;时,程序在虚函数表中查找`scalar deleting destructor'函数的地址,由于对象m是由CSenior类型转换而来的,所以虚函数表指向CSenior::`scalar deleting destructor',于是,正确的析构函数被调用。

    我们发现,虽然我们是把~CBasic()定义为虚函数,但是编译器却偷梁换柱,把”超级析构函数“`scalar deleting destructor'定义为虚函数,而CBasic::~CBasic, CMiddle::~CMiddle, CSenior::~CSenior事实上还是非虚函数。

    现在,我们知道了虚析构函数的神奇作用,那么,什么情况下使用虚析构函数呢?当一个类有可能被其他类继承的时候,都应该声明为虚析构函数,否则,想想我们 上面讲到的后果。如果你很确定这个类不会被继承,那就不应该声明称虚析构函数,因为虚函数指针会让对象多占用4个字节的地址空间,此外,调用虚函数会被调 用非虚函数慢,我不确定会慢多少,但是一定是慢一些的。

C++对象的内存分析(6)_面试虚函数指针的初始化

    前面的小节中,我们一直回避了一个重要的问题,那就是虚函数指针是在构造函数的什么时候被初始化的(当然,前提是类有虚函数表)?答案是:在基类的构造函 数被调用之后,自定义代码调用之前(还记得在第一节图中我们用‘……’省略的代码吗)。查看反编译的汇编代码,你可以在在CBasic::CBasic中 的这个位置发现这样一行代码:

013B1956  mov         dword ptr [eax],offset CBasic::`vftable' (13B7860h)

    这句汇编表示把CBasic类的虚函数表的偏移地址拷贝到EAX寄存器指向的地址的前4个字节上,EAX寄存器此时等于this指针的值。

    同样,在CMiddle::CMiddle中有:

013B18A4  mov         dword ptr [eax],offset CMiddle::`vftable' (13B7840h)

    在CSenior::CSenior中有:

013B1784  mov         dword ptr [eax],offset CSenior::`vftable' (13B781Ch)

    联系整个构造函数的调用链,虚函数指针初始化代码的位置和过程意味着下面2个事实:

    1)构造过程中,首先在基类的构造函数中,会把虚函数指针初始化为指向基类的虚函数表,然后,在子类构造函数中又覆盖为指向子类的虚函数表,层层覆盖,最后虚函数指针会正确指向构造的类的虚函数表。

    2)由于虚函数指针初始化代码是在自定义代码之前,这意味着我们可以在构造函数中调用该类的虚函数。比如当构造过程进行到 CMiddle::CMiddle的自定义代码时,虚函数指针也正是指向CMiddle类的虚函数表,我们就可以在CMiddle::CMiddle体内 调用CMiddle类实现的虚函数了。

    显然,把虚函数指针的初始化放在基类构造函数之后,自定义代码之前这个位置是精心设计的。如果虚函书指针的初始化放在基类构造函数之前,那么虚函数指针将 从指向子类虚函数表覆盖为指向基类虚函数表,这显然是错误的;如果把初始化过程放在最后,虽然能够正确构造虚函数指针,但是我们在构造函数中就不能使用虚 函数了,即使能使用,也不是当前类的实现。

    关于构造函数中虚函数指针的初始化问题到这里已经讲完了,但是这里有一个很有意思的发现。我惊奇的发现,在析构函数中,同样也会对虚函数指针进行初始化, 初始化代码被放在析构函数的最前面,也就是在自定义代码之前(记得第一节图中我们用‘……’略过的代码吗)。这样做的效果是,虚函数指针将在析构的过程 中,从指向子类的虚函数表层层覆盖为指向基类的虚函数表,和构造函数刚好相反。我认为,编译器这么做只有一个目的,那就是让我们在析构函数中可以调用当前 类的虚函数实现,而不是调用子类的实现。

C++对象的内存分析(6)_面试多重继承时对象的析构

     让我们回到前面小节提到的问题,为什么编译器要在后台实现CSenior::`scalar deleting destructor'这个“超级析构函数”,而不是让caller自己去分别调用析构函数和operator delete方法呢?为了分析这个问题,我们把前面的例子修改成下面的继承结构:

C++对象的内存分析(6)_内存_10

    CBasic类是CSenior的主基类,CBasic和CMiddle中都把析构函数声明为虚析构函数。我们还是来看这段代码:

CSenior *s=new CSenior;
CMiddle *m=(CMiddle*)s;
delete m;

 

    让我们假设,如果编译器没有隐含地实现CSenior::`scalar deleting destructor'并把它声明为虚函数,而是直接把::~CBasic, CMiddle::~CMiddle, CSenior::~CSenior定义为虚函数,编译器把”delete m”语句编译为由caller直接调用CMiddle::~CMddle和operator delete方法,会发生什么呢?首先,由于CMiddle::~CMiddle是虚函数,所以程序会通过虚函数表,调用其子类的析构函数 CSenior::~CSenior,所以,这一步没有问题。问题出在下一步,函数会把m作为operator delete方法的参数,也就是调用operator delete(m)。我们知道,多重继承的情况下,CSenior类对象转换为CMiddle对象时,进行了指针调整,也就是说,m的地址会比s的地址大 8个字节!所以,把m而不是s作为operator delete方法的参数会造成8个字节的内存泄漏!

    我们再来看,为什么隐含地实现CSenior::`scalar deleting destructor'并把它声明为虚函数可以完美地解决这个问题呢?这就跟Thunk技术有关了。根据我们在第4章节讲到的知识,我们知道,由于 CBasic类和CMiddle类中都把析构函数声明为虚函数,所以,在CSenior类对象内存中的CMiddle类对象的虚函数表,是通过Thunk 技术来指向CSenior类的虚析构函数的实现的。Thunk技术就好像是一个中转器,在跳转到真正的实现前的预处理代码。例如在delete m被调用时,事实上一个叫做[thunk]:CSenior::`vector deleting destructor'的‘代码段’被调用(我之所以只称其为‘代码段’,因为caller虽然是通过call指令调用这段代码的,但是这段代码却不是以 ret结尾的,所以它只能算“半个函数”。):

[thunk]:CSenior::`vector deleting destructor':
009F2790  sub         ecx,8
009F2793  jmp         CSenior::`scalar deleting destructor' (9F1217h)

   我们看到,在这段代码中,ECX寄存器,也就是this指针会被减8,这样就指向了CSenior s对象,然后,代码跳转到真正的CSenior::`scalar deleting destructor'函数。这样一来,传给operator delete方法的指针指向的就是对象首地址,对象内存可以被完整地回收。

    我们上面的分析是基于的前提是CSenior类的所有基类中都有虚析构函数,如果其中一个基类没有虚析构函数呢?我们不得不继续纠结这个问题。我们来分析下面2种情况。

    1)如果主基类CBasic有虚析构函数,CMiddle类没有虚析构函数,还是调用上面的代码。结果显而易见,“超级析构函数”将不是通过虚函数表调用 的,调用的将是CMiddle::`scalar deleting destructor' ,因此只有CMiddle::~CMiddle的代码会被执行,对象内存也不会被完全回收。

    2)如果主基类CBasic没有虚析构函数,而CMiddle类有虚析构函数,还是调用上面的代码(我们假设CBasic类有虚函数表,因为如果 CBaisc类没有虚函数表,编译器将自动把CMiddle类作为主基类)。我们发现在s对象中的CMiddle类的虚函数表 中,CSenior::`vector deleting destructor'还是用THUNK技术实现的。这就意味着执行”delete m”时的情况和所有基类都有虚析构函数时的情况完全一样:this指针会被THUNK代码段调整,然后代码跳转到真正的CSenior::`vector deleting destructor'函数,析构过程会被完整执行而对象内存也会完整的回收。我必须特别指出的是,在这里,编译器事实上为虚析构函数做了特别的处理的, 因为对于普通的虚函数来说,当其第一次在虚函数表中出现时,是不会使用THUNK的,比如假设有X()这个虚函数,在CSenior类对象的CBasic 类的虚函数表中没有它,它第一次出现在CMiddle类中的虚函数表中,那么,虚函数表会直接指向X()的实现而不会使用THUNK。显然,虚析构函数不 是这样的,只要它第一出现的位置不是在主基类的虚函数表中而是其他基类的虚函数表中,就会使用THUNK,编译器做这种处理的目的显而易见,但读者应该记 住这是为虚析构实现的一个特例。

    读者还可以自己去分析一些其他的情况,但是这里我们为本小节的分析做出一个结论:对于多重继承的子类来说,如果其继承树上的某个分支上的基类定义了虚析构函数,那么把这个子类转换为这个分支上的基类,析构是可以完整的完成的;反之,如果某个分支的基类没有定义虚析构函数,那么把子类转化为这个分支上的基类,析构一定不能完整的完成。

C++对象的内存分析(6)_面试多重继承时构造函数和析构函数的‘调用树’

    我们来看下一个关于多重继承的问题,我们提到,在单继承时,构造函数和析构函数体类存在一个构造链和析构链;而多重继承时,这更像是一个构造树和析构树。 下面给出了多重继承时CSenior::CSenior和CSenior::~CSenior的代码流程图,我就不多加解释了,相信很容易理解。

C++对象的内存分析(6)_面试_12

CSenior::CSenior

C++对象的内存分析(6)_休闲_13

CSenior::~CSenior

C++对象的内存分析(6)_面试 在堆栈(stack)上构造和析构对象

 

 

    我们知道,在堆栈(Stack)上创建的对象,地址是由函数自动在堆栈上分配的,而在函数结束时,函数也将自动回收内存,而不用我们显示地分配和回收。看下面的代码:

int _tmain(int argc, _TCHAR* argv[])
{
    CSenior s;
}

 

    反编译我们可以看到,CSenior s被编译为:

012B164E  lea         ecx,[s]
012B1651  call        CSenior::CSenior (12B1221h)

 

    请注意,operator new并没有被调用,因为对象所占内存是函数在堆栈上为其预留的。

    当main函数结束时,下面的代码自动添加在函数的结尾:

012B1656  lea         ecx,[s]
012B1659  call        CSenior::~CSenior (12B1109h)

 

    函数并没有调用CSenior::`scalar deleting destructor' 方法而是直接调用了CSenior::~CSenior方法,这也很好理解,因为函数将自动回收所有在堆栈上分配的内存,不需要也不能使用 operator delete函数来进行回收,事实上,operator delete方法是在堆(Heap)上分配内存时使用的。

C++对象的内存分析(6)_面试 一点感想

    本章节是C++对象的内存分析的最后一节,我为此感到长舒一口气。再次体会到,写文章是件很辛苦的事,不过我会坚持,希望能为社区提供有价值的知识,我将以这种方式对我以前和将来google出来的所有好的技术文章和他们的作者致敬。