现在的位置: 首页 > 综合 > 正文

你不知道的构造函数

2017年12月21日 ⁄ 综合 ⁄ 共 9803字 ⁄ 字号 评论关闭

转自:http://tech.ddvip.com/2013-01/1358926849189444.html

           http://tech.ddvip.com/2013-01/1358927019189445.html

Performanced C++,意为“高性能C++“编程,是笔者和所在团队多年C++编程总结的经验规则,按条款方式讲述(参考了《Effective C++》的方式),希望能对初入C++的程序员提供帮助,少走弯路,站在前人的肩膀上,看得更高走的更远。我们也同样是脚踩许许多多大牛的经典著作,还有无数默默付出的程序员的辛劳,以及自己许许多多惨痛的编程体验,才有了这些“规则”。

首先来看,我们“知道”的构造函数,C++构造函数究竟做了哪些事情?

1、创建一个类的对象时,编译器为对象分配内存空间,然后调用该类的构造函数;

2、构造函数的目的,是完成对象非静态成员的初始化工作(静态成员如何初始化?记住以下要点:在类外进行、默认值为0、在程序开始时、在主函数之前、单线程方式、主线程完成),记住:C++类非静态成员是没有默认值的(可对比Java)。

3、如果构造函数有初始化列表,则先按照成员声明顺序(非初始化列表中的顺序)执行初始化列表中的内容,然后再进入构造函数体。这里又有疑问了,如果类本身没有非虚拟的基类,应显式地调用直接基类的某个构造函数,否则,将会自动其直接基类的默认构造函数(如果此时直接基类没有默认构造函数,得到编译错误);如果类本身有虚拟基类,也应显式地调用虚拟基类的某个构造函数,否则,将会自动调用虚拟基类的默认构造函数;如果成员有其它类的对象,则应显式地调用成员所属类的相应构造函数,否则对于没有在初始化列表中出现的类成员,也会自动调用其默认的构造函数。

注意上述调用顺序,编程时应按照“先祖再客最后自己”的原则进行,即,首先完成自身包含的“祖先对象”的初始化,之后,完成自身包含的成员是其它类型(客人)的初始化,最后才是自身非类类型成员的初始化工作。

再注意,上面多次提到了术语“默认构造函数”,默认构造函数是指:无参构造函数或每个参数均有默认值的构造函数。当且仅当,一个类没有声明任何构造函数时,可认为编译器会自动为该类创建一个默认构造函数(无参的,注意“可认为”,即实际情况并非如此,编译器并不一定总是会自动创建默认构造函数,除非必要,这涉及到更深的汇编层面。当然,在写代码的时候,这个“可认为”是正确的)。

这一小部分内容可能信息量过大,让我们看一段代码以加深理解。

双击代码全选
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include
<iostream>  
 usingnamespace

std;  
         
 classBase  
 {  
 private:  
         int_x;  
 public:  
         Base(intx)
: _x(x) { cout <<
"Base(x)
_x="

<< _x << endl; }  
         Base()
{}  
 };  
         
 classDerivedA
:
virtual public

Base  
 {  
         int_y;  
 public:  
         DerivedA(intx
= 0,
inty
= 1) : Base(x), _y(y)  
         {
cout <<
"DerivedA(x,y)
_y="

<< _y << endl; }  
 };  
         
 classDerivedB
:
virtual public

Base  
 {  
         int_z;  
 public:  
         DerivedB(intx
= 0,
intz
= 2) : Base(x), _z(z)  
         {
cout <<
"DerivedB(x,z)
_z="

<< _z << endl; }  
 };  
         
 classOther  
 {  
         int_o;  
 public:  
         Other()
: _o(3) { cout <<
"Other()
_o="

<< _o << endl; }  
 };  
         
 classDerivedFinal
