C++ 合成默认构造函数的真相

  

 

对于C++默认构造函数,我曾经有两点误解

  • 类如果没有定义任何的构造函数,那么编译器(一定会!)将为类定义一个合成的默认构造函数。
  • 合成默认构造函数会初始化类中所有的数据成员。

  第一个误解来自于我学习C++的第一本书 《C++ Primer》,在书中392页:“只有当一个类没有定义构造函数时,编译器才会自动生成一个默认构造函数”

实际上这句话也没有说错,它说明了默认构造函数定义的必要非充分条件,然而却给当时初学C++的我造成了一定的误解。

  第二个误解依旧来自于Primer中的一句话:“合成的默认构造函数使用与变量初始化相同的规则来初始化成员。具有类类型的成员通过运行各自的默认构造函数来进行初始化”。然而这也是我理解的片面,因为Primer也说到了:“如果类包含内置或复合类型的成员,则该类不应该依赖于合成的默认构造函数”,言下之意就是合成的默认构造函数并不会初始化内置或复合类型的成员。

  总结了我有这些误解的原因,第一是初学时知识体系没形成,对Primer中所说的内容没有真正的理解,第二就是Primer在某种程度上的确不是C++初学者能看懂的书,或许看时觉得懂了,却是遗漏了很多知识。也说明了Primer 是座宝库,常常回顾将会有新的感悟。

  让我对上面两个观点产生疑惑,是在看《Effective C++》时,条款05《了解C++默认编写并调用哪些函数》中说到“….惟有当这些函数被需要(被调用),它们才会被编译器创建出来。” (“这些函数“指的是编译器版本的复制构造函数、赋值操作符和析构函数,还包括了默认构造函数。)也就是说,默认构造函数“被需要”的时候编译器才会帮我们合成,那什么情况才是默认构造函数”被需要“呢?这个问题《Effective C++》并没有给出答案,直到看了《深度探索C++对象模型》,才明白了编译器何时才会帮我们合成一个默认构造函数。

  我写这篇文章的目的是给和我有同样误解或疑惑的C++初学者看的,如果你对合成默认构造函数已有充分的认识,请忽略本文的内容。

正文

  • 什么是默认构造函数?

  默认构造函数是可以不用实参进行调用的构造函数,它包括了以下两种情况:

  1. 没有带明显形参的构造函数。
  2. 提供了默认实参的构造函数。    

  类设计者可以自己写一个默认构造函数。编译器帮我们写的默认构造函数,称为“合成的默认构造函数”。强调“没有带明显形参”的原因是,编译器总是会为我们的构造函数形参表插入一个隐含的this指针,所以”本质上”是没有不带形参的构造函数的,只有不带明显形参的构造函数,它就是默认构造函数。

  • 默认构造函数什么时候被调用?

   如果定义一个对象时没有提供初始化式,就使用默认构造函数。例如:

   

class A
{
public:
    A(bool _isTrue= true, int _num=10){ isTrue = isTrue; num = _num; }; //默认构造函数
    bool isTrue;
    int num;

};
int main()
{
    A a; //调用类A的默认构造函数
}
  • 理解“被需要”这三个字

  前面提到在《Effective C++》中指出惟有默认构造函数”被需要“的时候编译器才会合成默认构造函数。关键字眼是”被需要“。被谁需要?做什么事情?像下面这段代码,默认构造函数”被需要“了吗?

 

class A
{
public:
    bool isTrue;
    int num;

};
int main()
{
    A a;
    if (a.isTrue)
        cout << a.num;
    return 0;
}

  你可能认为这里定义类对象a的时候没有提供参数且A没有定义默认构造函数,编译器肯定是合成了一个默认构造函数并调用它来初始化A的数据成员,实则不是。当你试图查看合成默认构造函数把数据成员num初始化为什么值的时候,你会发现编译器甚至都让你运行不了程序:

 

  当类只含有内置类型或复合类型的成员时,编译器是不会为类合成默认构造函数的,这种类并不符合”被需要“的条件,甚至当类满足“被需要”条件,编译器合成了默认构造函数时,类中内置类型与复合类型数据成员依然不会在默认构造函数中进行初始化。Primer中也有提到:“如果类包含内置或复合类型的成员,则该类不应该依赖于合成的默认构造函数“。

  上面代码中,默认构造函数”被需要“是对程序来说的,程序需要isTrue被初始化以便可以进行条件判断,需要num被初始化以便可以输出。然而这种需要并不会促使编译器合成默认构造函数。惟有被编译器所需要时,编译器才会合成默认构造函数。那怎样的类才是编译器需要合成默认构造函数的呢?

  总结:

  1.   合成默认构造函数总是不会初始化类的内置类型及复合类型的数据成员。
  2.    分清楚默认构造函数被程序需要与被编译器需要,只有被编译器需要的默认构造函数,编译器才会合成它。
  •  何时默认构造函数才会被编译器需要?

  以下四种情况的类,编译器总是需要默认构造函数完成某些工作:

1. 含有类对象数据成员,该类对象类型有默认构造函数。

  如果一个类没有任何构造函数,但是它含有一个类对象数据成员,且该类对象类型有默认构造函数,那么编译器就会为该类合成一个默认构造函数,不过这个合成操作只有在构造函数真正需要被调用的时候才会发生。举个例子,编译器将为类B合成一个默认构造函数:

  

class A
{
public:
    A(bool _isTrue=true, int _num = 0){ isTrue = _isTrue; num = _num; }; //默认构造函数
    bool isTrue;
    int num;

};
class B
{
public:
    A a;//类A含有默认构造函数
    int b;
    //...
};
int main()
{
    B b;    //编译至此时,编译器将为B合成默认构造函数
    return 0;
}

 

  被合成的默认构造函数做了什么事情?大概如下面这样:

B::B()
{
    a.A::A();
}

  被合成的默认构造函数内只含必要的代码,它完成了对数据成员a的初始化,但不产生任何代码来初始化B::b。正如上面所说,初始化类的内置类型或复合类型成员是程序的责任而不是编译器的责任。为了满足程序的需要,我们一般会自己写构造函数来对B::b进行初始化,像这样:

 

B::B()
{
    a.A::A(); //编译器插入的代码
    b = 0;      //显示定义的代码
}

 

    如果类中有多种类对象成员,则编译器按照这些类对象成员声明的顺序,在构造函数按顺序插入调用各个类默认构造函数的代码。

2.基类带有默认构造函数的派生类。

  当一个类派生自一个含有默认构造函数的基类时,该类也符合编译器需要合成默认构造函数的条件。编译器合成的默认构造函数将根据基类声明顺序调用上层的基类默认构造函数。同样的道理,如果设计者定义了多个构造函数,编译器将不会重新定义一个合成默认构造函数,而是把合成默认构造函数的内容插入到每一个构造函数中去。

 

3. 带有虚函数的类  

  类带有虚函数可以分为两种情况:

  1. 类本身定义了自己的虚函数
  2. 类从继承体系中继承了虚函数(成员函数一旦被声明为虚函数,继承不会改变虚函数的”虚性质“)。

  这两种情况都使一个类成为带有虚函数的类。这样的类也满足编译器需要合成默认构造函数的类,原因是含有虚函数的类对象都含有一个虚表指针vptr,编译器需要对vptr设置初值以满足虚函数机制的正确运行,编译器会把这个设置初值的操作放在默认构造函数中。如果设计者没有定义任何一个默认构造函数,则编译器会合成一个默认构造函数完成上述操作,否则,编译器将在每一个构造函数中插入代码来完成相同的事情。

4.带有虚基类的类

  虚基类的概念是存在于类与类之间的,是一种相对的概念。例如类A虚继承于类X,则对于A来说,类X是类A的虚基类,而不能说类X就是一个虚基类。虚基类是为了解决多重继承下确保子类对象中每个父类只含有一个副本的问题,比如菱形继承。如下图: 

 

于是,类A对象中含有一份类X对象,类C中也含有一份类X对象,当我们遇上如下代码时:

class X  { public: int i; };
class A : public virtual X{ public:int j; };
class B : public virtual X{ public:double d; };
class C : public A, public B{ public: int k; };

void function(A *pa)
{
    pa->i = 1000;
}
int main()
{
    A *a= new A();
    C *c= new C();
    function(a);  //关注重点在这里
    function(c);     //关注重点在这里
    return 0;
}

  函数function参数pa的真正类型是可以改变的,既可以把A对象指针赋值给pa,也可以把对象指针赋值给pa,在编译阶段并无法确定pa存储的i是属于A还是C的虚基类对象。为了解决这问题,编译器将产生一个指向虚基类X的指针,使得程序得以在运行期确定经由pa而存取的X::i的实际存储位置。这个指针的安插,编译器将会在合成默认构造函数中完成,同样的,如果设计者已经写了多个构造函数,那么编译器不会重新写默认构造函数,而是把虚基类指针的安插代码插入已有的构造函数中。

  • 总结

  重新强调文章开篇所提,以下两个观点都是误解:

a)   任何类如果没有定义构造函数,则编译器会帮我们合成一个默认构造函数。

b)   合成默认构造函数会对类中的每一个数据成员进行初始化。

  只有在编译器需要默认构造函数来完成编译任务的时候,编译器才会为没有任何构造函数的类合成一个默认构造函数,或者是把这些操作插入到已有的构造函数中去。

编译器需要默认构造函数的四种情况,总结起来就是:

a)   调用对象成员或基类的默认构造函数。

b)   为对象初始化虚表指针与虚基类指针。

 

   PS:如果本文哪个地方阐述不清楚或者错误,十分期待指出,多谢! 

posted @ 2015-07-25 20:20  melonstreet  阅读(14229)  评论(7编辑  收藏  举报