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

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

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

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

不带参构造

下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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;
}

下面是对应的汇编源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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个字节中,因此我们得出结论,一般编译器不会提供不带参的构造函数,除非类中有虚函数。
下面请看这样一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Parent
{
public:
Parent()
{
cout<<"parent!"<<endl;
}
};

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

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

下面是它的汇编代码:

1
2
3
4
5
6
7
8
9
10
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)
;最后函数的收尾工作,代码略

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Parent
{
public:
virtual sayhello()
{
cout<<"hello"<<endl;
}
};

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

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

1
2
3
4
5
6
7
8
9
10
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]

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

拷贝构造

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

1
2
3
4
5
6
7
int main(int argc, char* argv[])
{
test t1;
test t(t1);
printhello(t);
return 0;
}

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

1
2
3
4
5
6
7
8
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新建对象时调用构造函数
对于全局对象和静态对象,当程序运行之处会调用构造函数
下面重点说明当对象作为函数参数和返回值时的情况

作为函数参数

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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;
}

对应的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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

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

作为函数的返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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释放空间时调用

参数对象

下面是一个例子代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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;
}

下面是它的反汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
;Test(a)
0040133A sub esp,100h;在main函数栈外开辟一段内存空间用于保存函数参数
00401340 mov ecx,3Fh;类大小为255个字节,为了复制这块内存,每次复制4字节,共需要63次
00401345 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)

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

函数返回对象

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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]

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

1
2
3
4
5
6
int main()
{
Test();
printf("main()\n");
return 0;
}

1
2
3
4
5
6
7
8
9
10
11
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

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