:
publicDerivedB,
publicDerivedA  
 {  
         int_xyz;  
         Other
_other;  
 public:  
         DerivedFinal(intx
= 10,
inty
= 20,
intz
= 30,
into
= 50) : DerivedA(x,y), DerivedB(x,z), Base(x), _xyz(x * y * z)  
         {
cout <<
"DerivedFinal(x,y,z,o)
_xyz="

<< _xyz << endl; }  
 };  
         
 intmain(intargc,
char**
argv)  
 {  
         DerivedFinal
df;  
         return0;  
 }

输出结果(Ubuntu 12.04 + gcc 4.6.3):

双击代码全选
1
2
3
4
5
Base(x)
_x=10  
DerivedB(x,z)
_z=30  
DerivedA(x,y)
_y=20  
Other()
_o=3  
DerivedFinal(x,y,z,o)
_xyz=6000

和你心中的答案是否一致呢?

一切从DerivedFinal的调用顺序说起,首先,这是虚继承,故虚基类Base的构造函数将首先被调用,尽管它在DerivedFinal构造函数的初始化列表顺序中排在后面的位置(再次记住,调用顺序与初始化列表中的顺序无关),接下来是DerivedB(x,z),因为它先被继承;之后是DerivedA(x,z),再之后,DerivedFinal自身非类类型成员_xyz被初始化,最后是Other(),other成员并没有出现在DerivedFinal的初始化列表中,所以它的默认构造函数将被自动调用。另外,如果不是虚继承,调用间接基类Base的构造函数将是非法的,但此处是虚继承,必须这样做。

接下来继续讨论,上面提到,编译器不一定总是会产生默认构造函数,虽然在编写代码时,你“可以这么认为”,这听起来太玄乎了,那么,到底什么时候,编译器才会真正在你没有定义任何构造函数时,为你产生一个默认构造函数呢?有以下三种情况,编译器一定会产生默认构造函数:

(1)该类、该类的基类或该类中定义的类类型成员对象中,有虚函数存在。

发生这种情况时,由于必须要完成对象的虚表初始化工作(关于虚函数的原理,笔者建议参考陈皓的《C++虚函数表解析》),所以编译器在没有任何构造函数的时候,会产生一个默认构造函数来完成这部分工作;然而,如果已经有任何构造函数,编译器则把初始化虚表这部分工作“合成”到你已定义的构造函数之中(用心良苦)。

让我们稍稍进入汇编领域(笔者强烈建议,要精通C/C++,一定的汇编和反汇编能力是必须的,能精通更好)看一下,一个有虚函数的类,构造函数的x86反汇编代码:

双击代码全选
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
28
29
30
31
32
classVirtualTest  
{  
public:  
    virtualvoid

foo(
intx)
{ cout << x << endl; }  
};  
        
intmain(intargc,
char**
argv)  
{  
    VirtualTest
vt;  
 lea
ecx, [ebp-4]  ;获取对象首地址  
 call
@ILT+15(VitrualTest::VirtualTest) (0048A500)  
;调用构造函数,由于该类没有定义任何构造函数又包含虚函数,编译器产生了一个默认构造函数并调用  
            
    return0;  
}  
        
