《深度探索C++对象模型》读书笔记第二章:构造函数语义学

《深度探索C++对象模型》读书笔记第二章 构造函数语义学

编译器背着程序员做了很多事,explicit关键字的导入,是为了制止“单一参数的constructor”被当作一个conversion运算符。这一章中,挖掘了编译器对于“对象构造过程”的干涉,以及对于“程序形式”和“程序效率”的冲击。

一.Defalut Constructor的构造过程

关键:是编译器需要(就会合成默认构造函数),而非程序需要
例如下面的例子:

class Foo{
public:
    int val;
    Foo *pnext;
};
void foo_bar(){
    Foo bar;
    if(bar.val || bar.pnext) {
        //do something
    }
}

if语句中,bar.valbar.pnext都需要被初始化,而这里被需要指的是程序需要它们被初始化,并非编译器需要,所以不会合成默认构造函数。“不会合成默认构造函数”这句话在C++ Standard中被修改成:
没有任何user-declaerd constructor的类,那么会有一个default constructor被隐式(implicitly)声明出来,称之为trivial(没有什么作用的) constructor.
具有Trivial constructor的类并不会发生任何的construction,我个人的理解是:具有trivial constructor的类内存分配和对象构造是分开的,只会分配内存,但不会对任何成员进行初始化。
下面说说四种编译器需要的合成的nontrivial constructor的四种情况:
1. 一个类具有这样的成员,该成员具有默认构造函数。
举个例子:

class Foo {
public:
    Foo();
}
class Bar {
public:
    Foo foo;
    char* str;
}
void foo_bar(){
    Bar bar;
    if(str){
        //do something
    }
}

在类Bar中的有一个成员foo的类型是带有默认构造函数的类,因此编译器会为类Bar生成一个默认的构造函数如下:

inline Bar::Bar(){
    //C++ 伪码
    foo.Foo::Foo();
}

生成的构造函数也只是为了满足编译器的需要,并没有对成员str做任何初始化。如果我们为类Bar写了这样一个构造函数:Bar::Bar(){str = 0;}
那么编译器就会扩张已经存在的这个构造函数,就像下面这样:

//扩充之后的default constructor
//C++伪码
Bar::Bar(){
    foo.Foo::Foo(); //附加的compiler code
    str = 0;    //explicit user code
}

如果多个class member object都要求constructor初始化操作,构造顺序和声明的顺序一致,例如类Bar中还有一个成员foo2的类型是带有默认构造函数的类Foo2,并且声明顺序在成员str后面,那么编译器扩张构造函数就是这样的:

//扩充之后的default constructor
//C++伪码
Bar::Bar(){
    foo.Foo::Foo(); //附加的compiler code
    foo2.Foo2::Foo2(); //附加的compiler code
    str = 0;    //explicit user code
    //(是否compiler code都会安插在explicit user code之前??)
}
  1. 一个派生类,该派生类的基类具有默认构造函数
    一个没有任何default constructor的派生类继承自带有default constructor的基类,那么派生类的default constructor将被视为nontrival的,需要被合成出来,它将调用上一层的基类的default constructor(根据声明顺序)。如果这个派生类还满足第一种情乱,即某个class member是带有默认构造函数的,那么合成顺序是先基类的default constructor,再成员的default constructor。
    如果设计者提供多个constructor,但不包括default constructor,这时编译器会扩张每一个constructor,将必要的default constructor的代码加进去,而不会合成一个新的default constructor。
  2. 带有虚函数的类。
    该class声明或者继承了一个virtual function,例如:
class Widget {
public:
    virtual void flip() = 0;    //pure virtual
};
class Bell : public Widget{};
class Whistle : public Widget{};
void flip(const Widget& widget) { widget.flip(); }
void foo(){
    Bell b;
    Whistle w;
    flip(b);
    filp(w);
}

