C++类的构造函数与析构函数

C++中每个类都有其构造与析构函数,它们负责对象的创建和对象的清理和回收,即使我们不写这两个,编译器也会默认为我们提供这些构造函数。下面仍然是通过反汇编的方式来说明C++中构造和析构函数是如何工作的。

编译器是否真的会默认提供构造与析构函数

在一般讲解C++的书籍中都会提及到当我们不为类提供任何构造与析构函数时编译器会默认提供这样六种成员函数:不带参构造,拷贝构造,“=”的重载函数,析构函数,以及带const和不带const的取地址符重载。但是编译器具体是怎么做的,下面来对其中的部分进行说明

不带参构造

下面是一个例子:

class test
{
private:
    char szBuf[255];
public:
    static void print()
    {
        cout<<"hello world";
    }   
};
void printhello(test t)
{
    t.print();
}
int main(int argc, char* argv[])
{
    test t;
    printhello(t);
    return 0;
}

下面是对应的汇编源码:

00401400   push        ebp
00401401   mov         ebp,esp
00401403   sub         esp,140h;栈顶向上抬了140h的空间用于存储类对象的数据
00401409   push        ebx
0040140A   push        esi
0040140B   push        edi
0040140C   lea         edi,[ebp-140h]
00401412   mov         ecx,50h
00401417   mov         eax,0CCCCCCCCh
0040141C   rep stos    dword ptr [edi]
26:       test t;
27:       printhello(t);
0040141E   sub         esp,100h

从上面可以看到,在定义类的对象时并没有进行任何的函数调用,在进行对象的内存空间分配时仅仅是将栈容量扩大,就好像定义一个普通变量一样,也就是说在默认情况下编译器并不会提供不带参的构造函数,在初始化对象时仅仅将其作为一个普通变量,在编译之前计算出它所占内存的大小,然后分配,并不调用函数。
再看下面一个例子:

class test
{
private:
    char szBuf[255];
public:
    virtual void sayhello()
    {
        cout<<"hello world";
    }
    static void print()
    {
        cout<<"hello world";
    }   
};
void printhello(test t)
{
    t.print();
}
int main(int argc, char* argv[])
{
    test t;
    printhello(t);
    return 0;
}
30:       test t;
0040143E   lea         ecx,[ebp-104h]
00401444   call        @ILT+140(test::test) (00401091)

;构造函数
004014A0   push        ebp
004014A1   mov         ebp,esp
004014A3   sub         esp,44h
004014A6   push        ebx
004014A7   push        esi
004014A8   push        edi
004014A9   push        ecx
004014AA   lea         edi,[ebp-44h]
004014AD   mov         ecx,11h
004014B2   mov         eax,0CCCCCCCCh
004014B7   rep stos    dword ptr [edi]
004014B9   pop         ecx
004014BA   mov         dword ptr [ebp-4],ecx
004014BD   mov         eax,dword ptr [ebp-4]
004014C0   mov         dword ptr [eax],offset test::`vftable' (0042f02c)
004014C6   mov         eax,dword ptr [ebp-4]
004014C9   pop         edi
004014CA   pop         esi
004014CB   pop         ebx
004014CC   mov         esp,ebp
004014CE   pop         ebp

这段C++代码与之前的仅仅是多了一个虚函数,这个时候编译器为这个类定义了一个默认的构造函数,从汇编代码中可以看到,这个构造函数主要初始化了类对象的头4个字节,将虚函数表的地址放入到这个4个字节中,因此我们得出结论,一般编译器不会提供不带参的构造函数,除非类中有虚函数。
下面请看这样一个例子:

class Parent
{
public:
    Parent()
    {
        cout<<"parent!"<<endl;
    }
};

class child: public Parent
{
private:
    char szBuf[10];
};

int main()
{
    child c;
    return 0;
}

下面是它的汇编代码:

27:       child c;
004013A8   lea         ecx,[ebp-0Ch]
004013AB   call        @ILT+100(child::child) (00401069)
;构造函数
;函数初始化代码略
004013E9   pop         ecx
004013EA   mov         dword ptr [ebp-4],ecx
004013ED   mov         ecx,dword ptr [ebp-4]
004013F0   call        @ILT+80(Parent::Parent) (00401055)
;最后函数的收尾工作,代码略

从上面的代码看,当父类存在构造函数时,编译器会默认为子类添加构造函数,子类的构造函数主要是调用父类的构造函数。

class Parent
{
public:
    virtual sayhello()
    {
        cout<<"hello"<<endl;
    }
};

class child: public Parent
{
private:
    char szBuf[10];
};

int main()
{
    child c;
    return 0;
}
27:       child c;
004013B8   lea         ecx,[ebp-10h]
004013BB   call        @ILT+100(child::child) (00401069)
;构造函数
004013FA   mov         dword ptr [ebp-4],ecx
004013FD   mov         ecx,dword ptr [ebp-4]
00401400   call        @ILT+80(Parent::Parent) (00401055);调用父类的构造函数
00401405   mov         eax,dword ptr [ebp-4]
00401408   mov         dword ptr [eax],offset child::`vftable' (0042f01c);初始化虚函数表
0040140E   mov         eax,dword ptr [ebp-4]

