深入探索C++对象模型(二)构造函数语义学

1. 默认构造函数会在需要的时候被编译器产生出来,要注意字眼“需要的时候”,例如如下代码:

[cpp]  view plain  copy
  1. class Bat{  
  2. public:  
  3.     int val;  
  4.     Bat* pNext;  
  5.     void bat_yell();  
  6. };  
  7.   
  8. void Bat::bat_yell()  
  9. {  
  10.     Bat batMan;  
  11.     if(batMan.val || batMan.pNext)  
  12.         // ...do something  
  13.     // ...        
  14. }  

按照程序员的本意,在bat_yell()方法中,batMan会调用默认构造函数将val和pNext初始化为0,但是实际结果并不是这样,程序员的需要并不代表编译器需要,当然,程序员的责任也不该被比那一起所承担,归根结底说来,上述代码片段中,并不会有默认构造函数被合成。退一步讲,即便是有默认的构造函数被合成出来了,也不会将上述的数据成员初始化为0。也就是说此时的默认构造函数是trivial (无用的,不重要的),有四种情况编译器会产生nontrivial(重要的,有用的)的默认构造函数:

a. 带有默认构造函数的类成员对象:


Bar的类成员对象foo含有默认构造函数,所以编译器会为类Bar合成默认构造函数,可能看起来像下面这样:

值得注意的是,即便是编译器为我们合成了默认构造函数,但是并不会产生任何代码来初始化Bar的str成员变量,这个是程序员的责任。那么程序员如果提供了构造函数(默认或者带参数的),编译器会做些什么呢?答案是编译器会扩展以后的构造函数,在每一个构造函数中安插代码,代码会被安插到程序员代码的前面,使得每一个构造函数调用每一个带有默认构造函数的成员变量的默认构造函数,有一点绕,看个具体例子:

b. 第二种情况是基类中由默认构造函数,类似于第一种情况,如果一个没有定义默认构造函数的类继承(派生)于一个带有默认构造函数的基类,那么这个子类的默认构造函数会被合成出来,在其中以按照声明顺序分别调用基类们的构造函数。如果类设计者提供了多个构造函数,但是没有默认构造函数,编译器会扩张已有的构造函数,而不会合成一个新的默认构造函数。如果此类还包含第一种情况:含有存在默认构造函数的成员类对象,那么他们的默认构造函数会在调用基类默认构造函数之后调用。
c. 带有虚函数的类,由于虚函数的存在,会产生虚函数表和虚表指针,而这些必须在对象构造期间安排好,所以对于类所定义的每一个构造函数,编译器会安插一些代码来做这样的事情,对于未声明任何构造函数类,编译器会为他们产生一个默认的构造函数,以便正确的初始化每一个类对象的虚表指针。
d. 继承自虚基类的类,由于在虚拟继承下子类会存在指向虚基类的指针,与第三种情况类似。

总之,以上四种情况编译器会合成一个隐式的重要的默认构造函数(implicit nontrivial default constructor)来满足编译器(而非程序)的需要:调用成员类或者基类的默认构造函数,或者完成每一个对象初始化其虚函数机制或者虚基类机制。对于其他情况并且没有声明任何构造函数的类,他们拥有的是隐式的不重要的默认构造函数(implicit trivial default constructor),实际上并不会被合成出来。
一定要注意,合成出来的默认构造函数只会初始化编译器认为的必要的部分(基类对象,类对象),其他数据成员并不会被初始化。


Default Constructor的构造操作

对于class X,如果没有任何user-declared constructor,那么会有一个default constructor被隐式(implicitly)声明出来...一个被隐式声明出来的default constructor将是一个trivial(浅薄而无能,没啥用的)constructor...

一个nontrivial default constructor在ARM(注释参考手册)的术语中就是编译器需要的那种,必要的话由编译器合成出来。下面4小节分别讨论nontrivial default constructor的4种情况

“带有Default Constructor”的member class object