//下面是默认构造函数反汇编  
D0
55               push        ebp   
D1
8B EC            mov         ebp,esp  
D3
51               push        ecx  
;头三句,初始化函数调用过程,详见汇编知识  
D4
89 4D FC         mov         dword ptr [ebp-4],ecx  
;获取对象首地址,即this指针  
D7
8B 45 FC         mov         eax,dword ptr [
this]  
;取出this指针,这个地址将会作为指针保存到虚表首地址  
DA
C7 00 60 68 40 00 mov         dword ptr [eax],offset VirtualTest::`vftable' (0042201c)  
;取虚表首地址,保存到虚表指针中(即对象头4字节)  
E0
8B 45 FC         mov         eax,dword ptr [
this]  
;再次取出this指针地址,返回函数调用,即得到对象  
E3
8B E5            mov         esp,ebp  
E5
5D               pop         ebp   
E6
C3               ret

由该汇编代码还可以看出,虚表指针初始化,在构造函数初始化列表之后,进入构造函数体代码之前。

(2)该类、该类的基类中所定义的类类型成员对象中,带有构造函数。

发生这种情况时,由于需要显式地调用这些类类型成员的构造函数,编译器在没有任何构造函数的时候,也会产生一个默认构造函数来完成这个过程;同样,如果你已经定义一个构造函数但没有对这些类类型成员显式调用构造函数,编译器则把这部分工作“合成"到你定义的构造函数中(调用它们的默认构造函数,再次用心良苦)。

(3)该类拥有虚基类。

发生这种情况,需要维护“独此一份"的虚基类继承而来的对象,所以也需要通过构造函数完成。方式同(1)(2)。

除上述3种情况外,“可认为在没有任何构造函数时候,编译器产生一个默认构造函数”是不对的,因为这样的默认构造函数是“无用”的,编译器也就不会再用心良苦去做没用的工作。这部分涉及汇编较多,如果想详细了解,建议阅读钱林松所著的《C++反汇编与逆向分析技术揭秘》,机械工业出版社,2012.5。

这里只要记住结论就可以了。

4、虚表初始化

上一篇曾提到,如果一个类有虚函数,那么虚表的初始化工作,无论构造函数是你定义的还是由编译器产生的,这部分工作都将由编译器隐式“合成”到构造函数中,以表示其良苦用心。上一篇还提到,这部分工作,在“刚”进入构造函数的时候,就开始了,之后,编译器才会理会,你构造函数体的第一行代码。这一点,通过反汇编,我们已经看的非常清楚。

虚表初始化的主要内容是:将虚表指针置于对象的首4字节;用该类的虚函数实际地址替换虚表中该同特征标(同名、同参数)函数的地址,以便在调用的时候实现多态,如果有新的虚函数(派生类中新声明的),则依次添加至虚表的后面位置。

5、构造函数中有虚特性(即多态、即动态绑定、晚绑定)产生吗?

这个问题,看似简单,答案却比较复杂,正确答案是:对于构造函数,构造函数中没有虚特性产生(在C++中答案是NO,但在Java中,答案是YES,非常的奇葩)。

先从基类构造函数说起,为什么要提基类构造函数呢,因为,派生类总是要调用一个基类的构造函数(无论是显式调用还是由编译器隐式地调用默认构造函数,因为这里讨论的是有虚函数的情况,所以一定会有基类构造函数产生并调用),而此时,在基类构造函数中,派生类对象根本没有创建,也就是说,基类根本不知道派生类中产生了override,即多态,故没有虚特性产生。

这一段非常让人疑惑。让我们再看一小段代码,事实胜于雄辩。

双击代码全选
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
#include
<iostream>  
 usingnamespace

std;  
         
 classBase  
 {  
 public:  
         Base()
{ foo(); }  
         virtualvoid

foo(
void)
{ cout <<
"Base::foo(void)"<<
endl; }  
         virtualvoid

callFoo(
void)
{ foo(); }  
 };  
         
 classDerived
:
publicBase  
 {  
 public:  
         Derived()
{ foo(); }  
         voidfoo(void)
{ cout <<
"Derived::foo(void)"<<
endl; }  
 };  
         
 intmain(intargc,
char**
argv)  
 {  
         Base*
pB =
newDerived;  
         pB->callFoo();  
         if(pB)  
                 deletepB;  
         return0;  
 }

在Ubuntu 12.04 + gcc 4.6.3输出结果如下:

双击代码全选
1
2
3
Base::foo(void)  
Derived::foo(void)  
Derived::foo(void)

这个结果可以很好的解释上述问题,第一行,由于在Base构造函数中,看不到Derived的存在,所以根本不会产生虚特性;而第二行,虽然输出了Derived::foo(void),但因为在派生类直接调用方法名,调用的就是本类的方法,(当然,也可认为在Derived构造函数中,执行foo()前,虚表已经OK,故产生多态,输出的是派生类的行为)。再看第三行,也产生多态,因为,此时,派生类对象已经构建完成,虚表同样也已经OK,所以产生多态是必然。

这个问题其实是C++比较诟病的陷阱问题之一,但我们只要记住结论:不要在构造函数内调用其它的虚成员函数,否则,当这个类被继承后,在构造函数内调用的这些虚成员函数就没有了虚特性(丧失多态性)。(非虚成员函数本来就没有多态性,不在此讨论范围)

解决此类问题的方法,是使用“工厂模式”,在后续篇幅中笔者会继续提到,这也是《Effective C++》中阐述的精神:尽可能以工厂方法替换公有构造函数。

另外,有兴趣的同学,可以将上述代码稍加修改成Java跑一跑,你会惊喜的发现,三个输出都是Derived::foo(void),也就是说,JVM为你提供了一种未卜先知的超自然能力。

6、构造函数中调用构造函数、析构函数

上面已经提到,不要在构造函数内调用其它成员函数,那么调用一些“特殊”的函数,情况又如何呢?我知道,有同学想到了,在构造函数中调用本类的析构函数,情况如何?如下面的代码

双击代码全选
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include
<iostream>  
 usingnamespace

std;  
         
 classA  
 {  
 public:  
         ~A()
{ cout << hex << (
int)this<<"destructed!"<<
endl; }  
         A()
{ cout << hex << (
int)this<<
"constructed!"<<
endl;  
                 ~A(); 
}  
         
 };  
         
 intmain(intargc,
char**
argv)  
 {  
         A
a;  
         return0;  
 }

虽然我对有这种想法的同学有强拖之去精神病院的冲动,但还是本着研究精神,把上述“疯子”代码跑一遍,还特地把析构函数的定义提到构造函数之前以防构造函数不认识它。结论是:构造函数中调用析构函数,编译器拒绝接受~A()是析构函数,从而拒绝这一不讲理行为。此时编译器认为,你是在重载~操作符,并给出没有找到operator ~()声明的错误提示。其实,无论是在构造函数A()里面调用~A()不行,在成员函数里,也是不行的(编译器仍认为你要调用operator ~(),而你并没有声明这个函数)。但是,有个小诡计,却可以编译通过,就是通过this->~A()来调用析构函数,这将导致对象a被析构多次,隐藏着巨大的安全隐患。

总之,在构造函数中调用析构函数,是十分不道德的行为,应严格禁止。

好了,接下来是,构造函数中,调用构造函数,情况又如何呢?

(1)首先,如果构造函数中递归调用本构造函数,产生无限递归调用,很快就栈溢出(栈上分配)或其它crash,应严格禁止;

(2)如果构造函数中,调用另一个构造函数,情况如何?

双击代码全选
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include
<iostream>  
 usingnamespace

std;  
         
 classConAndCon  
 {  
 public:  
     int_i;  
     ConAndCon(inti
) : _i(i){}  
     ConAndCon()  
     {  
         ConAndCon(0);  
     }  
 };  
         
 intmain(intargc,
char**
argv)  
 {  
     ConAndCon
cac;  
     cout
<< cac._i << endl;  
     return0;  
 }

上面代码,输出为0吗?

答案是:不一定。输出结果是不确定的。根据C++类非静态成员是没有默认值的规则,可以推定,上述代码里,在无参构造函数中调用另一个构造函数,并没有成功完成对成员的初始化工作,也就是说,这个调用,是不正确的。

那么,由ConAndCon产生的对象哪里去了?如果用gdb跟踪调试或在上述类的构造、析构函数中打印出对象信息就会发现,在构造函数中调用另一个构造函数,会产生一个匿名的临时对象,然后这个对象又被销毁,而调用它的cac对象,仍未得到本意的初始化(设置_i为0)。这也是应严格禁止的。

通常解决此问题的三个方案是:

方案一,我们称为一根筋方案,即,我仍要继续在构造函数中调用另一个构造函数,还要让它正确工作,即“一根筋”,解决思路:不要产生新分配的对象,即在第一个构造函数产生了对象的内存分配之后,仍在此内存上调用另一个构造函数,通过布局new操作符(replacement new)可以做到:

双击代码全选
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//标准库中replacement
new操作符的定义:  
//需要#include
<new>  
        
inlinevoid

*__cdecl operator
new(size_t,void*_P)  
{  
    return(_P);   
}  
        
//那么修改ConAndCon()为:  
        
    ConAndCon()  
    {  
        new(this)ConAndCon(0);  
    }

即在第一次分配好的内存上再次分配。

某次在Ubuntu 12.04 + gcc 4.6.3运行结果如下(修改后的代码):

双击代码全选
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
28
29
#include
<iostream>  
 #include
<new>  
 usingnamespace

std;  
         
 classConAndCon  
 {  
 public:  
     int_i;  
     ConAndCon(inti
) : _i(i){cout << hex << (
int)this<<"constructed!"<<
endl;}  
     ConAndCon()  
     {  
         cout
<< hex << (
int)this<<"constructed!"<<
endl;  
         new(this)ConAndCon(0);  
     }  
         ~ConAndCon()
{ cout << hex << (
int)this<<"destructed!"<<
endl; }  
 };  
         
 intmain(intargc,
char**
argv)  
 {  
     ConAndCon
cac;  
     cout
<< cac._i << endl;  
     return0;  
 }  
         
 //运行结果:  
 bfd1ae9cconstructed!  
 bfd1ae9cconstructed!  
 0  
 bfd1ae9cdestructed!

可以看到,成功在第一次分配的内存上调用了另一个构造函数,且无需手动为replacement new调用析构函数(此处不同于在申请的buffer上应用replacement new,需要手动调用对象析构函数后,再释放申请的buffer)

方案二,我们称为“AllocAndCall"方案,即构造函数只完成对象的内存分配和调用初始化方法的功能,即把在多个构造函数中都要初始化的部分“提取”出来,通常做为一个private和非虚方法(为什么不能是虚的参见上面第5点),然后在每个构造函数中调用此方法完成初始化。通常,这样的方法取名为init,initialize之类。

双击代码全选
1
2
3
4
5
6
7
8
class

AllocAndCall  
 {  
 private:  
     void

initial(...) {...}
//初始化集中这里  
 public:  
     AllocAndCall()
{ initial(); ...}  
     AllocAndCall(int

x) { initail(); ...}  
 };

这个方案和后面要详述的“工厂模式”,在一些思想上类似。

这个方案最大的不足,是在于,initial()初始化方法不是构造函数而不能使用初始化列表,对于非静态const成员的初始化将无能为力。也就是说,如果该类包含非静态的const成员(静态的成员初始化参看上一篇中的第2点),则对这些非静态const成员的初始化,必须要在每个构造函数的初始化列表完成,无法“抽取“到初始化方法中。

方案三,我们称为“C++ 0x“方案,这是C++ 0x中的新特性,叫做“委托构造函数”,通过在构造函数的初始化列表(注意不是构造函数体内)中调用其它构造函数,来得到相应目的。感谢C++ 0x!

双击代码全选
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class

CPerson  
 {  
 public:  
  CPerson()
: CPerson(0,
"")
{ NULL; }  
  CPerson(int

nAge) : CPerson(nAge,
"")
{ NULL; }  
  CPerson(int

nAge,
const

string &strName)  
  {  
   stringstream
ss;  
   ss
<< strName <<
"is
"

<< nAge <<
"years
old."
;  
   m_strInfo
= ss.str();  
  }  
         
 private:  
  string
m_strInfo;  
 };

其实,对于这样的问题,笔者认为,最好的解决方式,没有在这几种方案中讨论,仍是——使用“工厂模式”,替换公有构造函数。

中篇到此结束,下一篇将会有更多精彩内容——in C++ Constructor!。谢谢大家!

抱歉!评论已关闭.