《深度探索C++对象模型》第二章重点梳理。主要内容包括:
- 默认构造函数的构建操作
- 拷贝构造函数的构建操作
- 使用列表进行初始化的背后工作
1 默认构造函数
有一种常见的说法是,如果定义一个类的时候没有显式的为其声明构造函数,那么编译器将会自动生成默认的构造函数,但一般来说编译器生成的默认构造函数对于程序而言是没有任何用处的,编译器生成默认构造函数完全是为了满足编译器的需要,而不是我们所编写的程序的需要。但在一些情况下,编译器生成的默认构造函数是有用的,下面将讨论这些情况。
1.1 带有默认构造函数的类对象成员
如果一个类没有显式声明构造函数,但包含一个声明了构造函数的类对象成员,那么这个类的默认构造函数就是有用的。编译器会为这个类生成一个默认构造函数,但这个生成操作只在构造函数真正需要被调用的时候才进行。
例如,有下面的代码:
1 | class Foo { public: Foo(), Foo(int)... }; |
类 Bar 中包含一个对象成员 foo,该对象有显式声明的构造函数,因此编译器会为 Bar 生成默认构造函数,这个默认构造函数会调用 Foo 的构造函数来初始化对象成员 foo,但不会初始化 Bar 的 str 成员,因为初始化对象成员是编译器需要的,编译器必须为每个对象分配内存,而初始化一个字符指针对于编译器来说不需要。所以编译器生成的默认构造函数看起来就是这样:
1 | inline Bar::Bar() |
编译器自动生成的构造函数、析构函数、拷贝构造、assignment 等都被指定为内联函数,如果函数太复杂不适合做成内联,就会生成一个显式的非内联静态(explicit non-inline static)函数实体。
为了让程序正确运行,我们需要把 str 成员也初始化,但这不能指望编译器来生成,需要手动实现:
1 | Bar::Bar() {str = 0;} |
这时,虽然显式声明了构造函数,但这个构造函数没有初始化对象成员 foo,所以也还是没有满足编译器的需求,因此编译器还是会自动生成构造函数,但这时编译器不会单独生成一个函数了,而是会在用户定义的构造函数上进行扩展,加入编译器需要的代码:
1 | Bar::Bar() { |
如果一个类中有多个对象成员,这些成员都有显式定义的构造函数,那么编译器自动生成构造函数时会按照对象成员的声明顺序调用他们的构造函数,比如:
1 | class Dopey { public: Dopey(); ...); |
这时编译器自动扩展后的构造函数如下:
1 | Snow_White::Snow_White() : sneezy(1024) |
1.2 带有默认构造函数的基类
如果一个没有显式定义构造函数的类派生于一个有显式构造函数的基类,那么这个派生类的默认构造函数是有用的。派生类自动生成的默认构造函数将调用上一层基类的默认构造函数(根据声明次序)。
如果程序员为这个派生类声明了多个构造函数,但就是没有默认构造函数,那么编译器会扩展程序员声明的所有构造函数,将调用所有必要的默认构造函数的代码加入这些构造函数中,这些必要的默认构造函数就包括其父类的默认构造函数,以及其对象成员的默认构造函数。
1.3 带有虚函数的类
对于以下两种情况,默认构造函数也是有用的:
- 带有虚函数的类
- 该类派生自一个继承链,这个继承链中有一个或多个带有虚函数的基类
这两种情况下编译器将会在默认构造函数中加入对虚函数表的操作,因此是有用的。
例如有下图的继承关系:
执行下面的代码:
1 | class Widget { |
在编译期间,编译器会进行如下两个扩展操作:
- 编译器会产生一张虚函数表 vtbl,其中存放类的虚函数地址
- 每一个类对象中,编译器会生成一个额外的指针成员 vptr,指向类的虚函数表
此外,widget.flip()
的虚拟引发(virtual invocation)操作会被重新改写,以使用 widget 的 vptr 和 vtbl 中的 flip() 条目:
1 | (*widget.vptr[1]) (&widget) |
其中 1 表示 flip() 在虚函数表中的索引,&widget
表示要交给被调用的某个 flip() 函数实体的 this 指针。
为了让这个机制发挥功效,编译器必须为每一个 Widget (或其派生类)的对象的 vptr 设定初值,放置适当的 virtual table 地址。对于 class 所定义的每一个构造函数,编译器会安插一些代码来做这样的事情。对于那些未声明任何构造函数的类,编译器会为它们生成一个默认构造函数,以便正确地初始化每一个 class object 的 vptr。
1.4 继承于虚拟基类的类
虚拟继承的实现方法在不同的编译器之间有极大的差异,但是每一种实现方法都必须保证虚拟基类(virtual base class)子对象(subobject)在其每一个派生类对象中的位置能够在执行期准备妥当。例如有如下继承关系:
有下面的代码:
1 | class X {public: int i;}; |
在编译期间编译器无法确定函数 foo 中 pa->X::i
的实际位置,因为 pa 的类型可以改变,其中包含的基类子对象的位置并不确定,因此编译器必须改变对虚拟基类成员进行存取操作的代码(例如上面的 pa->i = 1024
就是在对虚拟基类的成员进行存取操作),使得 pa->X::i
可以延迟到执行期才确定下来。
编译器通过在派生类对象中添加一个指向虚拟基类的指针来完成上述操作,任何对虚拟基类成员的存取操作都可以通过该指针完成,比如上面的代码会被编译器改写为:
1 | void foo(const A* pa) |
其中 __vbcX
是编译器为派生类对象生成的指向 virtual base class X 的指针。 __vbcX
是在对象构造期间产生的,对于类中声明的每一个构造函数,编译器都会加入构建 __vbcX
的代码,如果类中没有声明任何构造函数,那么编译器会生成一个默认构造函数来完成这件事。
1.5 总结
以上四种情况,会导致 C++ 编译器必须为没有显式声明构造函数的类生成(合成)一个默认构造函数。C++ 将这些默认构造函数称为 implicit nontrivial default constructors。
被生成(合成)出来的 constructor 只能满足编译器(而非程序)的需要,这样的构造函数之所以被称为 nontrivial ,是因为他们完成了一些对编译器来说必要的工作:
- 调用对象成员的构造函数
- 调用基类的构造函数
- 为对象初始化指向虚函数表的指针
- 为对象初始化其 virtual base class 的指针
至于不存在这四种情况而又没有显式声明构造函数的类,我们说它拥有的是 implicit trivial default constructors,这样的构造函数没有任何用处,实际上也不会被生成出来。
在生成(合成)的构造函数中,只有基类子对象和对象成员会被初始化,其他所有的非静态成员变量,如 int,*int,int[] 等,都不会被初始化,因为这些初始化或许对程序而言有用,但对编译器来说并不必要。
因此,以下两种常见说法是完全错误的:
- 任何没有显式声明默认构造函数的类都会由编译器产生一个默认构造函数
- 编译器生成的默认构造函数会将类中所有成员明确初始化
2 拷贝构造函数
有三种情况,会以一个对象的内容作为另一个对象的初值:
- 使用一个对象对另一个对象进行初始化:
1 | class X {...}; |
- 对象作为函数参数传递:
1 | extern void foo (X x); |
- 对象作为函数返回值:
1 | X foo() |
如果程序员在类中明确定义了一个拷贝构造函数(copy constructor),即以本类型作为参数的构造函数,比如:
1 | X::X(const X& X); |
那么在上述情况下,拷贝构造函数将被调用。但如果类中没有用户显式定义的拷贝构造函数,编译器将如何完成拷贝操作呢?
2.1 Default Memberwise(逐成员的) Initialization
当用户没有显式定义拷贝构造函数时,编译器内部是以 Default Memberwise Initialization(默认逐成员初始化) 方法完成该操作的。也就是把每一个内建的或派生的成员变量(指针或其他变量)的值,从某个对象拷贝到另一个对象身上,但它并不会拷贝其中的类对象成员(member class object),而是以递归的方式进行 Memberwise Initialization。
例如,有一个字符串类:
1 | class String { |
当我们把一个字符串对象作为另一个对象的初值时:
1 | String noun("Book"); |
Default Memberwise Initialization 将会像下面这样:
1 | verb.str = noun.str; |
如果一个 String 对象作为另一个类的成员:
1 | class word { |
那么 word 对象的 Default Memberwise Initialization 将会直接拷贝成员 _occurs
,然后在 _word
身上递归的进行 Memberwise Initialization,即递归的调用 String 对象的 Default Memberwise Initialization。
一个类对象可以从两种方式复制得到,一种是被初始化(即我们现在讨论的),另一种是被指定(assignment),将会之后的章节讨论。这两个操作分别是以 copy constructor 和 copy assignment operator 完成的。拷贝构造函数和上一节的默认构造函数一样,只在必要的时候才由编译器产生出来,因此“如果一个 class 未定义出 copy constructor,编译器就自动为它自动生成一个”这句话是不对的。并且拷贝构造函数也被分为有用的(nontrivial)和无用的(trivial),只有 nontrivial 的拷贝构造函数才会被生成或者合成到现有代码中,而决定一个拷贝构造函数是否是 nontrivial 的标准在于这个类是否展现出所谓的 “bitwise copy semantics”。
2.2 bitwise copy semantics(逐位次拷贝语义)
现有下面的程序:
1 |
|
显然 verb 是根据 noun 来初始化的,但是在没有看过 word 类的声明之前,我们无法预测这个初始化操作的程序行为,如果 word 类的设计者定义了一个拷贝构造函数,verb 的初始化就会调用它,但如果没有显式定义的拷贝构造函数,编译器是否会为 word 类生成一个拷贝构造函数呢?这就得视 word 类是否展现出 “bitwise copy semantics” 而定。
举例说明,比如 word 类有如下定义:
1 | // 以下声明展现了 bitwise copy semantics |
这种情况下并不需要合成出一个拷贝构造函数,因为上面的声明展现出了 default copy semantics,于是 verb 的初始化也就不需要以一个函数调用进行。但如果 word 类的声明如下:
1 | // 以下声明没有展现 bitwise copy semantics |
其中 String 类显式声明了拷贝构造函数:
1 | class String { |
在这个情况下,编译器必须合成出一个拷贝构造函数,以便调用对象成员的拷贝构造函数:
1 | inline word::word(const word& wd) |
可以看到生成出来的拷贝构造函数也会将非对象成员进行复制。
一个类在四种情况下不会展现出 bitwise copy semantics,即会由编译器生成拷贝构造函数:
- 当 class 中包含一个对象成员而后者的类声明有一个拷贝构造函数时(这个拷贝构造函数可以是被用户显式声明的,就像前面的 String 那样,也可以是被编译器合成的,像 word 那样)
- 当 class 继承自一个 base class 而后者存在有一个 copy constructor 时(同样可以是用户定义的,也可以是编译器生成的)
- 当 class 声明了一个或多个 virtual functions 时
- 当 class 派生自一个继承串链,其中有一个或多个 virtual base classes 时
前两种情况中,编译器必须将对象成员或基类的拷贝构造函数调用代码安插到生成的拷贝构造函数中,而后两种情况较为复杂,下面具体讨论。
2.3 重新设定 Virtual Table 指针
首先是第三种情况。当类中声明了虚函数时,编译器会为类生成一个虚函数表,并在每一个类对象中增加一个指向虚函数表的指针 vptr,因此在拷贝构造函数中必须要对对象的 vptr 进行正确的赋值,否则后果不堪设想,所以这种情况下编译器必须生成拷贝构造函数。
假设有下面的类:
1 | class ZooAnimal { |
ZooAnimal 对象以另一个 ZooAnimal 对象作为初值,或者 Bear 对象以另一个 Bear 对象作为初值都可以直接靠 bitwise copy semantics 来完成,比如:
1 | Bear yogi; |
yogi 会被 Bear 的默认构造函数初始化,在构造函数中,yogi 的 vptr 被设定指向 Bear 类的虚函数表(靠编译器安插代码完成),因此直接把 yogi 的 vptr 值复制给 winnie 是完全没问题的,二者的关系如下图:
但是当一个基类对象以一个派生类对象作为初值进行初始化时,其 vptr 复制操作就不是这么简单了,如果直接把派生类对象的 vptr 复制给基类对象,就会发生严重错误。例如:
1 | ZooAnimal franny = yogi; |
如果 franny 的 vptr 被复制为 yogi 的 vptr,那么相当于一个 ZooAnimal 类的对象会调用 Bear 派生类的函数实体,这就会发生严重错误。因此编译器必须保证在拷贝构造函数中为基类对象设定正确的 vptr 值。
事实上,上述代码执行的会是 ZooAnimal 的 draw 方法,因为 franny 是一个 ZooAnimal 对象,yogi 中的 Bear 部分已经在 franny 初始化时被切割(sliced)掉了,如果 franny 被声明为一个指针或者引用,那么 franny.draw()
才会调用 Bear 的 draw 方法。
也就是说,编译器自动生成的拷贝构造函数会明确设定基类对象的 vptr 指向基类的虚函数表,而不是直接从派生类中复制 vptr 的值。
2.4 处理 Virtual Base Class Subobject
接下来讨论第四种情况。一个类对象以另一个对象作为初值,而后者有一个 virtual base class subobject,那么也会使 bitwise copy semantics 失效,从而编译器必须生成拷贝构造函数。
之前学习过,编译器必须保证 virtual base class subobject 在其每一个派生类对象中的位置能够在执行期准备妥当,但 bitwise copy semantics 可能会破坏这个位置,所以编译器必须生成一个拷贝构造函数来处理这一事件。
比如有下面的声明:
1 | class Raccoon : public virtual ZooAnimal { |
类之间的继承关系如下:
显然,编译器首先会安插一些代码在 Raccoon 的构造函数中,包括:调用基类 ZooAnimal 的默认构造函数,设定 Raccoon 的 vptr 值,定位出 Raccoon 中 ZooAnimal 子对象的位置等。
虽然 Raccoon 类中含有一个虚拟基类子对象,但当我们用 Raccoon 对象初始化另一个 Raccoon 对象时,也不会出现额外的问题,只依靠 bitwise copy 即可。问题在于“用派生类对象初始化一个基类对象”,比如现在有一个 RedPanda 类继承于 Raccoon 类:
1 | class RedPanda : public Raccoon { |
类继承关系如下:
使用一个 RedPanda 对象初始化 Raccoon 对象:
1 | RedPanda little_red; |
这时,只依靠 bitwise copy 就不够了,编译器必须明确的将 little_critter 的 virtual base class subobject 指针初始化,以指出 virtual base class subobject 在哪。
这种情况下,为了正确完成 little_critter 的初值设定,编译器必须生成一个拷贝构造函数,安插一些代码以设定 virtual base class subobject 指针的初值,对每一个成员执行必要的逐成员初始化,以及其他内存相关工作。
2.5 总结
我们已经讨论过所有四种情况,这四种情况实际上和上一节默认构造函数的四种情况一致。在这些情况下,类不再保持 bitwise copy semantics,并且默认拷贝构造函数未被声明的话,会被视为 nontrivial,于是编译器为了正确处理“以一个对象作为另一个对象初值”的情况,必须生成或合成一个拷贝构造函数。
3 使用列表初始化成员
C++ 中书写构造函数有一种特殊的写法,就是使用列表对类成员进行初始化,这一节来了解使用列表对成员进行初始化的背后发生了什么。
首先来看这样的一个类的构造函数:
1 | class word { |
这样写构造函数自然没有问题,但效率很低,编译器会先产生一个临时的 String 对象,然后将它初始化,再以一个 assigment 运算符将临时的对象指定给 _name
成员,最后再摧毁临时对象。所以经过编译器扩展后的代码看起来就是这样:
1 | word::word() |
但如果我们把构造函数写成这样:
1 | word::word() : _name(0) |
编译器将会将代码扩展成这样:
1 | word::word() |
所以使用列表是较好的方式,因此最好坚持列表初始化的代码习惯:
1 | word::word() : _name(0), _cnt(0) {} |
但有时我们不得不在函数体中指定成员初始值,比如要用一个成员初始化另一个成员,这时我们需要了解列表初始化背后到底发生了什么。
编译器会逐个操作初始化列表,以适当的次序将代码安插在构造函数体内,并且安插的代码会在用户定义的代码之前。这里的“适当的次序”是指成员变量在类中声明的次序,而不是初始化列表中书写的顺序。例如:
1 | class X { |
这里看起来是用 val 初始化 j,再用 j 初始化 i,但实际上列表初始化会按照成员声明次序扩展代码,因此会先为 i 赋值,但此时 j 还没有被赋值,因此会得到错误的结果。这时我们可以将构造函数写成这样,来避免问题:
1 | class X { |
因为编译器扩展的代码会在用户定义代码之前,所以会先为 j 赋值,再为 i 赋值。
总结一下,当存在以下情况时,尽量选择使用列表队成员进行初始化:
- 初始化一个 reference 成员时
- 初始化一个 const 成员时
- 当调用一个基类的构造函数,而它拥有一组参数时
- 当调用一个对象成员的构造函数,而它拥有一组参数时
4 总结
最后总结构造函数的执行过程:
- 在派生类构造函数中,所有虚基类和上一层基类的构造函数首先会按顺序被调用,以生成基类子对象
- 上述完成后,对象的 vptr 会被初始化,指向正确的虚函数表
- 如果有成员初始化列表的话,将在 vptr 被设定后扩展开来,以免其中调用了虚函数
- 如果有对象成员且其有构造函数,调用对象成员的构造函数
- 最后执行程序员提供的初始化代码