如果一个class没有任何constructor,但它内含一个member object,而后者有default constructor,那么这个class的implicit default constructor就是“nontrivial”,编译器为该class合成出一个default constructor。不过这个合成操作只有在constructor真正需要被调用时才会发生。

举例如下:编译器会为class Bar合成一个default constructor:

class Foo{ public: Foo(), Foo(int) ...};
class Bar{ public: Foo foo; char *str; };  

void foo_bar(){
    Bar bar;   //注意Bar::foo必须在此初始化
    if(str) { } ... 
}

被合成的Bar default constructor内含必要的代码,能够调用class Foo的default constructor来处理member Bar::foo,但它并不产生任何码来初始化Bar::str。将Bar::foo初始化时编译器的责任,将Bar::str初始化则是程序员的责任。

如果有多个class member objects都要求constructor初始化操作,将如何? C++语言要求以“member objects在class中的声明顺序”来调用各个constructors

“带有Default constructor”的base class

如果一个没有任何constructors的class派生自一个“带有default constructor”的base class,那么这个derived class的default constructor会被视为nontrivial,并因此需要被合成出来。它将调用上一层base classes的default constructor(根据它们的声明的顺序)。对于一个后继派生的class而言,这个合成的constructor和一个“被显式提供的default constructor”并没有差异

“带有一个Virtual Funtion”的class

  • class声明(或继承)一个virtual function
  • class派生自一个继承串链,其中有一个或更多的virtual base classes

不管哪一种情况,由于缺乏由user声明的constructors,编译器会详细记录合成一个default constructor的必要信息。以下面程序段为例:

class Widget{
public:
    virtual void flip() = 0;
    //...
};

void flip(const Widget& widget) { widget.flip();}

//假设Bell和Whistle都派生自Widget
void foo(){
    Bell b;
    Whistle w;
    
    flip(b);
    flip(w);
}

下面两个扩张行动会在编译期间发生:

  • 一个virtual function table(在cfront中被称为vtbl)会被编译期产生出来,内放class的virtual functions地址
  • 在每一个class object中,一个额外的pointer member(也就是vptr)会被编译期合成出来,内含相关之class vtbl的地址

此外,widget.flip()的虚拟引发操作(virtual invocation)会被重新改写,已使用widget的vptr和vtabl中的flip()条目

//widget.flip()的虚拟引发操作的转变
(*widget.vptr[1])(&widget)

其中:

  • 1 表示flip()在virtual table中的固定索引
  • &widget代表要交给“被调用的某个flip()函数实体”的this指针

“带有一个virtual base class”的class

Virtual base class的实现法在不同的编译器之间有极大的差异。然而,每一种实现法的共同点在于必须使virtual base在其每一个derived class object中的位置,能够于执行期准备妥当。

Copy Constructor的构造操作

有三种情况,会以一个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;
}

假设class设计者明确定义了一个copy constructor(这是一个constructor,有一个参数的类型是其class type),像下面这样:

//user-defined copy constructor实例
//可以是多参数形式,其第二个参数及后继参数以一个默认值供应之
X::X(const X& x);
Y::Y(const Y& y, int = 0);

那么在大部分情况下,当一个class object以另一个同类实体作为初值时,上述的constructor会被调用,这可能会导致一个暂时性class object的产生或程序代码的蜕变(或两者都有)

Default memberwise initialization

如果class没有提供一个explicit copy constructor又当如何?当class object以“相同class的另一个object”作为初值,其内部是以所谓default memberwise initialization手法完成的,也就是把每一个內建的或派生的data member(例如一个指针或一个数组)的值,从某个object拷贝一份到另一个object身上。不过它并不会拷贝其中的member class object,而是以递归的方式施行memberwise initialization。例如:

class String{
public:
    //... 没有explicit copy constructor
private:
    char *str;
    int len;
};

一个String object的default memberwise initialization发生在这种情况之下:

String noun("book");
String verb = noun;

