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


第二章主要分析编译器什么情况下为我们合成默认构造函数,我的这篇博客已经写得很详细了: C++编译器默认构造函数合成机制分析,这次只写一些其他要点。


一:构造函数

  1. 编译器在四种情况下会为未声明构造函数的类合成默认(nontrival)构造函数。被合成出来的构造函数只能满足编译(而非程序)的需要,分别是:(1)包含的成员对象有默认构造函数。(2)继承的基类有默认构造函数。(3)该类含有虚函数,需要配置 vptr。(4)该类继承自 virtual base class,需要配置 vitrual base class subobject。至于没有存在这四种情况而有没有声明任何构造函数的类,我们说它们拥有的是无关痛痒的构造函数(implicit trivial default constructors), 实际上并不会被合成出来。
  2. 在合成的 default constructor 中,只有基类子对象(base class subobject)和成员类对象会被初始化。所有其他非静态成员(如整数、整数指针、整数数组都不会初始化)。因为这些初始化对编译器没有必要。
  3. C++在不同编译模块中,如何避免合成出多个默认构造函数(类位于头文件)?解决方法是把合成的默认构造函数、析构函数、拷贝构造函数、拷贝赋值函数等都以 inline 的方式完成(合成于类内部就是默认 inline)。一个 inline 函数有静态链接(static linkage),不会被档案以外者看到。如果函数太复杂,不适合做成 inline,就会合成出一个explicit non-line static实体(注意是static,所以在头文件中定义函数也不会被重复包含)。
  4. 编译器无法通过 pa->X::i (X是虚基类,pa 是第二派生类,与后继派生类可实现多态)确定经由 pa 而存取的 X::i 的实际偏移位置,因为 pa 可能是继承了虚基类的其他子类,虚基类在子类中偏移量都是不同的,所以要使得 X::i可以延迟至执行期才决定下来。这就需要引入一层间接性,可以使用指向 virtual base class subject 的指针,但是实际上采用的都是把虚基类子对象在派生类内存中的 offset 放在 vtbl 中即可,通过 offset 访问该虚基类子对象。(VS 单独为虚基类子对象生成了额外的一张 vtbl)。
  5. 实际上面 4 是因为 pa 可能持有其他派生类的引用而多态,所以编译器无法确定 i 的内存位置(因为 pa 可能是任意派生类型)。如果采用这样 point3d origin; origin.i(point3d 是继承了虚基类的某个派生类)操作,这种经由一个非多态的 class 对象存取一个继承而来的 virtual base class 的成员,可以优化为一个直接存取操作,可在编译期被决议完成。因为对象的类型已经定下来了,virtual base class subobject 的 offset 就确定了,就可以访问它的成员 i。 

二:拷贝构造函数


  1. 有三种情况下会调用拷贝构造函数:(1)明确以一个对象作为另一个对象的初值。(2)将对象作为参数传入某函数。(3)将对象作为函数返回值返回(可能被优化)。
  2. 关于拷贝构造函数,如果一个类表现出可以按位逐次拷贝语意,那么编译器不会为它和成默认拷贝构造函数,会直接浅拷贝,只进行简单内存搬移以及指针赋值。(所以不安全,如字符串指针)。
  3. 编译器在四种情况下会合成默认拷贝构造函数,即不展现位逐次拷贝语意:(1)当类包含一个成员对象,而成员对象存在拷贝构造函数(不论合成还是用户定义)。(2)当类继承自一个存在拷贝构造函数的基类(不论合成或定义)。(3)当类声明了一个或多个虚函数时。(4)当类派生自一个继承串链,其中有一个或多个虚基类时。(后两种情况不一定会合成,下面讨论)。
  4. 前两种情况(继续讨论6)中,编译器必须将成员类或基类的拷贝构造函数调用操作插入到当前类要合成的拷贝构造函数中。
  5. 后两种情况,如果拷贝对象与被拷贝对象都是继承体系中的同一种 class,即便有 vptr 或者虚基类子对象,对于同一种 class 来说 vtbl 以及虚基类子对象位置都是一模一样的,可以展现出位逐次拷贝语意,直接浅拷贝而不会合成默认拷贝构造函数。因为它们要调用的虚函数什么的种类都一样。
  6. 情况(3)不安全在于当一个基类对象以其派生类的对象内容做初始化操作时,其 vptr 必须重新设定。因为基类对象调用虚函数需要调用自己的虚函数(注意此处是初始化不是多态),如果使用浅拷贝会将基类的 vptr 直接设定指向派生类的 vptr,而派生类有可能重写了虚函数,所以调用虚函数会出错。
  7. 情况(4)不安全在于继承体系中,一对均继承了虚基类某个基类和它的某个后继派生类发生该基类对象以其派生类对象作为初值时,必须确保虚基类子对象依然存在且位于正确的位置。因为虚基类子对象一般都位于对象内存分布尾部,所以从派生类到基类转换,虚基类子对象需要向前挪动,以及 vtbl 中的 虚基类子对象 offset 必须重新设定。
  8. NRV 优化即编译器在函数返回值为一个 class 对象的情况下,会采用将一个该 class 引用类型的作为函数传入参数,这个参数其实是函数调用者用来接收该函数返回值的对象的引用,直接在该对象上进行操作,可以省去了未优化情况下需要局部定义对象作为返回值的操作,并且避免了提高了效率。(关于 NRV 优化见这篇:关于NRV优化详细分析)。
  9. 什么时候需要提供显示的拷贝构造函数?如果编译器已经为你实施了最后的行为,那就不需要显示提供了(比如 POD类型)。当需要 NRV 优化时,那么就需要显示提供拷贝构造函数。显示提供意味着深拷贝,编译器认为显示提供的拷贝构造函数说明有优化的必要了,就会触发 NRV 优化(但是目前编译器不需要提供这个也可以触发了)。注意如果你的程序依赖于拷贝构造函数中的内容,比如计数拷贝发生的次数,那么优化后该程序就不会得到正确结果了,因为拷贝构造被优化掉了。

三:成员初始化


  1. 四种情况必须使用成员初始化列表:(1)当初始化一个引用成员时。(2)当初始化一个 const 成员时。(3)当调用一个基类的构造函数,但是它是有参数的。(4)当调用一个成员类的构造函数,但它有参数。
  2. 如果对一个成员类对象不在成员初始化列表中初始化,而是在构造函数体内使用 ‘=' 号初始化。那么编译器会首先调用成员类的 default constructor,然后再产生一个成员类的临时对象,然后初始化临时对象,然后再以 operator= 运算符将临时对象赋值给成员对象,最后摧毁临时对象。(卧槽,所以还是乖乖用初始化列表初始化类对象吧)。



附录
最后第2点我写码验证了,GCC 下结果符合,而使用Clang++编译竟然 core dump 了,可能和我选的例子有关系。
class string {
public:
    string() { std::cout<<"string init"<<std::endl; }
    string& operator=(const string& other) { std::cout<<"string operator="<<std::endl; }
};

class wrapper {
public:
    wrapper(string s) { s_ = s; }  //第一次使用这个
    //wrapper(string s) :s_(s) { } //第二次使用这个
private:
    string s_; 
};

int main()
{
    string s;
    wrapper wr(s);

    return 0;
}
GCC下不使用初始化列表和使用初始化列表的输出分别是:

所以,用成员初始化列表吧!





  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值