构造函数语意学


一、默认构造函数

什么时候会合成一个default constructor呢?当编译器需要它的时候!此外,被合成的constructor只执行编译器所需的行动

C++标准规定,对于类X,如果没有任何用户声明的构造函数,那么它会有一个default constructor被隐式(implicit)声明出来。一个被隐式声明出来的default constructor将是一个trivial constructor

一个nontrivial default constructor才是编译器需要的那种,必要的话会由编译器合成。共有4种情况。

1.1、带有default constructor的成员类对象

如果一个类没有任何构造函数,但它内含一个成员对象,该对象有default constructor,那么这个类的implicit default constructor就是nontrivial,编译器需要为该类合成出一个default constructor。不过这个合成操作只有在constructor真正需要被调用时才会发生。

于是出现了一个问题:C++编译器如何避免合成出多个default constructor呢?解决方法是把合成的default constructor、copy constructor、destructor、assignment copy constructor都以inline方式完成。一个inline函数有静态链接,不会被文件以外者看到。如果函数太复杂,不适合做成inline,就会合成出一个explicit non-inline static实例。

举个例子:
构造函数语意学
编译器会为类Bar合成一个default constructor,被合成的Bar default constructor内含必要的代码,能够调用类Foo的default constructor来处理成员对象foo,但它并不产生任何代码来初始化Bar::str。将Bar::foo初始化是编译器的责任,将Bar::str初始化则是程序员的责任。被合成的default constructor看起来可能像这样:
构造函数语意学
需要注意的是,被合成的default constructor只满足编译器的需要,而不是程序的需要。为了让程序正确执行,字符指针str也需要被初始化。假设程序员经由下面的default constructor提供了str的初始化操作:
构造函数语意学
现在程序的需求获得满足了,但是编译器还需要初始化成员对象foo。由于default constructor已经被显式地定义出来,编译器没办法合成第二个。编译器采取的行动是:如果类A内含一个或一个以上的成员类对象,那么类A的每一个构造函数必须调用每一个成员类的default constructor。编译器会扩张已经存在的构造函数,在其中安插一些代码,使得用户代码被执行之前,先调用必要的default constructor。在上述例子中,扩张后的constructor可能像这样:
构造函数语意学
如果有多个类成员对象都要求构造函数初始化操作,情况会如何呢?C++语言要求以成员对象在类中的声明顺序来调用各个构造函数。这一点由编译器完成,它为每一个构造函数安插程序代码,以成员声明顺序调用每一个成员所关联的default constructor。这些代码被安插在用户代码之前。

1.2、带有default constructor的基类

如果一个没有任何构造函数的类派生自一个带有default constructor的基类,那么这个派生类的default constructor会被视为nontrivial,因此需要被合成出来。它将调用上一层基类的default constructor(根据它们的声明顺序)。对一个后继派生的类而言,这个合成的构造函数和一个被显式提供的default constructor没有什么差异。

如果设计者提供多个构造函数,但其中都没有default constructor,情况如何呢?编译器会扩张现有的每一个构造函数,将用以调用所有必要的default constructor的程序代码加进去。它不会合成一个新的default constructor,因为存在其他由用户所提供的构造函数。如果同时存在着带有default constructor的成员类对象,那些default constructor 也会被调用——在所有基类构造函数都被调用之后。

1.3、带有一个虚函数的类

举个例子:
构造函数语意学
下面两个扩张行动会在编译期间发生:

  • 1、一个virtual function table(在cfront中被称为vtbl)会被编译器产生出来,其中包含类的虚函数地址
  • 2、在每一个类对象中,一个额外的指针(也就是vptr)会被编译器合成出来,内含相关vtbl的地址

此外,widget.flip()的虚调用操作(virtual invocation)会被重新改写,以使用widget的vptr和vtbl中的flip()条目:
构造函数语意学
有关虚函数表的内容,请参考Function语意学

为了让这个机制发挥功效,编译器必须为每一个Widget对象的vptr设定初值,放置适当的virtual table地址。对于类所定义的每一个构造函数,编译器会安插一些代码来做这样的事情。对于那些未声明任何构造函数的类,编译器会为它们合成一个default constructor,以便正确地初始化每一个类对象的vptr。

1.4、带有一个虚基类的类

虚基类的实现法在不同的编译器之间有极大的差异。然而,每一种实现法的共同点在于必须使虚基类在其每一个派生类对象中的位置,能够于执行期准备妥当。在下面这个例子中:
构造函数语意学
编译器无法固定住foo()之中经由pa而存取的 X::i 的实际偏移位置,因为pa的真正类型可以改变。编译器必须改变执行存取操作的那些代码,使 X::i 可以延迟至执行期才决定下来。上述foo()可能被改写为:
构造函数语意学
其中__vbcX 表示编译器所产生的指针,指向虚基类X。__vbcX是在类对象构造期间被完成的。对于类所定义的每一个构造函数,编译器会安插那些允许每一个虚基类的执行期存取操作的代码。如果类没有声明任何构造函数,编译器必须为它合成一个default constructor。