其完成方式就好像个别设定每一个members一样:

//语义相等
verb.str = noun.str;
verb.len = noun.len;

一个class object可用两种方式复制得到,一种是被初始化,另一种是被指定。从概念上看,这两种操作分别是以copy constructor和copy assignment operator完成的。

Bitwise Copy Semantics(位逐次拷贝)

什么时候一个class不展现出“bitwise copy semantics”呢?有4种情况:

  1. 当class内含一个member object而后者的class声明有一个copy constructor时。(不论是被class设计者明确声明,或是被编译器合成)
  2. 当class继承自一个base class而后者存在一个copy constructor时(再次强调,不论是被显式声明或是被合成而得)
  3. 当class声明了一个或多个virtual function时
  4. 当class派生自一个继承串链,其中有一个或多个virtual base classes时

前两种情况中,编译器必须将member或base class的"copy constructors调用操作"安插到被合成的copy constructor中。

重新设定Virtual Table的指针

回忆编译期间的两个程序扩张操作(只要有一个class声明了一个或多个virtual functions就会如此):

  • 增加一个virtual function table(vtbl),内含每一个有作用的virtual function的地址
  • 将一个指向virtual function table的指针(vptr),安插在每一个class object内

当编译器导入一个vptr到class之中时,该class就不再展现bitwise semantics了。现在编译器需要合成出一个copy constructor,以求将vptr适当地初始化。下面是个例子

class ZooAnimal{
public:
    ZooAnimal();
    virtual ~ZooAnimal();
    virtual void animate();
    virtual void draw();
    //...
private:
    //ZooAnimal的animate()和draw()所需要的数据
};

class Bear : public ZooAnimal{
public:
    Bear();
    void animate();   //虽未写明是virtual,但其实是virtual
    void draw();      //同上
    virtual void dance();
    //...
private:
    //Bear的animate()和draw()和dance()所需要的数据
};

ZooAnimal class object以另一个ZooAnimal class object作为初值,或Bear class object以另一个Bear class object作为初值,都可以直接靠"bitwise copy semantics"完成。举个例子

Bear yogi;
Bear winnie = yogi;

yogi会被default Bear constructor初始化,而在constructor中,yogi的vptr被设定指向Bear class的virtual table(靠编译器安插的码完成),因此,把yogi的vptr值拷贝给winnie的vptr是安全的。

当一个base class object以其derived class的object内容做初始化操作时,其vptr复制操作也必须保证安全,例如:

ZooAnimal franny = yogi;   //这会发生切割(sliced)行为

franny的vptr不可以被设定指向Bear class的virtual table(但如果yogi的vptr被直接“bitwise copy”的话,就会导致此结果),否则当下面程序片段中的draw()被调用而franny被传进去时,就会“炸毁”(blow up):

void draw(const ZooAnimal& zoey) { zoey.draw(); }
void foo(){
    //franny的vptr指向ZooAnimal的virtual table,
    //而非Bear的virtual table(彼由yogi的vptr指出)
    ZooAnimal franny = yogi;
    
    draw(yogi);    //调用Bear::draw()
    draw(franny);  //调用ZooAnimal::dram()
}

也就是说,合成出来的ZooAnimal copy constructor会明确设定object的vptr指向ZooAnimal class的virtual table, 而不是直接从右手边的class object中将其vptr现值拷贝过来。

处理virtual base class subobject

virtual base class的存在需要特别处理。一个class object如果以另一个object作为初值,而后者有一个virtual base class subobject,那么也会使“bitwise copy semantics”失效

每一个编译器对于虚拟继承的支持的承诺,都代表必须让“derived class object中的virtual base class subobject位置”在执行期就准备妥当。维护“位置的完整性”是编译器的责任。“Bitwise copy semantics”可能会破坏这个位置,所以编译器必须在它自己合成出来的copy constructor中做出仲裁。举例如下:

class Raccoon : public virtual ZooAnimal{
public:
    Raccoon() { /*设定private data初值*/ }
    Raccoon(int val) { /*设定private data初值*/}
    //...
private:
    //所有必要的数据
};

编译器所产生的代码(用以调用ZooAnimal的default constructor、将Raccoon的vptr初始化,并定位出Raccoon中的ZooAnimal subobject)被安插在两个Raccoon constructors之内,成为其先头部队

在"memberwise 初始化"呢? 一个virtual base class的存在会使bitwise copy semantics无效,注意,这个问题并不发生在“一个class object以另一个同类的object作为初值”之时,而是发生在“一个class object以其derived classes的某个object作为初值”之时。举例如下:

class RedPanda : public Raccoon{
public:
    RedPanda() { /*设定private data初值*/ }
    RedPanda(int val){ /*设定private data初值*/ }
    //...
private:
    //所有必要的数据
};

强调,如果以一个Raccoon object作为另一个Raccoon object的初值,那么bitwise copy就绰绰有余了。

//简单的bitwise copy就足够了
Raccoon rocky;
Raccoon little_critter = rocky;

然而如果企图以一个RedPanda object作为little_critter的初值,编译器必须判断“后续当程序员企图存取其ZooAnimal subobject时是否能够正确地执行”

//简单的bitwise copy还不够
//编译器必须明确将little_critter的
//virtual base class pointer/ooset初始化
RedPanda little_red;
Raccoon little_critter = little_red;

在这种情况下,为了完成正确的little_critter初值设定,编译器必须合成一个copy constructor,安插一些码以设定virtual base class pointer/offset的初值(或只是简单地确定它没有被抹消),对每一个members执行毕业得memberwise初始化操作,以及执行其它的内存相关工作。

程序转化语意学(Program Transformation Semantics)

显式的初始化操作

必要的程序转化有两个阶段

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

参数的初始化

C++ Standard说,把一个class object当做参数传给一个函数(或是作为一个函数的返回值),相当于以下形式的初始化操作:

X xx = arg;

其中xx代表形式参数(或返回值)而arg代表真正的参数值,因此,若已知如下函数:

void foo(X xo);

下面的调用方式:

X xx;
//...
foo(xx);

将会要求局部实体(local instance) xo以membeerwise的方式将xx当作初值。

在编译器实现技术上,有一种策略是导入所谓的临时性object,并调用copy constructor将它初始化,然后将此临时性object交给函数。例如,前一段的代码转换如下:

//C++伪码
//编译器产生出来的临时对象
X _temp0;

//编译器对copy constructor的调用
_temp0.X::X(xx);
//重新改写函数调用操作,以便使用上述的暂时对象
foo(_temp0);

然而这样的转换只做了一半功夫而已,残留问题如下:问题出在foo()的声明。暂时性object先以class X的copy constructor正确设定了初值,然后再以bitwise防守拷贝到xo这个局部实体中(所以,不能按照以往的声明)。因此,foo()的声明因而也必须被转化,形式参数必须从原先一个class X object改变为一个class X reference。如下:

void foo(X& xo);

其中class X声明了一个destructor,它会在foo()函数完成之后被调用,对付那个暂时性的object。

另外一种实现方法是以“拷贝建构”(copy construct)的方式把实际参数直接建构在其应该的位置上,此位置视函数活动范围的不同,记录于程序堆栈中。在函数返回之前,局部对象(local object)的destructor(如果有定义的话)会被执行。

返回值得初始化

已知下面这个函数定义:

X bar(){
    X xx;
    //处理xx ...
    return xx;
}

bar()的返回值如何从局部对象xx中拷贝过来? Stroustrup在cfront中的解决办法是一个双阶段的转化:

  1. 首先加上一个额外参数,其类型是class object的一个reference,这个参数将被用来放置被“拷贝建构”而得的返回值
  2. 在return指令之前安插一个copy constructor调用操作,以便将欲传回之object的内容当做上述新增参数的初值。

