《深度探索C++对象模型》第二章 | 构造函数语意学
默认构造函数的构建操作
默认构造函数在需要的时候被编译器合成出来。这里“在需要的时候”指的是编译器需要的时候。
带有默认构造函数的成员对象
如果一个类没有任何构造函数,但是它包含一个成员对象,该成员对象拥有默认构造函数,那么这个类的隐式默认构造函数就是非平凡的,编译器需要为该类合成默认构造函数。为了避免合成出多个默认构造函数,编译器会把合成的默认构造函数、拷贝构造函数、析构函数和赋值拷贝操作符都以内联的方式完成。一个内联含有具有静态链接,不会被文件以外者看到。如果函数不适合做成内联,就会合成出一个显式非内联静态(explicit non-inline static)实体。
如果默认构造函数已经被显式定义,那么编译器会扩张已经存在的构造函数,使得在用户代码执行前,先调用默认的构造函数。如果有多个成员对象都要求构造初始化操作,那么编译器就会按照成员对象在类中的声明顺序依次调用每个成员对象所关联的默认构造函数。
带有默认构造函数的基类
类似的,如果一个没有任何构造函数的类派生自一个带有默认构造函数的基类,那么这个派生类的默认构造函数就是非平凡的,因此编译器会为它合成一个默认构造函数。如果派生类拥有多个构造函数,但没有默认构造函数,编译器就会扩张现有的每一个构造函数,而不会合成一个新的默认构造函数。
带有虚函数的类
class Widget{
public:
virtual void filp() = 0;
// ...
};
void flip(const Widget& widget) {widget.flip();}
// 假设Bell和Whistle都派生自Widget
void foo{
Bell b;
Whistle w;
flip(b);
flip(w);
}
在编译期,对于上述代码编译器会执行下面两个扩张操作:
- 产生一个虚函数表,里面存放的是虚函数地址
- 在每个类对象中合成一个额外的指针成员,内含相关的类的虚表地址
为了让上述机制发挥功效,编译器必须为每个Widget
或其派生类对象的虚指针设定初值,以存放适当的虚表地址。因此,对于那些没有声明任何构造函数的类,编译器会为它们合成一个构造函数。
继承自虚基类的类
编译器必须是虚基类再每个派生类对象中的位置能够于执行期准备妥当。例如下面的代码:
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;}
int main(){
foo(new A);
foo(new C);
// ...
}
编译器无法在编译器foo()
之中经由pa
而存取的X::i
的实际偏移位置,因为pa的真正类型是可以改变的。因此,编译器必须改变“执行存取操作”的那些码,使X::i
可以延迟至执行期决定。对于类中定义的每个构造函数,编译器会安插那些“允许每个虚基类的执行期存取操作”的码。如果类中没有声明任何构造函数,编译器必须为它合成一个默认构造函数。
拷贝构造函数的建构操作
拷贝构造函数是一种构造函数,它的一个参数是其本类对象。大部分情况下,当一个对象以另一个同类对象作为初值时,拷贝构造函数就会调用。以下三种情况会调用拷贝构造函数:
- 显式初始化操作
- 对象被当作参数传递给某个函数
- 对象作为函数返回值
默认的逐成员初始化
当需要调用拷贝构造函数而类中没有提供显式的拷贝构造函数时,编译器会执行逐成员初始化,也就是把每个内建或派生的数据成员的值,从某个对象拷贝一份到另一个对象身上。不过它并不会拷贝其中的成员对象,而是以递归的方式进行逐成员初始化。像默认构造函数一样,如果一个类没有声明一个拷贝构造函数,就会有隐式的声明或定义出现。C++标准把拷贝构造函数区分为平凡的和非平凡的,只有非平凡的拷贝构造函数才会被编译器合成。决定一个拷贝构造函数是否平凡的标准在于类是否展现出逐位拷贝语义(bitwise copy semantics)。
逐位拷贝
class Word{
public:
Word(const char*);
~Word(){delete[] str;};
// ...
private:
int cnt;
char* str;
};
这种情况下编译器不会合成一个默认的拷贝构造函数,因为上述Word
类的声明展现了默认拷贝语义。然而,如果Word
类是以如下形式声明的,且String
类声明了一个显式构造函数,那么编译器就必须合成一个拷贝构造函数以调用成员对象str
的拷贝构造函数。
class Word{
public:
Word(const String&);
~Word();
// ...
private:
int cnt;
String str;
};
那么,一个类什么时候不展现出逐位拷贝语义呢?有以下四种情况:
- 类中含有一个成员对象而后者的类声明中有一个拷贝构造函数(无论此拷贝构造函数是被显式声明的还是由编译器合成的)
- 继承自一个基类而后者存在一个拷贝构造函数(无论此拷贝构造函数是被显式声明的还是由编译器合成的)
- 声明一个或多个虚函数
- 派生自一个继承链,其中有一个或多个虚基类
前两种情况,编译器必须将成员或基类的“拷贝构造函数调用操作”安插到被合成的拷贝构造函数中。
重新设定虚表指针
前面提到,只要一个类声明一个或多个虚函数,编译器就会进行如下扩张操作:
- 增加一个虚函数表,内含每一个有作用的虚函数地址
- 将一个指向虚函数表的指针,安插在每个类对象内
显然,如果编译器对于每个新产生的对象的虚指针不能设定正确的初始值,将会导致不可预料的后果。一般来说,当我们使用一个基类对象作为同类对象的初始值,或者使用一个派生类对象作为同类对象的初始值时,都可以直接依靠逐位拷贝操作完成,这种情况下编译器不会合成拷贝构造函数。当一个基类对象用其派生类对象做初始化操作时,必须保证虚指针的赋值操作安全的。我们知道,基类的虚指针不可以指向派生类的虚表。但是,如果直接进行逐位拷贝操作,基类的虚指针就指向了派生类的虚函数表,这是不被允许的。因此,编译器会为基类合成出一个拷贝构造函数,该函数会明确设定基类对象的虚指针指向基类的虚表,而非直接拷贝派生类对象中虚指针的现值。
处理虚基类对象
在虚拟继承方面,编译器必须让“派生类对象中的虚基类子对象位置”在执行期准备妥当,维护“位置的完整性”是编译器的责任。而逐位拷贝语义可能会破外这个位置,所以编译器必须在它自己合成出来的拷贝构造函数中做出仲裁。ZooAnimal
是Raccoon
的一个虚基类:
class Raccoon : public virtual ZooAnimal{
public:
Raccoon(){}
Raccoon(int val){}
private:
};
编译器所产生的代码(用以调用ZooAnimal
的默认构造函数、将Raccoon
的虚指针初始化,并定位出Raccoon
中的ZooAnimal
子对象)被安插在两个构造函数中。
class RedPanda : public Raccoon{
public:
RedPanda(){}
RedPanda(int val){}
private:
};
如果以一个RedPanda
对象作为Raccoon
对象的初值,编译器必须判断“后续当程序员企图存取其ZooAnimal
子对象时是否能够正确地执行”。这种情况下,为了完成对Raccoon
对象的初值设定,编译器必须合成一个拷贝构造函数,安插一些代码以设定虚基类的指针或偏移量的初值(或简单地确定它有没有被抹消),对每个成员执行必要的初始化操作,以及其他的内存相关工作(将在第三章讨论)。
下面这种情况中,编译器无法知道逐位拷贝语义还保持着,因为它无法知道Raccoon
指针是否指向一个真正的Raccoon
对象,或是指向一个派生类对象:
Raccoon *ptr;
Raccoon littlr_critter = ptr;
程序转化语意学
显式初始化操作
X x0;
void foo_bar(){
X x1(x0);
X x2 = x0;
X x3 = X(x0);
}
两个阶段:
- 重写每条语句,其中的初始化操作会被剥除
- 类的拷贝构造函数调用操作会被安插进去
转化后的代码可能是这样:
void foo_bar(){
X x1;
X x2;
X x3;
// 编译器安插拷贝构造函数调用操作
x1.X::X(x0);
x2.X::X(x0);
x3.X::X(x0);
//...
}
其中x1.X::X(x0);
就表现出对拷贝构造函数X::X(const X& xx)
的调用。
参数初始化
把类的对象当作参数传给一个函数(或作为函数的返回值),相当于以下形式的初始化操作:
X xx = arg;
其中xx代表形式参数(或返回值)而arg
代表真正的参数值。因此,对于函数void foo(X x0)
,下面这种调用方式:
X xx;
foo(xx);
将会要求局部变量x0
以逐成员的方式将xx
当作初值。在编译器实现技术上,一种策略是导入暂时性对象,并调用拷贝构造函数将它初始化,然后将该暂时性对象交给函数。
X __temp0;
__temp0.X::X(xx);
foo(__temp0);
暂时性对象先以类X的拷贝构造函数正确地设定了初值,然后再以bitwise的方式拷贝到x0
这个局部变量中。foo()
函数的声明因此必须被转化,形式参数必须从原来的对象变成引用void foo(X& x0);
。类X需要声明一个析构函数,它会在foo
函数完成之后被调用,以销毁那个暂时性对象。
另一种实现策略是以拷贝构造的方式把实际参数直接建构到其应该的位置上,该位置视函数活动范围的不同记录于程序堆栈中。在函数返回之前,局部对象的析构函数(如果有定义的话)会被执行。
返回值初始化
X bar(){
X xx;
// 处理 xx ...
return xx;
}
上述代码中,bar()
的返回值如何从局部对象xx中拷贝出来?一种解决方法是双阶段转化:
- 首先加上一个额外参数,类型是对象的引用。这个参数用来放置被“拷贝构造”得到的返回值。
- 在
return
指令之前安插一个拷贝构造函数调用操作,以便将传回对象的内容当作上述新增参数的初始值。
第二阶段的转化操作会重新改写函数,使它不传回任何值:
vodi bar(X& __result){
X xx;
xx.X::X();
__result.X::X(xx);
return;
}
用户层优化
对于像bar()
这样的函数,程序员可以定义一个计算用的构造函数。也就是说,程序员不再写如下代码:
X bar(const T& y, const T& z){
X xx;
return xx;
}
因为上述的代码要求xx
被memberwise地拷贝到编译器产生的__result
之中。我们可以定义另一个构造函数,可以直接计算xx
的值:
X bar(const T& y, const T& z){
return X(y, z);
}
这样bar()
函数转化之后效率就比较高,__result
可以直接被计算出来,而不是经由拷贝构造函数生成。
编译器层优化
命名返回值优化(Named Return Value Optimization)消除了冗余拷贝构造函数和析构函数调用,从而提高了程序性能。对于一个如foo()
的函数,编译器可能会对它进行NRVO,方法是以__result
参数取代返回对象。
X bar(){
X xx;
// 处理 xx ...
return xx;
}
优化后的foo()以result取代xx:
void bar(X &__result){
// 调用默认构造函数
// C++ 伪码
_result.X::X();
// ... 直接处理 __result
return;
}
对比优化前与优化后的代码可以看出,对于一句类似于X xx1 = foo()
这样的代码,NRVO后的代码相较于原代码节省了一个临时对象的空间(省略了xx
),同时减少了两次函数调用(减少xx
对象的默认构造函数和析构函数,以及一次拷贝构造函数的调用,增加了一次对xx1
的默认构造函数的调用)
- NRVO由编译器默默完成,至于它是否真的被完成,我们并不是十分清楚
- 一旦函数变得比较复杂,优化也就难以进行
- 破外程序对称性,导致程序出错
需要实现拷贝构造函数吗?
如果一个类没有任何成员对象或基类对象带有构造函数,也没有任何虚函数或继承自虚基类,那么该类的默认拷贝构造函数就被视为平凡的,编译器不会为它合成一个默认拷贝构造函数。所以,默认情况下该类对象的初始化操作就会导致逐位拷贝。这种拷贝操作既快速又安全,因此类的设计者没有必要提供一个显式的拷贝构造函数。但是,如果这个类需要大量的逐成员初始化操作(例如以传值的方式返回对象),那么提供一个显式的拷贝构造函数就非常有必要。
成员初始化列表
除了在构造函数内初始化类成员外,我们还可以通过成员初始化列表对类中的成员进行初始化。一般来说,以下四种情况必须使用成员初始化列表:
- 初始化引用成员
- 初始化静态成员
- 调用基类的构造函数,且它拥有参数
- 调用成员对象的构造函数,且它拥有参数
编译器会逐个操作初始化列表,按照适当的顺序将初始化操作安插在构造函数中,且在用户代码之前。初始化列表中的项目初始化顺序是由类中成员的声明次序决定的,而不是列表的排列次序决定的。
在构造函数内,我们可以调用成员函数来初始化类成员,因为和此对象相关的this指针已经被建构。但是,不要在成员初始化列表中使用其他成员函数。一般来说,我们可以使用“存在于构造函数内的一个成员”而非“存在于成员初始化列表中的成员”来为另一个成员设置初值。此外,不要把派生类成员函数的返回值当作基类构造函数的参数,相关问题将在后续章节讨论。
本文来自博客园,作者:shuo-ouyang,转载请注明原文链接:https://www.cnblogs.com/shuo-ouyang/p/12540583.html