1.5、小结

有4种情况,会使编译器必须为未声明构造函数的类合成一个default constructor。C++标准把那些合成的构造函数称为implicit nontrivial default constructor。被合成出来的构造函数只能满足编译器(而非程序)的需要。它之所以能够完成任务,是借着"调用成员对象或基类的default constructor"或是"为每一个对象初始化其虚函数机制或虚基类机制"而完成的。至于不存在那4种情况而又没有声明任何构造函数的类,我们说它们拥有的是implicit trivial default constructor,它们实际上并不会被合成出来

在合成的default constructor中,只有基类的子对象和成员对象会被初始化。所有其它的非静态数据成员(如整数、整数指针、整数数组等等)都不会被初始化。这些初始化操作对程序而言或许有需要,但对编译器则非必要。

1.5.1、两个误解

C++新手一般有两个常见的误解:

  • 1、任何类如果没有定义default constructor,就会被合成出一个来
  • 2、编译器合成出来的default constructor会显式设定类内每一个数据成员的默认值

如你所见,没有一个是真的!

二、拷贝构造函数

有3种情况,会以一个对象的内容作为另一个对象的初值。

  • 第一种情况是对一个对象做显式的初始化操作,像这样:
    构造函数语意学
  • 第二种情况是当对象被当做某个函数的参数时,例如:
    构造函数语意学
  • 第三种情况是当函数返回一个类对象时,例如:
    构造函数语意学
    假设类显式定义了一个copy constructor,像这样:
    构造函数语意学
    那么在大部分情况下,当一个类对象以另一个同类实例作为初值,上述构造函数会被调用。这可能会导致一个临时性类对象的产生或导致程序代码的蜕变(或两者都有)。

2.1、default memberwise initialization

如果类没有提供一个explicit copy constructor又当如何呢?当类对象以相同类的另一个对象作为初值,其内部是以所谓的default memberwise initialization手法完成的,也就是把每一个内建的或派生的数据成员的值,从某个对象拷贝一份到另一个对象身上。不过它并不会拷贝其中的成员类对象,而是以递归的方式施行memberwise initialization。

这样的操作是怎么完成的呢?就是由copy constructor完成的。当一个类不含copy constructor时,编译器会在必要时为其合成一个。"必要"的意思是指当类不展现bitwise copy semantics时

就像default constructor一样,C++ Standard上说,如果类没有声明一个copy constructor,就会有隐式的声明(implicitly declared)或隐式的定义(implicitly defined)出现。C++ Standard把copy constructor区分为trivial 和nontrivial两种。只有nontrivial的实例才会被合成于程序之中。决定一个copy constructor是否为trivial的标准在于类是否展现出所谓的"bitwise copy semantics"。

2.2、不展现bitwise copy semantics

共有4种情况,在这些情况下类不展现出bitwise copy semantics:

  • 1、当类内含一个成员对象而该对象的类声明有一个copy constructor时(不论是被类设计者显式地声明,或是被编译器合成)
  • 2、当类继承自一个基类而该基类存在一个copy constructor时(不论是被类设计者显式地声明,或是被编译器合成)
  • 3、当类声明了一个或多个虚函数时
  • 4、当类派生自一个继承串链,其中有一个或多个虚基类时

对于前两种情况,编译器必须将成员或基类的copy constructor调用操作安插到被合成的copy constructor中。以下详细说明后两种情况。

2.3、带有虚函数的类

编译期间的两个程序扩张操作:

  • 增加一个vtbl,内含每一个有作用的虚函数的地址
  • 一个指向vtbl的指针vptr,安插在每一个类对象内

很显然,如果编译器对于每一个新产生的类对象的vptr不能成功而正确地设定好其初值,将导致可怕的后果。因此,当编译器导入一个vptr到类中时,该类就不再展现bitwise semantics了。现在,编译器需要合成出一个copy constructor以求将vptr适当地初始化。

举个例子:
构造函数语意学
当ZooAnimal类对象以另一个ZooAnimal对象作为初值,或Bear类对象以另一个Bear类对象作为初值,都可以直接靠bitwise copy semantics完成。例如:
构造函数语意学
yogi会被default Bear constructor初始化。而在构造函数中,yogi的vptr被设定指向Bear类的虚函数表。因此,把yogi的vptr值拷贝给winnie的vptr是安全的。如下所示:
构造函数语意学
当一个基类对象以其派生类对象的内容做初始化操作时,其vptr复制操作也必须保证安全,例如:
构造函数语意学
franny的vptr不可以被设定指向Bear 类的虚函数表(但如果yogi的vptr被直接bitwise copy的话,就会导致此结果),否则当通过基类的指针或引用调用虚函数时,就会发生错误。如下所示:
构造函数语意学
也就是说,合成出来的ZooAnimal copy constructor会显式设定对象的vptr指向ZooAnimal类的虚函数表,而不是直接从等号右边的类对象中将其vptr值拷贝过来。
构造函数语意学