下面两个扩张行动会在编译器发生:
- 一个vtbl(virtual function table虚函数表)会被编译器产生;
- 每一个class object中,一个vptr(point to vtbl)会被合成出来,指向vbtl;
另外再flip()函数会被改写成:(*widget.vptr[1])(&widget);具体见function语意学
对于那些没有声明任何constructor的class,编译器会合成一个default constructor以便正确初始化每一个class object的vptr。
4. 一个派生类,该派生类的继承体系中含有虚基类(虚继承)。
必须使得virtual base class在其每一个derived class object中的位置在执行期准备妥当。例如:

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 foo(const A* pa) { pa->i = 1024; }
main{
    foo(new A);
    foo(new C);
}

foo()函数中,我们不知道pa的动态类型(实际内存中的类型),所以无法在编译期中resolve出pa->X::i的实际位置,通常的策略是设定一个指针指向virtual base class:

void foo(const A* pa) { pa->__vbcX->i = 1024; }

这里的__vbcX就是那个指向virtual base class的指针,并且这个指针的设定是在class object的构造期间被完成的,所以如果class 没有声明任何constructor,编译期就会为它合成一个default constructor。

二.Copy Constructor的构造操作

拷贝构造函数通常适用于以下四种情况:
1. 显式将一个object赋值给另一个object,即“=”;
2. 当实参传递给非引用形参时;
3. 返回非引用的对象;
4. 使用”{}”进行初始化;(本质和1.是一样,先默认构造,再赋值)

Default Memberwise Initialization(默认成员逐一初始化)

如果一个class没有提供任何的copy construct,那该class是如何完成copy construct的呢?事实上,class内部是以default memberwise initialization的手法完成的。实际上就是bitwise copies(位逐次拷贝),即把class object中的所有data members按顺序一个一个拷贝到另一个object身上,如果有data members是类类型,那么就会递归地施行bitwise copies。例如:

扫描二维码关注公众号,回复: 1106461 查看本文章
class Word {
public:
    Word(int i, String s) : _occurs(i), _word(s) {}
private:
    int _occurs;
    String _word;   //String object称为一个data member
};
Word word1(2, "word");
Word word2 = word1;

最后一句的赋值操作可能是这样的:

word2._occurs = word1._occurs;
word2._word = word1._word;

然后如果String类没有任何copy constructor,那么就会递归这个位逐次拷贝的过程。

不要Bitwise Copy Semantics

什么情况下一个class不展现出Bitwise Copy Semantics,而是编译期帮助其合成一个呢?这和默认构造函数是类似的。
1. 一个类具有这样的成员,该成员具有copy constructor。
2. 一个派生类,该派生类的基类具有copy constructor。
3. 带有虚函数的类。
4. 一个派生类,该派生类的继承体系中含有虚基类(虚继承)。
因此,一个类的copy语意有三种情况:
- 存在copy constructor,使用copy constructor;
- 满足上面的四种情况,编译期帮助合成copy constructor;
- bitwise copy;

重新设定Virtual Table的指针

假设下面的继承关系:

class ZooAnimal {
public:
    ZooAnimal();
    virtual ~ZooAnimal();
    virtual void draw();
};
class Bear : public ZooAnimal {
public:
    Bear();
    virtual ~Bear();
    void draw();    //virtual function
};
Bear yogi;
Bear winnie = yogi;

Bear yogi;这个语句会用Bear的默认构造函数初始化yogi,并且正确设定yogi的vptr指向Bear的vbtl;Bear winnie = yogi;将yogi的vptr值拷贝给winnie的vptrwinnie.vptr = yogi.vptr;这是安全的。
但是若用派生类的对象位基类对象初始化时,也必须保证vptr的操作安全,例如:

void draw(const ZooAnimal& zoey) { zoey.draw(); }
void foo() {
    ZooAnimal franny = yogi;    //这会发生slice(切割)
    draw(yogi);     //调用yogi.Bear::draw();
    draw(franny);   //调用franny.ZooAnimal::draw();
}

ZooAnimal franny = yogi;并不会直接将yogi的vptr拷贝给franny的vptr(如果是bitwise copy,则直接拷贝),实际上合成的constructor会显式地设定franny的vptr指向ZooAnimal的vbtl

处理virtual base class

virtual base class需要特别处理,编译期必须让derived class object中的virtual base class subobject在执行期就准备妥当。假设有这样的继承关系:
继承关系

