第2章 构造函数语意学
2.1 默认构造函数的构造
考虑如下代码
class Foo {
public:
int val;
Foo *pnext;
}
void foo_bar() {
// 程序要求bar's members都被清为0
Foo bar;
if (bar.val || bar.pnext)
// ... don something
// ...
}
上述代码是否会合成默认的构造函数? 这里有两个问题要弄明白:
- 编译器需要
- 程序需要: 上述代码就是"程序需要", 编译器会声明一个构造函数, 但是并不会合成出来, 所以还是没有构造函数, 在这种情况下为程序执行初始化应该是程序员的责任
那么在未来是否会合成默认构造函数:
- 在C++ Annotated Reference Mannual(ARM)中: 只有在编译器需要需要时才会合成默认的构造函数
- C++ Standard: 如果没有任何用户声明的构造函数(注意: 是任何声明的构造函数, 包括拷贝构造函数), 那么就会有一个默认构造函数被隐式的声明出来, 但是这样被声明出来的默认构造函数是trivial(浅薄无能, 就是没啥用)constructor. 只有当一个默认构造函数是nontrivial时, 才会被合成出来. 所以, 上面的代码会声明一个trivial的默认构造函数, 但是因为是trivial, 所以不会合成出来, 编译器会报错
有4种情况, 会使编译器为没有声明构造函数的类合成一个默认构造函数, 即nontrivial的默认构造函数
- 类包含带有默认构造函数的成员, 合成出来的默认构造函数并不会初始化本类的其他成员, 初始化其他成员是程序员的责任
- 类是由带有默认构造函数的基类所派生出来的
- 类中带有一个虚函数
1. class声明或继承了一个虚函数
2. class派生自一个继承串链, 其中有一个或更多的虚基类 - 类中带有一个虚基类 有些疑问
被合成出来的构造函数只能满足编译器(而非程序)的需要, 它之所以能够完成任务, 是借着"调用成员对象或基类的默认构造函数"或是"为每一个对象初始化其虚函数机制或虚基类机制"而完成的
在合成的默认构造函数中, 只有基类子对象和类成员对象会被初始化. 所有其他的nonstatic数据成员(如整数, 整数指针, 整数数组等等)都不会初始化. 这些初始化操作对程序而言或许有需要, 但对编译器则是非必要. 如果程序需要一个"把某指针设为0"的默认构造函数, 那么提供它的人应该是程序员
总结就是都会声明, 但是会不会合成又是另一回事了, 取决于是trivial还是nontrivial
2.2 拷贝构造函数的构造初始化
有3种情况会以一个对象内容作为另一个对象的初值, 1. 显示以一个对象内容作为另一个对象的初值(初始化, 而不是单纯等号操作); 2. 当对象做参数交给某个函数(做形参, 非引用指针形式); 3. 对象做返回值(非引用指针形势)
- 默认逐成员初始化(Default Memberwise Initialization)
- 逐成员初始化(Memberwise Initialization)
- 位逐次拷贝(Bitwise Copy Semantic)
// 以下声明展现了bitwide copy semantic
class Word {
public:
Word(const char *);
~Word() { delete[] str; };
// ...
private:
int cnt;
char *str;
}
如果类X没有显式的拷贝构造函数, 那么在用一个类X的对象a初始化这个类的对象b时, 内部采用的就是默认逐位成员初始化. 具体来讲, 就是把a的数据一个个单独拷贝到b中. 如果类X里面还包含有成员类对象(Member Class Object), 如类Y的对象, 那么此时就不会把a的成员对象拷贝到b中, 而是递归的进行逐成员初始化, 逐成员初始化用的就是逐位拷贝和拷贝构造函数
就像默认拷贝构造函数一样, C++ Standard上说, 如果类没有声明一个拷贝构造函数, 就会有隐式的声明或隐式的定义出现. 和以前一样, C++ Standard把拷贝构造函数区分为trivial和nontrivial两用, 只有nontrivial的实例才会被合成于程序之中. 如果展现出"bitwise copy semantic"(位逐次拷贝语义), 那么拷贝构造函数就是trivial的
qu
如果一个类没有定义显示的拷贝构造函数, 那么编译器是否会为其合成取决于类是否展现"位逐次拷贝":
- 如果类展现出"位逐次拷贝", 则编译器不需要合成一个默认的拷贝构造函数
- 如果类不展现"位逐次拷贝", 则编译器必须合成一个默认的拷贝构造函数, 不展现"位逐次拷贝"的情况有以下4种:
1. 类包含具有拷贝构造函数的成员
2. 类继承自一个具有拷贝构造函数的基类
3. 类中声明一个或多个虚函数
4. 类派生自一个继承串链, 其中有一个或多个虚基类
前2种情况中, 编译器必须将成员或基类的"拷贝构造函数调用操作"安插到被合成的拷贝构造函数中
第3种情况不展现出"位逐次拷贝"是因为需要正确的处理虚函数指针vptr. (1)如果使用子类的一个对象初始化另一个子类的对象, 可以直接靠"位逐次拷贝"完成; (2)如果用一个子类对象初始化一个父类对象, 会发生切割行为, 父类对象的虚函数指针必须指向父类的虚函数表vtlb, 如果使用"位逐次拷贝", 那么父类的虚函数指针会执行子类的vtlb
合成出来的ZooAnimal copy constructor会显式设定object的vptr指向ZooAnimal class的virtual table, 而不是直接从右手边的class object中将其vptr现值拷贝过来. 参考第1章最后一句话
第4中情况不展现"位逐次拷贝"是因为虚基类对象部分能够正确初始化. (1)如果使用虚基类子类的一个对象, 初始化虚基类子类另一个对象, 那么"位逐次拷贝"绰绰有余; (2)但如果企图以一个虚基类子类的子类对象, 初始化一个虚基类子类的对象, 编译器就必须判断"后续当程序员企图存取其虚基类子对象能否正确执行", 因此必须合成一个拷贝构造函数, 安插一些代码以设定虚基类指针和偏移量的初始值(或只是简单的确定它没有被抹销)
在下面的情况下, 编译器无法知道"位逐次拷贝"是否还保持着, 因为他无法知道Raccoon指针是否指向一个真正的Raccoon对象或者是指向一个Raccoon的子类对象
Raccoon *ptr;
Raccoon little_critter = *ptr;
总结就是, 按照C++ Standard, 如果用户没有声明, 就会隐式的声明一个, 但是会不会合成取决于声明出的是trivial还是nontrivial, 拷贝的动作都会有, 就在于会不会合成拷贝构造函数(成员都是基础数据类型就不用拷贝函数了, 因此就不用合成了)
2.3 程序转换语意学
class Test {
public:
Test() { cout << "默认构造函数" << endl; }
Test(const Test &obj) { cout << "拷贝构造函数" << endl; }
};
Test foo() {
Test t;
return t;
}
int main() {
// 输出默认构造函数
Test t = foo();
return 0;
}
拷贝构造函数的应用, 迫使编译器多多少少对程序代码做部分转化. 尤其是当一个函数以值的方式传回一个类对象, 而该类有一个拷贝构造函数(无论是显式定义出来还是合成出来)时, 这将导致深奥的程序转化为-->无论在函数的定义上还是在使用上. 此外, 编译器将拷贝构造的调用操作优化, 以一个额外的第一参数(数值被直接存放其中)取代NRV(named return value). 如果了解那些转换, 已经拷贝构造函数优化后的可能状态, 就比较能够控制程序的执行效率
2.4 成员初始化列表
为使程序能够被正确编译, 在下列情况中必须使用初始化列表:
- 当初始化一个引用成员时
- 当初始化一个常成员时
- 当调用一个基类的构造函数, 而它拥有一组参数时
- 当调用一个类类型成员的构造函数, 而它拥有一组参数时
编译器会对初始化列表一一处理, 以反应出成员的声明顺序. 它会安插一些代码到构造函数体内, 并置于任何用户代码(explicit user code)之前
和默认构造函数, 拷贝构造函数相关的问题: 是否可以使用memset来初始化一个对象, 使用memcpy来拷贝一个对象?
只有在"class不含任何由编译器产生的members"时才能有效运行. 如果class声明一个或多个以上的virtual functions, 或者内含一个virtu base class, 那么上述函数将会导致哪些"被编译器产生的内部members"的初值改写
class Shape { public: // 会改变内部vptr Shape() { memset(this, 0, sizeof(Shape)); } // 当传入一个子类对象的地址时, vptr会指向子类的虚函数表 Shape(const Shape &rhs) { memcpy(this, & rhs, sizeof(Shape)); } virtual ~Shape(); }