真正的返回值是什么? 最后一个转换操作会重新改写函数,使它不传回任何值。bar()转换如下:

//函数转换,以反映copy constructor的应用
void bar(X& _result){
    X xx;
    
    //编译器所产生的default constructor调用操作
    xx.X::X();
    
    //...处理 xx
    
    //编译器所产生的copy constructor调用操作
    _result.X::X(xx);
    
    return;
}

在编译器层面做优化

在一个如bar()这样的函数,所有的return指令传回相同的具名数值(name value,即是指函数中的xx),因此编译器有可能自己做优化,方法是以result参数取代name return val。例如原bar()函数,可能被转换为:

void bar(X& _result){
    //default constructor被调用
    _result.X::X();
    
    //...直接处理_result;
    
    return;
}

这样的编译器优化操作,有时被称为Named Return Value(NRV)优化。 NRV优化如今被视为是标准C++编译器的一个义不容辞的优化操作——虽然其需求其实超越了正式标准之外。

虽然NRV优化提供了重要的效率改善,它还是饱受批评。其中一个原因是,优化由编译器默认完成,而它是否真的被完成,并不十分清楚。第二个原因是,一旦函数变得比较复杂,优化也就变得比较难以施行。

下面例子,三个初始化操作在语义上相等:

X xx0(1024);
X xx1 = X(1024);
X xx2 = (X)1024;

但是在第二行和第三行中,语法明显提供了两个步骤的初始化操作:

  1. 将一个暂时性的object设以初值1024
  2. 将暂时性的object以拷贝建构的方式作为explicit object的初值

换句话说,xx0是被单一的constructor操作设定初值:

xx0.X::X(1024);

而xx1或xx2却调用两个constructor,产生一个暂时性object,并针对该暂时性object调用class X的destructor

X _temp0;
_temp0.X::X(1024);
xx1.X::X(_temp0);
_temp0.X::~X();

一般而言,面对“以一个class object作为另一个class object的初值”的情形,语言允许编译器有大量的自由发挥空间。其利益当然是导致机器码产生时有明显的效率提升。缺点则是你不能安全地规划你的copy constructor的副作用,必须视其执行而定。

Copy Constructor:要还是不要?

copy constructor的应用,迫使编译器多多少少对你的程序代码做部分优化。尤其当一个函数以传值(by value)的方式传回一个class object,而该class有一个copy constructor(不论是明确定义出来的,或是合成的)时。这将导致深奥的程序转化——不论在函数的定义或使用上,此外编译器也将copy constructor的调用操作优化,以一个额外的第一参数(数值被直接存放在其中)取代NRV。

成员们的初始化队伍(Memeber Initialization List)

在下列情况下,为了让你的程序能够顺利编译,你必须使用member initialization list:

  • 当初始化一个reference member时
  • 当初始化一个const member时
  • 当调用一个base class的constructor,而它拥有一组参数时
  • 当调用一个member class的constructor,而它拥有一组参数时

下列情况下,程序可以被正确编译并执行,但是效率不彰,例如:

class Word{
    String _name;
    int _cnt;
public:
    //没有错误,只不过太天真
    Work(){
        _name = 0;
        _cnt = 0;
    }
};

在这里,Word constructor会先产生一个暂时性的String object,然后将它初始化,再以一个assignment运算符将暂时性object指定给_name,然后再摧毁那个暂时性对象。以下是constructor可能的内部扩张结果:

Word::Word( /*this pointer goes here*/ ){
    //调用String的default constructor
    _name.String::String();
    
    //产生暂时性对象
    String temp = String(0);
    
    //"memberwise"地拷贝_name
    _name.String::operator=(temp);
    
    //摧毁暂时性对象
    temp.String::~String();
    
    _cnt = 0;
}

对程序代码反复审查并修正之,得到一个明显更有效率的实现方法:

//较佳的方式
Word::Word : _name(0){
    _cnt = 0;
}

它会被扩张成如下样子:

Word::Word( /*this pointer goes here*/ ){
    //调用String(int) constructor
    _name.String::String(0);
    _cnt = 0;
}

member initialization list中到底会发生什么事情?编译器会一一操作initialization list,以适当顺序在constructor之内安插初始化操作,并且在任何explicit user code之前。

initialization list中的项目顺序是由class中的members声明顺序决定的,不是由initialization list中的排列顺序决定的。


默认构造函数详解 - CSDN博客

https://blog.csdn.net/coolwriter/article/details/80264949

错误认识1:若程序员没有自己定义无参数的构造函数,那么编译器会自动生成默认构造函数,来进行对成员函数的初始化。

错误认识2:编译器合成出来的default constructor会明确设定'“class内每一个data member的默认值”。但这两种种认识是有误的,不全面的。

正确认识:

默认的构造函数分为有用的和无用的,所谓无用的默认构造函数就是一个空函数、什么操作也不做,而有用的默认构造函数是可以初始化成员的函数。
对构造函数的需求也是分为两类:一类是编辑器需求,一类是程序的需求。
程序的需求:若程序需求构造函数时,就是要程序员自定义构造函数来显示的初始化类的数据成员。
编辑器的需求:编辑器的需求也分为两类:一类是无用的空的构造函数(trivial),一类是编辑器自己合成的有用的构造函数(non-trivival)。

在用户没有自定义构造函数的情况下:

一、由于编辑器的需求,编辑器会调用空的无用的默认构造函数。如:类中没有显式定义任何构造函数。

二、但在某些情况下,编辑器就一定会合有用的默认构造函数。

1.无关紧要(trivial)的默认构造函数【无用构造函数】

用户并没有显示地定义默认构造函数,编译器会为它自动生成一个无关紧要(trivial)的默认构造函数,生成的默认构造函数什么也不错,既不会讲其成员变量置零,也不会做其他的任何事情,只是为了保证程序能够正确运行而已,这就是所谓的“需要”,如果还需要给初始化成员变量,这件事情还是交给程序员做吧!

2.非平凡(non-trivival)默认构造函数【有用构造函数】

C++标准描述了哪些情况,这样的隐式默认构造函数是无关紧要的。一个非平凡(non-trivival)的默认构造函数是ARM中所说的被实现所“需要”,并在必要的时候被编译器自动生成。下面来看看默认构造函数是非平凡的四种情况:

2.1含有包含默认构造函数的成员类对象

2.2 一个类的父类自定义的无参构造函数(有non-trival的默认构造函数)

父类有自定义的默认构造函数,子类无任何自定义构造函数

2.3一个类里隐式的含有Virtual tabel(Vtbl)或者pointer member(vptr),并且其基类无任何构造函数或者有用户自定义的默认构造函数。

vtbl或vptr需要编辑器隐式的合成出来,那么编辑器就把合成动作放在了默认构造函数里,所以编辑器必需自己产生一个构造函数来完成这些动作。所以你的类里只要含有virtual function,那么编辑器就会生成默认的构造函数。

2.4 如果一个类虚继承与其他类

理由和2.3一样,虚基类也需要vtbl和vptr管理,那么这些管理就需要合成构造函数来实现管理,则需要生成默认的构造函数。

3.当用带有默认参数的构造函数进行初始化的时候,可以用类似默认参数初始化类的对象的方式来进行初始化。

特别注意:以下所有情况均为把有参构造函数用默认值初始化的特例(长得像,但并不是默认构造函数),并非有默认构造函数。创建对象方式与默认构造函数相同,但意义不一样。以下是在声明时成成员初始化为0,则调用形式与默认构造函数相同。其本质是使用带

例1:父类无构造函数(有编译器自己创建的trival型的),子类已经有有参构造函数。生成对象的时候实际调用的是用参构造函数,只不过参数都是0,可以省略不写。


猜你喜欢

转载自blog.csdn.net/coolwriter/article/details/80550936