2.4、带有虚基类的类

虚基类的存在需要特别处理。一个类对象如果以另一个对象作为初值,而后者有一个虚基类子对象,那么会使bitwise copy semantics失效。

每一个编译器对于虚继承的支持承诺,都代表必须让派生类对象中的虚基类子对象位置在执行器就准备妥当。维护位置的完整性是编译器的责任。bitwise copy semantics可能会破坏这个位置,所以编译器必须在它自己合成出来的copy constructor中做出仲裁。

举个例子:
构造函数语意学
编译器所产生的代码(用以调用ZooAnimal的default constructor、将Raccoon的vptr初始化,并定位出Raccoon中的ZooAnimal子对象)被安插在两个Raccoon constructor之内,成为其先头部队。

一个虚基类的存在会使bitwise copy semantics无效。问题并不发生于一个类对象以另一个同类的对象作为初值,而是发生于一个类对象以其派生类的某个对象作为初值。例如让Raccoon类对象以一个RedPanda对象作为初值,如下所示:
构造函数语意学
如果以一个RedPanda对象作为little_critter的初值,编译器必须判断后续当程序员企图存取其ZooAnimal子对象时是否能够正确地执行。在这种情况下,为了完成正确的little_critter初值设定,编译器必须合成一个copy constructor,安插一些代码以设定虚基类指针/偏移的初值,以及执行其他的内存相关工作。

三、程序转换语意学

3.1、显式的初始化操作

已知有这样的定义X x0;,下面的三个定义,每一个都明显地以x0来初始化其类对象:
构造函数语意学
必要的程序转化有两个阶段:

  • 1、重写每一个定义,其中的初始化操作会被剥除
  • 2、类的copy constructor调用操作会被安插进去

在明确的双阶段转化之后,foo_bar()可能看起来像这样:
构造函数语意学

3.2、参数的初始化

C++ Standard说,把一个类对象当做参数传给一个函数(或是作为一个函数的返回值),相当于初始化操作:X xx = arg;,其中xx代表形式参数(或返回值)而arg代表真正的参数值。

对于下面的代码:
构造函数语意学
将会要求局部实例x0以memberwise的方式将xx当做初值。在编译器实现技术上,有一种策略是导入所谓的临时对象,并调用copy constructor将它初始化,然后将此临时性对象交给函数。上述代码将被转换为:
构造函数语意学
然而这样的转换只解决了问题的一半。问题的另一半出在foo()的声明上。临时性对象先以类X的copy constructor正确地设定了初值,然后再以bitwise方式拷贝到x0这个局部实例中。foo()的声明因而也必须被转化,形式参数必须从原先的一个类对象改变为一个类对象的引用,像这样:void foo(X& x0);,其中类X声明了一个destructor,它会在foo()函数完成之后被调用,对付那个临时性的对象。

3.3、返回值的初始化

已知下面的函数定义:
构造函数语意学
bar()的返回值如何从局部对象xx中拷贝过来呢?Stroustrup在cfront中的解决做法是一个双阶段转化:

  • 1、首先加上一个额外参数,参数的类型是类对象的引用,这个参数将用来放置被"拷贝构建(copy constructed)"而得的返回值。
  • 2、在return指令之前安排一个copy constructor调用操作,以便将欲传回的对象内容当做上述新增参数的初值。

真正的返回值是什么?最后一个转化操作会重新改写函数,使它不传回任何值。bar()将会被转化为:
构造函数语意学
这样的编译器优化操作,有时候被称为Named Return Value(NRV)优化。

现在编译器必须转换每一个bar()调用操作,以反映其新定义。例如:
构造函数语意学
将被转换为:
构造函数语意学

四、成员初始化列表

当写下一个constructor时,就有机会设定类成员的初值。或者经由成员初始化列表,或者在constructor函数体内。除了下述4种情况必须使用成员初始化列表外,其他情况下,任何选择其实都差不多。

  • 1、当初始化一个引用成员时
  • 2、当初始化一个常量成员时
  • 3、当调用一个基类的构造函数,而它拥有一组参数时
  • 4、当调用一个成员类的构造函数,而它拥有一组参数时

一个明显的问题是:成员初始化列表中到底会发生什么事情?许多C++新手对于成员初始化列表的语法感到迷惑,他们误以为它是一组函数调用。当然不是!

编译器会一一操作成员初始化列表,以适当的顺序在构造函数之内安插初始化操作,并且在任何explicit user code之前。例如:
构造函数语意学
将被转化为:
构造函数语意学

五、总结

构造函数语意学