进步始于交流
收货源于分享

《深度探索C++对象模型》笔记(2)

第二章 构造函数语意学

总结

这章主要讲:构造函数中的默认构造函数、拷贝构造函数,以及构造函数中的初始化列表。对于默认构造函数以及拷贝构造函数是否会被编译器自动添加、添加的内容是什么做了详细的介绍说明。

最开始还提到了explicit关键字:可以阻止不应该允许的经过转换构造函数进行的隐式转换的发生。声明为explicit的构造函数不能在隐式转换中使用。

默认构造函数default constructor

何时会自动增加默认构造函数取决于:编译器需要,当编译器需要的时候就会增加。所以只有以下情况会自动建立默认构造函数,而且建立的构造函数只完成:编译器需要的功能。

  1. member constructor有defualt constructor;
  2. base class具有default constructor;
  3. 该类具有virtual funciton,或者继承串链中有virtual base class
  4. 该类有一个virtual base class

若符合上述任意一点,并没有显示声明的构造函数,一定会建立默认构造函数;若有自定义constructor,则会在每一个构造函数的自定义代码前插入这些处理代码。(注意是在自定义代码前)

无论父类还是自己有virtual时,或者在用了虚拟继承,总之涉及到virtual的时候,都会涉及到vptr指向vtbl的构建过程,这是编译器要做的。

无论成员数据、还是父类中有默认构造函数,当前类一定要去调用以对其进行构建。

默认构造只进行编译器需要的操作,不会对无关的非静态数据成员进行初始化操作。静态成员也会被初始化,但只在第一次初始化。

class_name();就是默认构造函数的声明,自建的也是默认构造函数,并非编译器建立的才是。

拷贝构造函数

提到拷贝了,先复习一下深拷贝、浅拷贝

浅拷贝:如果复制的对象中引用了一个外部内容(例如分配在堆上的数据),那么在复制这个对象的时候,让新旧两个对象指向同一个外部内容,就是浅拷贝。(指针虽然复制了,但所指向的空间内容并没有复制,而是由两个对象共用,两个对象不独立,一个内部delete了空间,另一个的也没了,但是指针仍指向原来的地址)

深拷贝:如果在复制这个对象的时候为新对象制作了外部对象的独立复制,就是深拷贝。

首先拷贝构造函数的声明:T(const T& t);,T为类名无返回值。

一个object可以用两种方式复制得到,一个是初始化(拷贝构造),一个是被指定(assignment,赋值运算符第五章)。

Bitwise copy semantics位逐次拷贝,这种拷贝的在有下述任意一种情况时,不会进行:

  1. 类的成员包括一个类对象的时候,且这个对象有拷贝构造函数(无论是显示声明还是编译器默认生成)
  2. 类继承自一个拥有拷贝构造函数的基类(无论是显示声明还是编译器默认生成)
  3. 类声明了n个虚函数时,父类有虚函数也算。
  4. 类派生自一个继承串链,其中有n个虚基类时。

对于Bitwise copy semantics的定义“概念上而言,对于一个class x,这个操作是被一个copy constructor实现出来的。一个良好的编译器可以为大多数class objects产生bitwise copies”。

一个类如果能通过逐位拷贝,就会进行逐位拷贝,而这个操作不需要建立T(const T& t);拷贝构造函数,只有当不能进行逐位拷贝时,即满足了上面四个条件之一时才必须要有拷贝构造函数。

对于情况1/2较为简单,编译器需要将对成员或基类的拷贝构造函数插入到自定义的拷贝构造函数中,或者在没有显示声明时,构建默认的。

对于情况3,有虚函数,那么必然有虚函数表vbtl,object中也就有vptr指针。当基类A有虚函数,B重写A后,A的实例互相拷贝或B的实例互相拷贝此时可以进行Bitwise copy semantics,因为虚函数表vbtl是一样的,那么两个object中的vptr值也是一样的,所以可进行逐位拷贝。但当B b;A a=b;此时会出现sliced切割行为,a中的vptr不能直接用b的值,而应指向A对应实例的vbtl,因此需要相应的拷贝函数进行操作。

对于情况4,需要处理虚基类问题,确保虚基类的object在当前类中是唯一的object,保证指向虚基类的指针和偏移的正确性。

程序转化语义学program transformation semantics

拷贝构造被使用的情景:

1、显示初始化:

X x1(x0);
X x2 = x0;
X x3 = X(x0);
//实质是
X x1;
X x2;
X x3;
x1.X::X(x0);
x2.X::X(x0);
x3.X::X(x0);

感觉如果编译器没优化(RVO优化),x3可能是这样的:

X x3;

X _temp;

_temp.X::X(x0);

x3.X::X(_temp);

其实2,3两个第一个应该是先调用的赋值运算符吧?

2、参数初始化