从上面的代码中可以看到,当父类有虚函数时,编译器也会提供构造函数,主要用于初始化头四个字节的虚函数表的指针。

拷贝构造

当我们不写拷贝构造的时候,仍然能用一个对象初始化另一个对象,下面是这样的一段代码

int main(int argc, char* argv[])
{
    test t1;
    test t(t1);
    printhello(t);
    return 0;
}

我们还是用之前定义的那个test类,将类中的虚函数去掉,下面是对应的反汇编代码

30:       test t1;
31:       test t(t1);
0040141E   mov         ecx,3Fh
00401423   lea         esi,[ebp-100h]
00401429   lea         edi,[ebp-200h]
0040142F   rep movs    dword ptr [edi],dword ptr [esi]
00401431   movs        word ptr [edi],word ptr [esi]
00401433   movs        byte ptr [edi],byte ptr [esi]

从这段代码中可以看到,利用一个已有的类对象来初始化一个新的对象时,编译器仍然没有为其提供所谓的默认拷贝构造函数,在初始化时利用串操作,将一个对象的内容拷贝到另一个对象。
当类中有虚函数时,会提供一个拷贝构造,主要用于初始化头四个字节的虚函数表,在进行对象初始化时仍然采用的是直接内存拷贝的方式。
由于默认的拷贝构造是进行简单的内存拷贝,所以当类中的成员中有指针变量时尽量自己定义拷贝构造,进行深拷贝,否则在以后进行析构时会崩溃。
另外几种就不再一一进行说明,它们的情况与上面的相似,有兴趣的可以自己编写代码验证。另外需要注意的是,只要定义了任何一个类型的构造函数,那么编译器就不会提供默认的构造函数。
最后总结一下默认情况下编译器不提供这些函数,只有父类自身有构造函数,或者自身或父类有虚函数时,编译器才会提供默认的构造函数。

何时会调用构造函数

当对一个类进行实例化,也就是创建一个类的对象时,会调用其构造函数。
对于栈中的局部对象,当定义一个对象时会调用构造函数
对于堆对象,当用户调用new新建对象时调用构造函数
对于全局对象和静态对象,当程序运行之处会调用构造函数
下面重点说明当对象作为函数参数和返回值时的情况

作为函数参数

当对象作为函数参数时调用的是拷贝构造,而不是普通的构造函数
下面是一个例子代码:

class CA
{
public:
    CA()
    {
        cout<<"构造函数"<<endl;
    }

    CA(CA &ca)
    {
        cout<<"拷贝构造"<<endl;
    }
private:
    char szBuf[255];
};

void Test(CA a)
{
    return;
}
int main()
{
    CA a;
    Test(a);
    return 0;
}

对应的汇编代码如下:

