深度探索C++对象模型——第二章 构造函数语义学
写在最前面:本文的内容主要来自《深度探索C++对象模型》这本书,里面还写了一些在看的过程中不明白的东西,还有一些自己的理解(希望那算是理解233),很多书上的代码没码上,因为我估计这篇也没多少人能看到(再次233)。
文章目录
本章主要介绍编译器对“对象构造过程”的干涉,和对于“程序形式”和“程序效率”上的影响。
需要注意
- 单一参数的constructor能被当做是一个conversion运算符。
- 使用
explicit
可以制止“单一参数的constructor"被当做一个conversion运算符。 - global objects的内存保证会在程序启动的时候被清为0;local objects配置于程序的堆栈中,heap objects配置于栈中,都不一定被清为0,它们的内容是内存上次被使用后的痕迹。
- 被合成的default constructor 只是为了满足编译器的需要(如构造),而非程序的需要(如不会为了初始化data member 而自动生成default constructor)。
- 如果default constructor已经被显式定义,那么编译器无法合成第二个。但是如果class内含有其他member class objects,而且constructor没有对其进行初始化,那么编译器会扩张已经存在的constructor,安插一些代码,使得user code在执行之前,先调用必要的default constructors。
2.1 Default Constructor 的构造操作
default constructor 在需要的时候被编译器产生出来。被谁需要?做什么事?
- answer:被编译器需要,而非被程序需要。如果是程序需要,那么这是程序员的责任。
只有编译器需要它的时候,才会合成一个default constructor,此外,被合成出来的constructor只执行编译器所需的行动。
- 对于Class X,如果没有任何user-declared constructor,那么会有一个default constructor 被隐式(implicitly)声明出来,一个被隐式声明出来的default constructor 将是一个trivial(没啥用的)constructor。
一个nontrivial default constructor就是编译器所需要的那种,必要的话会由编译器合成出来。下面是nontrivial default constructor 的四种情况:
①"带有Default Constructor"的Member Class Object
如果一个class 没有任何constructor,但它内含一个member object,而且这个member object有default constructor,那么这个class的implicit default constructor就是“nontrivial”。编译器需要为这个class合成一个default constructor,但这个合成的操作只有在constructor真正需要的时候才会发生。
出现了一个问题:如果在C++各不同的编译模块(compilation module,也就是不同的文件)中,编译器如何避免合成出多个default constructor(比如文件A和B都合成出了default constructor,分别是A.C,B.C)呢?
- 解决方案是把合成的default constructor、copy constructor、destructor和assignment copy operator都以inline方式完成。因为inline函数都有static linkage,不会被文件以外的文件看到。如果函数复杂,不适合做成inline,那么就会合成出explicit non-inline static实例。
如果default constructor已经被显式定义,那么编译器无法合成第二个。但是如果class内含有其他member class objects,而且constructor没有对其进行初始化,那么编译器会扩张已经存在的constructor,安插一些代码,使得user code在执行之前,先调用必要的default constructors。
如果有多个class member objects要求constructor初始化操作,行为如何?
- C++语言要求以member objects 在class中的声明顺序来调用各constructors。这一点由编译器完成,它为每个constructor安插程序代码,以"member声明顺序"调用每一个member所关联的default constructors。并且这些由编译器扩张的代码会安插在explicit user code之前,也就是,如果我们没有为这个包含多个class member objects的class创建default constructor,那么编译器会为我们生成一个nontrivial default constructor,这个constructor的作用是调用这个class object中的member class object的constructor以实例化member object,而如果我们自己写了default constructor,那么编译器就没有能力为我们再生成一个default constructor,因为default constructor只能有一个,但这个时候如果我们自己写的default constructor中没有显式初始化这些member class objects,那么编译器会在我们写的constructor代码(也就是explicit user code)之前,安插编译器生成的这些调用member class objects 的constructor的代码,并且安插的顺序是我们class中声明的顺序。
②“带有Default Constructor”的Base Class
在继承体系中,如果么有任何constructors的class派生自一个**"带有default constructor"的base class**,那么这个derived class的default constructor会被视为nontrivial,并因此需要被合成出来。这个合成的default constructor将调用上一层base classes的default constructor(根据它们声明的顺序),并且对于该class的后继派生的class而言,这个合成的constructor和一个“被显式提供的default constructor”没有什么差异。也就是说,如果后续的派生的class没有提供constructor,那么会根据这个合成的constructor来合成自己的constructor。
如果写了多个constructor ,但是其中都没有default constructor,编译器会扩张现有的每个constructor,将用来调用所有必要的default constructors的代码加进去。不会合成新的default constructor,因为已经提供了其他的constructor。同时,如果也存在”带有default constructor"的member class objects,那些default constructor也会被调用,但是是在所有base class constructor都被调用之后。
还有两种情况也会合成出default constructor:
- class 声明(或继承)一个virtual function。
- class派生自一个继承串链,其中有一个或多个virtual base classes。
③“带有一个Virtual Function”的Class
以下面程序为例:
class Widget{
public:
virtual void flip() = 0;
// ...
};
void flip(const Widget& widget) {widget.flip();}
// 假设Bell和Whistle 都派生自Widget
void foo()
{
Bell b;
Wistle w;
flip(b);
flip(w);
}
由于缺乏user声明的constructors,编译器会详细记录合成一个default constructor的必要信息。
在编译期间会发生下面两个扩张行为:
- 一个virtual function table(vtbl)会被编译器产生出来,里面存放class的virtual function address
- 每个class object内,编译器会合成一个pointer member(也就是vptr),内含相关的class vtbl的地址。
此外,widget.flip()
的虚拟调用操作(virtual invocation)会被重新改写,以使用widget的vptr和vtbl中的flip()条目:
// widget.flip()的虚拟调用操作的转变
(*widget.vptr[1])(&widget)
其中:
- 1 表示flip()在virtual table中的固定索引
- &widget表示要交给**“被调用的某个flip()函数实例”的this**指针
为使这个机制有用,编译器必须为每个Widget(或其派生类的)object的vptr设定初值(也就是让这个vptr指向vtbl)。对于class所定义的每个constructor,编译器都会插入一些代码来完成这样的事情。对于那些未声明任何constructors的classes,编译器会给它们合成一个default constructor,以正确地初始化每一个class object 的vptr。
④ “带有一个Virtual Base Class”的Class
Virtual base class的实现在不同编译器间有极大差异。Virtual base class和多重继承导致的菱形继承使vtbl有一些特殊的问题,具体在后面的第四章function 语义学里介绍(如果没鸽的话 😃
小结:
有四种情况,编译器必须为未声明constructor的classes合成一个default constructor。这些合成物被称为implicit nontrivial default constructor。这些被合成出来的constructor只能满足编译器的需要,而非程序的需要。
它们之所以能够完成任务,是依靠**“调用member object或base class的default constructor”,或是“为每个object初始化其virtual function机制或virtual base class机制**而完成。
对于除这四种情况外,并且没声明任何constructor 的classes,它们持有的是implicit trivial default constructor,它们实际上并不会被合成出来。
在编译器合成的default constructor 中,只有base class sub-objects和member class objects会被初始化。其他的non-static data member(如整数、整数指针、整数数组等)都不会被初始化。因为,这些初始化对编译器非必要(理解这一点很重要),它们或许只是对程序比较重要。如果程序需要一个将指针初始化为nullptr
的default constructor,那么完成这件事的应该是写程序的人,而非编译器。
两个常见的误解:
- 任何class如果没有定义default constructor ,就会被合成出一个(当然不是这样,首先如果定义了其他的constructor,那么编译器不会再生成default constructor,另外,如前面所介绍的那样,只有在四种情况下,编译器才会为了满足自己的需求产生出default constructor)
- 编译器合成出来的default constructor 会显式设定“class 内每个data member的默认值”(如前面所描述的,编译器合成的或扩展的constructor只会做对编译器来讲是必要的事,而非对于程序的)
2.2 Copy Constructor 的构造操作
copy constructor是一个constructor,其有一个参数,类型是其class type。
有三种情况,会以一个object的内容作为另一个class object的初值。
第一种,也是最明显的,就是对一个object做显式初始化操作:
class X {...};
X x;
// 显式地以一个object的内容作为另一个class object的初值
X xx = x;
另外两种情况是,当object被当做参数交给某个函数时,如:
extern void foo(X x);
void bar()
{
X xx;
// 以 xx 作为foo() 的一个参数的初值(隐式的初始化操作)
foo(xx);
// ...
}
以及当函数传回一个class object时,如:
X foo_bar()
{
X xx;
// ...
return xx;
}
Default Member-wise Initialization
考虑class没有提供一个explicit copy constructor的情况。当class object以相同class 的另一个object作为初值时,内部会发生default member-wise initialization,也就是把每一个内建的或派生的data member的值(注意是值),从某个object拷贝一份,到另一个object身上。不过它并不会拷贝其中的member class object,而是以递归的方式施行member-wise initialization。(那member class object咋整,这个递归的方式施行member-wise initialization是啥意思>???
举个栗子:
class String{
public:
// ... 没有explicit copy constructor
private:
char* str;
int len;
};
一个String object的default member-wise initialization发生在下面情况:
String noun("book");
String verb = noun;
它的完成方式就好像对每个member进行设定:
//相当于下面:
verb.str = noun.str;
verb.len = noun.len;
如果一个String object被声明为另一个class的member,比如:
class Word
{
public:
// ... 没有explicit copy ctor
private:
int _occurs;
String _word; // 此时String object 成为 class Word的一个member
}
此时,一个Word object的default member-wise initialization会拷贝其内建的member _occurs,然后对于String member object _word递归的实施member-wise initialization。
“如果一个class未定义copy constructor,编译器就自动为它产生出一个”这句话不对。
应该是:“default constructors和copy constructors只有在必要的时候才由编译器产生”。
这里的必要,是指class不展现bitwise copy semantics 时。(注意,是不展现bitwise copy semantics)
一个class object可用两种方式复制得到,一种是被初始化(也就是这里所关心的在初始化时对于class object的拷贝行为),另一种是被指定(也就是对一个已有的对象进行拷贝行为),也就是assignment。概念上讲,这两个操作分别是以copy constructor和copy assignment constructor完成的。
如default constructor 一样,C++ Standard说,如果class没有声明一个copy constructor,就会有隐式的声明(implicitly declared)或隐式的定义(implicitly defined)出现。
也同前面的default constructor一样,C++ Standard将copy constructor分为trivial 和non-trivial两种。只有non-trivial的实例才会被合成到程序中。
决定一个copy constructor是否为trivial的标准在于,class是否展现出所谓的"bitwise copy semantics"。
Bitwise Copy Semantics(位拷贝)
考虑下面的栗子:
Word noun("book");
void foo()
{
Word verb = noun;
// ...
}
verb是由noun来初始化的。但是在没有得知class Word 的声明前,不可能预测这个初始化操作的程序行为。
如果class Word 定义了一个copy constructor,那么verb会调用这个cp ctor。
但如果没有定义explicit cp ctor,那么是否会有编译器合成的实例被调用,就得看这个class是否展现bitwise copy semantics了。
展现bitwise copy semantics的情况:
class Word{
public:
Word(const char*);
~Word() {delete [] str;}
// ...
private:
int cnt;
char* str;
}
未展现bitwise copy semantics的情况:
class Word{
public:
Word(const char*);
~Word() {delete [] str;}
// ...
private:
int cnt;
String str; // 注意,这里不一样
}
其中String声明了一个explicit cp ctor。
在这种情况下,编译器必须合成一个cp ctor,以便调用member class String object的cp ctor。
生成的pseudocode如下:
inline Word::Word(const Word& wd)
{
str.String::String(wd.str);
cnt = wd.cnt;
}
在被合成出来的copy constructor中,如整数、指针等non-class member也都会被复制。
总结一下,就是如果class的member的复制过程不需要调用copy constructor,那么这个就展现了bitwise copy semantics。那么如果我们没有定义copy constructor,编译器也不会帮我们生成copy constructor。
不要Bitwise Copy Semantics
一个class 不展现“bitwise copy semantics”的四种情况(也就是编译器会帮我们生成copy constructor的四种情况):
- 当class内含一个member object,而后者的class声明中有一个copy constructor(不论是class的设计者显式声明的,还是编译器合成的)。
- 当class继承自一个base class而后者存在一个copy constructor时(不论是被显式声明还是合成得到的)。
- 当class声明一个或多个virtual functions时。
- 当class派生自一个继承链,其中有一个或多个virtual base classes时。
前两种情况,编译器必须将member或base class的"copy constructor调用操作"安插到被合成的copy constructor中。
后两种情况比较复杂,介绍如下:
重新设定Virtual Table的指针
只要有一个class 声明了一个或多个virtual functions,那么程序在编译期间就会发生扩张操作:
- 增加一个virtual function table(vtbl),内含一个有作用的virtual function的地址。
- 每个class object都会被安插一个指向vtbl的指针vptr。
如果编译器对新产生的class object不能成功而且正确的设定其初值,那么后果很可怕。
因此,当编译器导入一个vptr到class中,这个class 就不再展现bitwise sematics了(为啥)。
因为编译器需要合成出一个copy constructor以使得vptr适当的初始化(而不是简单的把本来的vptr值copy过来)。
合成的copy constructor会显式设定object的vptr指向的class的virtual table,而不是从=的右边的class object中将其vptr值拷贝过来。
如果是相同的class 的object间发生的copy行为,那么可以直接使用bitwise copy ,因为它们的vptr指向相同的vtbl。
如果用derived class object去初始化base class object,那么derived class 中比base class多的部分会被切割掉(sliced)。
另外,通过一个class object去访问其member function(即使是virtual function),那只能访问到这个class object所在派生层级的那个function(主要是针对virtual function 讲)。而多态发生在通过指针或reference调用virtual member function的时候。而非通过class object 调用。这一点需要注意。
处理Virtual Base Class Sub-object
Virtual base class的存在需要特殊处理。
一个class object如果以另一个object作为初值,而后者有一个virtual base class sub-object,那么这也会导致“bitwise copy semantics”失效(也就是会使编译器有产生copy constructor的机会)。
每个编译器对虚拟继承的支持承诺,都代表必须让**"derived class object 中的virtual base class sub-object位置"在执行期就准备妥当**。维护“位置的完整性”是编译器的责任。“bitwise copy semantics"可能会破坏这个位置。因此,编译器必须在它自己生成的copy constructor中作出仲裁。
总结一下,就是在virtual base class存在的派生体系中,两个不同的class的object间发生的copy行为,需要通过copy constructor来改变一些东西,使得程序正常运行。具体来讲,编译器会合成copy constructor或安插一些代码,来设定virtual base class pointer/offset 的初值,对每个members执行必要的初始化操作,以及执行其他的内存相关工作。
小结:
有四种情况,class不再保持"bitwise copy semantics"(也就是不能简单的把member所在内存单元的值拷贝到目标object中相应member的内存单元位置)。这时如果default copy constructor未被声明的话,会被视为nontrivial。这四种情况下,如果缺乏一个已声明的copy constructor,编译器为了正确处理“以一个class object作为另一个class object的初值”,必须合成一个copy constructor。
2.3 程序转化语义学(Program Transformation Semantics)
这一节主要讨论编译器调用copy constructor的策略,以及这些策略如何影响我们的程序。
考虑下面的程序片段:
#include "X.h"
X foo()
{
X xx;
// ...
retrun xx;
}
可能会有下面两个假设:
- 每次调用foo(),都会传回xx的值
- 如果class X 定义了一个cp ctor,那么foo()调用时,保证该cp ctor也会被调用
对于以上两个假设:第一个假设的真实性,视class X的定义而定;第二个假设的真实性,视class X 的定义和C++编译器所提供的优化层级而定。
显式的初始化操作(Explicit Initialization)
对于以下定义:
X x0;
下面三个定义都是显式的以x0来初始化object:
void foo_bar()
{
X x1(x0); // 定义了 x1 ,下面分别定义了x2,x3
X x2 = x0;
X x3 = X(x0);
}
必要的程序转化有两个阶段:
- 重写每个定义,其中的初始化操作会被剥除;
- class 的cp ctor调用操作会被安插进去。
举个栗子,双阶段转化之后,foo_bar()可能看起来如下:
// 可能的程序转换
// c++ psudocode
void foo_bar()
{
X x1; // 定义被重写,初始化操作被剥除
X x2;
X x3;
// 编译器安插 X copy construction的调用操作
x1.X::X(x0);
x2.X::X(x1);
x3.X::X(x2);
}
参数的初始化
把一个class object当做参数传给 一个函数(或是作为函数的返回值),相当于一下形式的初始化操作:
X xx = arg;
其中xx代表形式参数(或返回值),而arg代表真正的参数值。
因此,对于下面这个函数:
void foo(X x0);
下面的这种调用方式:
X xx;
// ...
foo(xx);
将会要求局部实例(local instance)x0以member-wise的方式将xx作为初值。
在编译器的实现上,有一种策略是,导入所谓的临时性object,并调用copy constructor将它初始化,然后将此临时性object交给函数。
前一段程序代码将转换如下:
// C++ pseudocode
// 编译器产生出来的临时对象
X __temp0;
// 编译器对copy constructor的调用
__temp0.X::X(xx);
// 重新改写函数调用操作,以便使用上述的临时对象
foo(__temp0);
书上讲这里有个问题,但我没看懂是啥意思。
突然想明白了,上面这个地方必须得改成:
void foo(X& x0);
不然会发生无穷递归调用的copy constructor。
不对。。。还是不明白为啥本来不行。
另一种实现是,以“拷贝构建(copy construct)”的方式把实际参数之间构建在其应该的位置上,此位置视函数活动范围的不同,记录在堆栈中。函数返回前,局部对象的destructor会被执行。
返回值的初始化
X foo()
{
X xx;
// ...
retrun xx;
}
对于上述函数定义,bar()的返回值如何从局部对象xx中拷贝出来?
一种编译器的实现是:
- 首先加上一个额外参数,类型是class object的一个reference。这个参数放被“拷贝建构(copy constructed)”而得的返回值。
- 在return 指令之前安插一个copy constructor调用操作,以便将想传回的object的内容当做上述新增参数的初值。
这样的实现导致它并不返回任何值,其实现如下:
// 函数转换
// c++ pseudocode
void bar(X& __result)
{
X xx;
// 编译器所产生的default constructor调用操作
xx.X::X();
// .... 处理xx
// 编译器所产生的copy constructor操作
__result.X::X(xx);
return;
}
现在编译器必须转换每个bar()调用操作,以反映其新定义:
X xx = bar();
// 上面一条将会被转换为下面两个指令句
// 注意:这里不必施行default constructor
X xx;
bar(xx);
Optimization at he User Level
方法:定义一个**“计算用”**的constructor。
即不再写下面这样的代码:
X bar(const T& y, const & z)
{
X xx;
// ... 以y 和 z 处理xx
return xx;
}
上面的代码会要求xx被"member-wise"地拷贝到编译器产生的__result
中。
我们的优化方案是,重新定义一个新的constructor,来计算xx的值,并返回:
X bar(const T& y, const & z)
{
return X(y,z);
}
上面的代码__result
直接被计算出来,而不是被copy constructor拷贝而得。
这种类型的优化主要考虑有更佳的效率,而不是以“支持抽象化“为优先。
Copy Constructor:要还是不要?
在没有四种导致nontrivial copy constructor 的情况下,是否需要explicit copy constructor呢?
视情况而定,分析member-wise的操作导致的bit-wise copy 是否会导致memory leak,是否会导致address aliasing。
如果class需要大量的member-wise初始化操作,比如以by value的方式传回objects,那么提供一个copy constructor 的 explicit inline函数的实例就很合理——如果编译器提供NRV优化。
2.4 成员初始化列表 Member initialization List
有两种方法在constructor中设定class members的初值:通过member initialization list或constructor 函数本体之内。
这一节主要阐述:何时使用initialization才有意义,list内部的真正操作,一些微妙的陷阱。
为使程序顺利正常编译,而必须使用member initialization list的情况:
- 初始化一个reference member时
- 初始化一个const member时
- 调用一个base class的constructor,而它有一组参数时
- 调用一个member class的constructor,而它有一组参数时
考虑下面的栗子:
class Word{
String _name;
int _cnt;
public:
// 没有错误,但是效率太低
Word(){
_name=0;
_cnt=0;
}
}
上面的栗子有很大的开销:首先Word constructor先产生一个临时性的String object,然后将其初始化,然后以一个operator=
将临时性object指定给_name,然后调用临时object的析构函数。
但是用成员初始化列表就能将开销减少到只有一个_name 这个String object的constructor的调用。
member initialization list中到底发生了什么?
编译器会一一操作initialization list,然后以适当的顺序在constructor内安插初始化操作,并且在任何explicit user code之前。
需要注意的是:list中初始化的顺序是class 中member声明顺序决定的,而不是initialization list中的排列顺序决定的。
因此在成员初始化列表中要特别注意初始化的顺序,特别是某个member的初始化用到了前面刚初始化的member的值,这个时候需要分析他们在class中定义的先后顺序。
小结:编译器会对initialization list一一处理并可能重新排序,以反映members的声明顺序。它可能会安插一些代码到constructor体内,并置于任何explicit user code之前。