《深度探索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.val
和bar.pnext
都需要被初始化,而这里被需要指的是程序需要它们被初始化,并非编译器需要,所以不会合成默认构造函数。“不会合成默认构造函数”这句话在C++ Standard中被修改成:
没有任何user-declaerd constructor的类,那么会有一个default constructor被隐式(implicitly)声明出来,称之为trivial(没有什么作用的) constructor.
具有Trivial constructor的类并不会发生任何的construction,我个人的理解是:具有trivial constructor的类内存分配和对象构造是分开的,只会分配内存,但不会对任何成员进行初始化。
下面说说四种编译器需要的合成的nontrivial constructor的四种情况:
- 一个类具有这样的成员,该成员具有默认构造函数。
举个例子:
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之前??)
}
- 一个派生类,该派生类的基类具有默认构造函数。
一个没有任何default constructor的派生类继承自带有default constructor的基类,那么派生类的default constructor将被视为nontrival的,需要被合成出来,它将调用上一层的基类的default constructor(根据声明顺序)。如果这个派生类还满足第一种情乱,即某个class member是带有默认构造函数的,那么合成顺序是先基类的default constructor,再成员的default constructor。
如果设计者提供多个constructor,但不包括default constructor,这时编译器会扩张每一个constructor,将必要的default constructor的代码加进去,而不会合成一个新的default constructor。 - 带有虚函数的类。
该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。
- 一个派生类,该派生类的继承体系中含有虚基类(虚继承)。
必须使得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的构造操作
拷贝构造函数通常适用于以下四种情况:
- 显式将一个object赋值给另一个object, 即“=”, (2019/8/16修改, 应该是赋初值的时候);
- 当实参传递给非引用形参时;
- 返回非引用的对象;
- 使用"{}"进行初始化;(本质和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。例如:
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,而是编译期帮助其合成一个呢?这和默认构造函数是类似的。
- 一个类具有这样的成员,该成员具有copy constructor。
- 一个派生类,该派生类的基类具有copy constructor。
- 带有虚函数的类。
- 一个派生类,该派生类的继承体系中含有虚基类(虚继承)。
因此,一个类的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的vptr即winnie.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);
}
必要的两个转化阶段:
- 重写每一个定义。
- 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中拷贝过来的呢?双阶段转化:
- 增加一个返回值的引用类型的额外参数,用来放置返回值;
- 在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)
下列四种情况必须使用成员初始化列表:
- 当初始化一个reference member时;
- 当初始化一个const member时;
- 当调用一个base class的constructor,而它拥有一组参数时;
- 当调用一个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.