33:       Test(a);
004013F9   sub         esp,100h
004013FF   mov         ecx,esp
00401401   lea         eax,[ebp-100h];eax保存对象的首地址
00401407   push        eax
00401408   call        @ILT+15(CA::CA) (00401014)
0040140D   call        @ILT+35(Test) (00401028)
;拷贝构造代码
cout<<"拷贝构造"<<endl;
0040152D   push        offset @ILT+50(std::endl) (00401037)
00401532   push        offset string "\xbf\xbd\xb1\xb4\xb9\xb9\xd4\xec" (0042f028)
00401537   push        offset std::cout (00434088)
0040153C   call        @ILT+170(std::operator<<) (004010af)
00401541   add         esp,8
00401544   mov         ecx,eax
00401546   call        @ILT+125(std::basic_ostream<char,std::char_traits<char> >::operator<<) (00401082)
21:       }
0040154B   mov         eax,dword ptr [ebp-4]
0040154E   pop         edi
0040154F   pop         esi
00401550   pop         ebx
00401551   add         esp,44h
00401554   cmp         ebp,esp
00401556   call        __chkesp (004025d0)
0040155B   mov         esp,ebp
0040155D   pop         ebp
0040155E   ret         4

从上面的代码来看,当对象作为函数参数时,首先调用构造函数,将参数进行拷贝。

作为函数的返回值

class CA
{
public:
    CA()
    {
        cout<<"构造函数"<<endl;
    }

    CA(CA &ca)
    {
        cout<<"拷贝构造"<<endl;
    }
private:
    char szBuf[255];
};

CA Test()
{
    CA a;
    return a;
}

int main()
{
    CA a = Test();
    return 0;
}
34:       CA a = Test();
0040155E   lea         eax,[ebp-200h];eax保存的是对象a 的首地址
00401564   push        eax
00401565   call        @ILT+145(Test) (00401096);调用test函数
0040156A   add         esp,4
0040156D   push        eax;函数返回的临时存储区的地址
0040156E   lea         ecx,[ebp-100h]
00401574   call        @ILT+15(CA::CA) (00401014);调用拷贝构造

;test函数
28:       CA a;
004013BE   lea         ecx,[ebp-100h]
004013C4   call        @ILT+10(CA::CA) (0040100f)
29:       return a;
004013C9   lea         eax,[ebp-100h]
004013CF   push        eax
004013D0   mov         ecx,dword ptr [ebp+8]
004013D3   call        @ILT+15(CA::CA) (00401014);调用拷贝构造
004013D8   mov         eax,dword ptr [ebp+8];ebp + 8是用来存储对象的临时存储区

通过上面的反汇编代码可以看到,在函数返回时会首先调用拷贝构造,将对象的内容拷贝到一个临时存储区中,然后通过eax寄存器返回,在需要利用函数返回值时再次调用拷贝构造,将eax中的内容拷贝到对象中。
另外从这些反汇编代码中可以看到,拷贝构造以对象的首地址为参数,返回新建立的对象的地址。
当需要对对象的内存进行拷贝时调用拷贝构造,拷贝构造只能传递对象的地址或者引用,不能传递对象本身,我们知道对象作为函数参数时会调用拷贝构造,如果以对象作为拷贝构造的参数,那么回造成拷贝构造的无限递归。

何时调用析构函数

对于析构函数的调用我们仍然分为以下几个部分:
局部类对象:当对象所在的生命周期结束后,即一般语句块结束或者函数结束时会调用
全局对象和静态类对象:当程序结束时会调用构造函数
堆对象:当程序员显式调用delete释放空间时调用

参数对象

下面是一个例子代码:

class CA
{
public:
    ~CA()
    {
        printf("~CA()\n");
    }
private:
    char szBuf[255];
};

void Test(CA a)
{
    printf("test()\n");
}

int main()
{
    CA a;
    Test(a);
    return 0;
}

下面是它的反汇编代码