class Raccoon : public virtual ZooAnimal {
public:
    Raccoon() {}
    Raccoon(int val) {}
};
class RedPanda : public Raccoon {
public:
    RedPanda() {}
    RedPanda(int val) {}
};

如果只是一个Raccoon object作为另一个Raccoon object的初值,“bitwise copy”就足够了。

Raccoon rocky;
Raccoon little_critter = rocky;

但是如果以RedPanda object作为Raccoon object的初值,编译器必须判断“能否正常执行存取ZooAnimal的subobject的动作

RedPanda little_red;
Raccoon little_critter = little_red;

为了正确完成litte_critter的初值设定,编译期必须合成一个copy constructor,安插一些代码以设定virtual base class pointer/offset的初值。
内存模型
对指针而言,“bitwise copy”可能够用,也可能不够用:

Raccoon *ptr;
Raccoon little_critter = *ptr;

因为编译期不知道Raccoon指针是否真正指向一个Raccoon object。

三.程序转化语意学

显式的初始化操作

例如:

// X是一个类
X x0;
void foo_bar() {
    X x1(x0);
    X x2 = x0;
    X x3 = X(x0);
}

必要的两个转化阶段:
1. 重写每一个定义。
2. class 的copy constructor调用操作会被安插进去。
实际的foo_bar()可能看起来是这样的:

void foo_bar(){
    //重写定义
    X x1; 
    X x2;
    X x3;
    //copy constructor: X::X(const X& x);被安插进去
    x1.X::X(x0);
    x2.X::X(x0);
    x3.X::X(x0);
}

参数初始化

将一个class object当作参数传递给一个函数或者作为一个函数的返回值,相当于以下形式的初始化:
已知函数:void foo(X x0);有以下调用:

X xx;
foo(xx);

由于是传值或者返回值(而非引用),策略是导入临时性的对象,并且调用copy constructor将其初始化,然后将临时性对象交给函数。

X __temp0;  //临时对象
__temp0.X::X(xx);   //copy constructor初始化
foo(__temp0);   //临时性对象交给函数

此时函数声明被改写成:void foo(X& x0);,最后会调用X的destructor来析构掉临时对象。

返回值初始化(NRV优化)

已知函数定义:

X bar(){
    X xx;
    return xx;
}

那么bar返回值是如何从局部对象xx中拷贝过来的呢?双阶段转化:
1. 增加一个返回值的引用类型的额外参数,用来放置返回值;
2. 在return之前安插一个copy constructor调用操作,来初始化那个额外参数。
改写如下:

void bar(X& __result){
    X xx;
    xx.X::X();
    __result.X::X(xx);
    return;
}

在使用者层面做优化

直接返回构造的临时对象即可

在编译器层面做优化

NRV优化:用引用参数代替返回值

copy constructor:要还是不要?

假如class需要大量的memberwise初始化操作,例如以传值(by value)的方式传回object,那么提供一个copy constructor的explicit inline函数实例就非常合理了。

成员们的初始化队伍(member initialization list)

下列四种情况必须使用成员初始化列表:
1. 当初始化一个reference member时;
2. 当初始化一个const member时;
3. 当调用一个base class的constructor,而它拥有一组参数时;
4. 当调用一个member class的constructor,而它拥有一组参数时;
例如下面的程序可以正确编译,但是效率不高:

class Word {
    String _name;
    int _cnt;
public:
    Word() {
        _name = 0;
        _cnt = 0;
    }
}

构造效率不高的原因是产生了临时对象,内部扩张的结果可能是:

Word::Word {
    _name.String::String();
    String temp = String(0);
    _name.String::operator=(temp);
    temp.String::~String();
    _cnt = 0;
}

应该坚持(member initialization list)

Word::Word() : _name(0), _cnt(0) {}

内部的转化为:

Word::Word() {
    _name::String::String(0);
    _cnt = 0;
}

另外,特别注意,成员初始化列表中的初始化顺序是按照声明顺序来的=,并且initialization list的项目要先于explicit user code.

主要参考自侯捷老师的《深度探索C++对象模型》第二章 构造函数语意学

猜你喜欢

转载自blog.csdn.net/qq_25467397/article/details/80421720