void func(X x0){ ... }
X xx;
func(xx);
//实质是
X _temp; //创造一个临时变量
_temp.X::X(xx);  //一次拷贝构造
void func(X &x0){ ... } //函数被转换为按引用传值
func(_temp); //进行参数传递
//这种方式临时变量在func外建立,需要将func的接口改为引用传参
//否则还需要进行一次无条件的逐位拷贝将临时变量拷贝成x0并以值的形式传入
//另一种实现是copy construct(拷贝建构),直接在函数内构件temp,并将内容拷贝过去
//函数返回前,局部变量都需要进行析构

3、返回值初始化

X bar(){
    X xx;
    //处理xx
    return xx;
}
//实质是
void bar(X &__r){ //返回值变为void,增加一个引用参数; 
X xx;
//构造函数
xx.X::X(); 
//处理xx 
__r.X::X(xx); //拷贝构造
return ; 
}
//对bar的调用也需要改变
X xx = bar();//原来的代码 
bar().memfunc(); //memfunc()是X类的成员函数 
X _temp; (bar(_temp), _temp).memfunc();
//对于下面的也会被转换
X (*pf)(); 
pf = bar; 
void (*pf)(X&); 
pf = bar();

想到了右值引用。。。。

使用者层面优化

X bar(const T &y, const T &z){
    X xx;
    //用y,z来处理xx得到相应的值
    return xx;
}
//优化成
X bar(const T &y, const T &z){
  return X(y,z);
}

这是针对将yz两个参数计算得到X结果的一个函数,转变成了对X增加一个构造函数,这样避免了一次拷贝构造,因为这样直接按照上面的方式把_result作为引用参数传进来,然后对_result进行构造函数调用,构造函数内容就是原始的bar函数,而不是构造一个临时object,把算好的结果存到临时object,构造一个用于返回的object,然后拷贝构造,析构临时o,返回。

5、编译器层面优化

NRV优化:named return value已经具名的值作为返回值,如之前的3、提到的程序:

X bar(){ 
X xx; //处理xx 
return xx; } 
//实质是 
void bar(X &__r){ 
//返回值变为void,增加一个引用参数; 
X xx; 
xx.X::X(); //构造函数?
//处理
xx __r.X::X(xx); //拷贝构造 
return ; }

上述过程中可以进行优化为:

void bar(X &__r){ 
//返回值变为void,增加一个引用参数; 
__r.X::X(); //构造函数?
//处理__r
return ; }

是否用自己写拷贝构造?

如果类本身符合逐位拷贝,那么不要自己写,编译器直接进行逐位拷贝的操作效率更高。

如果有复杂的拷贝过程,可以主动写出来,编译器也会通过NRV进行一定优化。

如果类涉及到virtual,不要在自建拷贝构造中直接使用memcpy、memset等对整个object进行操作,因为编译器会在自建的拷贝构造代码的前方插入针对vptr的操作,若后续自行对整个object进行cpy、set也会同时修改了vptr值

类成员的初始化列表

以下几种情况必须使用成员初始化列表:

  1. 初始化引用成员变量
  2. 初始化const成员变量
  3. 调用基类的构造函数,并且拥有参数的时候
  4. 调用成员类对象的构造函数,并且拥有参数的时候

构造函数的初始化列表形式:class A{A():XXX(),YYY(){}}

使用初始化列表可在一定程度上提高效率,因为可以避免默认构造函数/在自定义构造函数前增加的内容,如下:

class Word{
private:
    string name;
    int cnt;
public:
    Word(){
        name = 0;
        cnt = 0;
    }
};
//实质:
Word::Word(){ 
//调用string默认构造函数 
name.string::string(); 
//产生临时对象 
string temp = string(0); 
//拷贝name 
name.string::operator=(temp); 
//摧毁临时对象 
temp.string::~string();
cnt=0;
}
//使用初始化列表:
Word():name(0),cnt(0){}
//实质
Word::Word(){ 
name.string::string(0);
cnt=0;
}

主要就是当编译器自动增加代码时,初始化列表给的初始化值会直接被编译器识别,而作为初始化值,而不是先给默认初始化然后再改值。对于不会被编译器自动加代码的没有影响。

对于模板类,不知道模板对应类型是什么的时候,最好用初始化列表。

初始化列表不影响初始化顺序,顺序仅与声明顺序有关。

初始化指定的值请使用构造函数内的值,而不是同样在初始化列表的值,避免声明顺序错误导致初始化结果不可控。

初始化列表的赋值操作在构造函数之前。

基类的初始化操作在派生类前。

最后成员变量的初始化顺序:

  1. 基类静态成员(类外初始化)
  2. 派生类静态成员(类外初始化)
  3. 基类成员
  4. 派生类成员

构造函数被调用顺序也不会因为初始化列表调用顺序而改变:

  1. 任何虚拟基类的构造函数按照它们被继承的顺序构造;
  2. 任何非虚拟基类的构造函数按照它们被继承的顺序构造;
  3. 任何成员对象的构造函数按照它们声明的顺序调用;
  4. 类自己的构造函数

 

 

赞(0) 打赏
未经允许不得转载:Techie亮博客 » 《深度探索C++对象模型》笔记(2)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