;Test(a)
0040133A   sub         esp,100h;在main函数栈外开辟一段内存空间用于保存函数参数
00401340   mov         ecx,3Fh;类大小为255个字节,为了复制这块内存,每次复制4字节,共需要6300401345   lea         esi,[ebp-10Ch];esi保存的是对象的首地址
0040134B   mov         edi,esp;参数首地址
0040134D   rep movs    dword ptr [edi],dword ptr [esi];执行复制操作
0040134F   movs        word ptr [edi],word ptr [esi]
00401351   movs        byte ptr [edi],byte ptr [esi];将剩余几个字节也复制
00401352   call        @ILT+5(Test) (0040100a);调用test函数
;调用Test函数
23:       printf("test()\n");
00401278   push        offset string "test()\n" (0042f01c)
0040127D   call        printf (00401640)
00401282   add         esp,4
24:   }
00401285   lea         ecx,[ebp+8];参数首地址
00401288   call        @ILT+0(CA::~CA) (00401005)

从上面的代码看,当类对象作为函数参数时,首先会调用拷贝构造(当程序不提供拷贝构造时,系统默认在对象之间进行简单的内存复制,这个就是提供的默认拷贝构造函数)然后当函数结束,程序执行到函数大括号初时,首先调用析构完成对象内存的释放,然后执行函数返回和做最后的清理工作

函数返回对象

下面是函数返回对象的代码:

class CA
{
public:
    ~CA()
    {
        printf("~CA()\n");
    }
private:
    char szBuf[255];
};

CA Test()
{
    printf("test()\n");
    CA a;
    return a;
}

int main()
{
    CA a = Test();
    return 0;
}
30:       CA a = Test();
0040138E   lea         eax,[ebp-100h];eax保存了对象的地址
00401394   push        eax
00401395   call        @ILT+20(Test) (00401019)
0040139A   add         esp,4
31:       return 0;
0040139D   mov         dword ptr [ebp-104h],0
004013A7   lea         ecx,[ebp-100h]
004013AD   call        @ILT+0(CA::~CA) (00401005);调用类的析构函数
004013B2   mov         eax,dword ptr [ebp-104h]
32:   }
;test函数
24:       CA a;
25:       return a;
004012AA   mov         ecx,3Fh
004012AF   lea         esi,[ebp-10Ch];esi保存的是类对象的首地址
004012B5   mov         edi,dword ptr [ebp+8];ebp+8是当初调用这个函数时传进来的类的首地址
004012B8   rep movs    dword ptr [edi],dword ptr [esi]
004012BA   movs        word ptr [edi],word ptr [esi]
004012BC   movs        byte ptr [edi],byte ptr [esi]
004012BD   mov         eax,dword ptr [ebp-110h]
004012C3   or          al,1
004012C5   mov         dword ptr [ebp-110h],eax
004012CB   lea         ecx,[ebp-10Ch]
004012D1   call        @ILT+0(CA::~CA) (00401005);调用析构函数
004012D6   mov         eax,dword ptr [ebp+8]

当类作为返回值返回时,如果定义了一个变量来接收这个返回值,那么在调用函数时会首先保存这个值,然后直接复制到这个内存中,但是接着执行类的析构函数析构在函数中定义的类对象,接受返回值得这块内存一直等到它所在的语句块结束才调用析构
如果不要这个返回值时又如何呢,下面的代码说明了这个问题

int main()
{
    Test();
    printf("main()\n");
    return 0;
}
30:       Test();
0040138E   lea         eax,[ebp-100h]
00401394   push        eax
00401395   call        @ILT+20(Test) (00401019)
0040139A   add         esp,4
0040139D   lea         ecx,[ebp-100h]
004013A3   call        @ILT+0(CA::~CA) (00401005)
31:       printf("main()\n");
004013A8   push        offset string "main()\n" (0042f030)
004013AD   call        printf (00401660)
004013B2   add         esp,4

同样可以看到当我们不需要这个返回值时,函数仍然会将对象拷贝到这块临时存储区中,但是会立即进行析构对这块内存进行回收。

posted @ 2017-10-24 20:55  masimaro  阅读(273)  评论(0编辑  收藏  举报