C++拷贝构造函数、无名临时对象以及NRV优化分析

来源:互联网 发布:c语言调用java接口 编辑:程序博客网 时间:2024/06/05 19:38


一:合成 Default Copy Constructor


那么什么时候编译器会为一个 class 生成一个 default copy constructor 呢?

和 default constructor 一样,C++ Standard 上说,如果 class 没有声明一个 copy constructor,就会有隐含的声明(implicitly declared)或隐含的定义(implicitly defined)出现,和以前一样,C++ Standard 把 copy constructor 区分为 trivial 和 nontrivial 两种,只有 nontrivial 的实体才会被合成于程序之中,决定一个 copy constructor 是否为 trivial 的标准在于 class 是否展现 “bitwise copy semantics”,即位逐次拷贝语义。如果没有展现,编译器将会为其合成 default copy constructor。


其实,bitwise copy = 浅拷贝,memberwise copy = 深拷贝 :)


什么时候一个 class 不展现出 “bitwise copy semantics” 呢,有四种情况:

  1. 当 class 内含一个 member object 而后者的 class 声明有一个 copy constructor 时
  2. 当 class 继承自一个 base class 而后者存在有一个 copy constructor 时 
  3. 当 class 声明了一个或多个 virtual functions 时
  4. 当 class 派生自一个继承串链,其中有一个或多个 virtual base classes 时

情况1,2比较易懂,和 constructor 类似,无非就是要安插别人的 copy constructor,所以要编译器为其合成 default copy constructor。
现在来说说 3 和 4这两种情况。

假如现有一个 virtual base 类 ZooAnimal,和一个 derived 类Bear,那么有以下情况,并且下面情况都是具有 virtual 属性的,符合 3 和 4 情况 :

无需合成的情况

ZooAnimal class object 以另一个 ZooAnimal class object 作为初值,或 Bear class object 以另一个 Bear class object 作为初值,都可以直接靠 “bitwise copy semantics” 完成,所以这个时候编译器不会为我们合成 copy constructor(但是要使用编译器的 default constructor必须注意类型于指针情况,比如字符串指针,指针如果直接赋值它们会指向一个字符串,析构函数如果内部 delete 可能会出现问题)。因为原对象和拷贝后的对象为同一 class 类型的对象,它们调用相同的虚函数,这都是它们的类共有的函数,是一模一样的,所以浅拷贝足矣。


//简单的 bitwise copy 还不够//编译器必须明确地将 little_critter 的//virtual base class pointer/offset 初始化RedPanda little_red;Raccoon little_critter = little_red;


父类以子类内容做初始化操作情况

    ZooAnimal franny = yogi;    //译注:这会发生切割(sliced)行为

当一个 base class object 以其 derived class 的 object 内容做初始化操作时,其 vptr 复制操作必须保证安全。父类的 vptr 不可以指向子类的 virtual table。因为父类声明一个对象需要调用自己的 virtual function,如果编译器不合成默认拷贝构造函数,那么父类的 vptr 会指向子类的 virtual table,再调用就会调用子类的 virtual function,这叫“炸毁”。


这种情况比较难理解,下面我来举个例子:

class base {   public:      base(int i) : int_base_(i) {}      virtual void foo() { std::cout<<"base"<<std::endl; }       virtual ~base() = default;  protected:      int int_base_;   //data member also can be overrided  };    class derived : public base {   public:      derived(int i) : int_(i), base(4) { }        ~derived() = default;      void foo() { std::cout<<"derived"<<std::endl; }   private:      int int_;  };  void do_polymorphic(base& ptr)  {      ptr.foo();  }    int main()  {      derived dr(5);      base b = dr;     do_polymorphic(b);    return 0;}
这段代码子类 override 了虚基类父类的 foo 方法,如果编译器没有为我们合成 default copy constructor 去为父类明确设定 vptr 指向父类自己的 virtual table ,而是按照 bitwise copy 的话,父类调用 foo 方法将会调用子类的 foo 方法,因为 bitwise copy 即浅拷贝的方式只是会将父类的 vptr 简单的指向子类的 virtual table(相当于指针赋值)。

但是输出结果是这样的:

这证明了在当 class 声明了一个或多个 virtual functions 时,父类使用子类初始化,编译器需要合成 default copy constructor。


处理 Virtual Base Class Subobject

最后这一种特殊情况是,在一个继承串链中,一个 class object(派生自虚基类)如果以另一个 object 作为初值,而后者有一个 virtual base class subobject,那么也会使 “bitwise copy” 失效,编译器此时需要合成default copy constructor。
如图:

再强调一次,如果以一个 Raccoon object 作为另一个 Raccoon object 的初值,浅拷贝绰绰有余了(它们是同一类)。
如果企图以一个 RedPanda object 作为 little_critter 的初值,编译器必须判断 “后续当程序员企图存取其 Zooanimal subobject 时能否正确的执行”:
//简单的 bitwise copy 还不够//编译器必须明确地将 little_critter 的//virtual base class pointer/offset 初始化RedPanda little_red;Raccoon little_critter = little_red;
在这种情况下,为了完成正确的 little_critter 初值设定,编译器必须合成一个 copy constructor,安插一些码以设定 virtual base class pointer/offset 的初值(或者只是简单地确定它没有被抹消)。
最后这种情况这一理解为,编译器为了防止 virtual base subobject 在执行期间放到正确的位置并且不被破坏,而采取的合成 default copy constructor 操作。


二:程序转化语意学


关于临时对象的总结:

首先给出我测试所用的类,下文测试代码仅给出部分,均在主函数中运行,主函数结束会进行析构操作:
class base {   public:     base() { std::cout<<"base"<<std::endl; }    base(int i) : int_base_(i) { std::cout<<"base"<<std::endl; }       base(const base& othread) { std::cout<<"copy base"<<std::endl; }    virtual void foo() const { std::cout<<"foo: base"<<std::endl; }       virtual ~base() { std::cout<<"~base"<<std::endl; }   protected:      int int_base_;   //data member also can be overrided  };  

1.使用一个临时对象来初始化一个新对象时,编译器一般会优化为直接使用临时对象的参数来创建新对象,实际上不会生成临时对象。

如:
{    std::cout<<"x0 begin"<<std::endl;    base x0 = base(5);      //甚至 base x0(base(5)) 这种拷贝构造初始化也会被优化    std::cout<<"x0 end"<<std::endl;    std::cout<<"main end"<<std::endl;}
输出:

base 生成之后,在函数结束后析构掉,编译器优化掉临时对象,只生成了一个对象。


2.使用一个临时对象来赋值一个对象时,临时对象创建完会直接被销毁掉

如:
{    base x0;     std::cout<<"x0 begin"<<std::endl;    x0 = base(5);    std::cout<<"x0 end"<<std::endl;    std::cout<<"main end"<<std::endl;}
输出:



3. 类对象作为函数参数按值传递时,使用临时对象作为函数使用的副本

// C++ 伪码,假如要调用 X xx; foo(xx);//编译器产生出来的临时对象X __temp0;//编译器对 copy ocnstructor 的调用__temp0.X::X( xx );//被调用的函数实际上为foo( __temp0 )
测试如下:
void do_polymorphic(base ptr){       std::cout<<"fun begin"<<std::endl;    ptr.foo();      std::cout<<"fun end"<<std::endl;}main(){    base b;    std::cout<<"after declare"<<std::endl;    do_polymorphic(b);    std::cout<<"call fun end"<<std::endl;    std::cout<<"main end"<<std::endl;}
输出:


注意两点:
  1. 引用传递可以避免 __temp0 临时对象的产生,不会调用拷贝构造函数。
  2. __temp0 是在 main() 函数内,未调用 do_polymorphic() 函数前产生的,并且在该函数调用完毕后立即销毁,类似于上面的 2 。(用 GDB 可以调试查看)



返回值的初始化

已知下面这个函数定义:
X bar(){    X xx;    //处理 xx ...    return xx;}
如何将 bar() 的返回值如何从局部对象 xx 中拷贝过来?解决方案是双阶段转化:
  1. 首先加上一个额外参数,类型是类的一个引用,这个参数用来放置被拷贝构造而得的返回值。
  2. 在 return 指令之前安插一个拷贝构造操作,以便将欲传回的对象的内容当作上述新增参数的初值。
根据这样的算法,bar() 转化如下:
//函数转换//以反应出 copy constructor 的应用//C++ 伪码void bar( X& __result )  //在这里加上额外参数{    X xx;    //编译器所产生的 default constructor 操作    xx.X::X();    // ... 处理 xx     //编译器所产生的 copy constructor 操作    __result.X::XX( xx );    return;}
现在我们的编译器在外部调用该函数就会就这样的转化:
//原语句X xx = bar();//编译器转化为以下两个指令句X xx;bar( xx );
好的,上面这些就是返回值初始化的原理。

上述代码要先构造局部变量 xx,然后返回时又将其拷贝到 __result 作为返回值,效率很低。有两种解决方案:
  1.  在程序员层面做优化,直接计算 __result。(其实就是类似 return X(); ,这种做法,返回的时候构造一次就可以了)。
  2. 编译器层面做优化,即 NRV(Name Return Value)优化。编译器可以在 return 指令传回相同的临时有名对象(即 xx 对象)的情况时,直接处理 __result,不生成对象xx。NRV 优化要求 class 必须具备拷贝构造函数。

NRV优化:
对于下列代码:
base x0, x1;base x2 = add(x0, x1);
如果编译器未开启NRV优化,会生成如下东东:
base __temp0;     // 构造函数.add(__temp0, x0, x1);base (__temp0);  // 拷贝构造函数.
而开启NRV优化后,只会生成下列代码:
add(x2, x0, x1);
所谓的NRV优化,即保存返回值的变量不再使用编译器内部生成的__temp0这样的东西,而是直接把 x2 直接作为返回变量。

书中说,NRV优化和拷贝构造函数是有关系的,只有定义了拷贝构造函数才会开启NRV优化,但现代编译器NRV优化的开启一般都与拷贝构造函数没有关系,下面一段话摘自网络,参考关于cfront的NRV优化,解释了为什么lippman在书中说有关的原因:
  • “早期的 cfront需要一个开关来决定是否应该对代码实行NRV优化,这就是是否有客户(程序员)显式提供的拷贝构造函数:如 果客户没有显示提供拷贝构造函数,那么cfront认为客户对默认的逐位拷贝语义很满意,由于逐位拷贝本身就是很高效的,没必要再对其实施NRV优化;但 如果客户显式提供了拷贝构造函数,这说明客户由于某些原因(例如需要深拷贝等)摆脱了高效的逐位拷贝语义,其拷贝动作开销将增大,所以将应对其实施NRV 优化,其结果就是去掉并不必要的拷贝函数调用。”
  • 需要说明的一点:NRV优化会导致原本预想中的调用“拷贝构造函数”变成调用别的“构造函数”,一旦这个时候,拷贝构造函数和别的构造函数提供的功能不同,就可能会出问题。
目前 NRV 优化是 g++ 的默认行为。


三:copy constuctor 要还是不要?


什么时候我们应该为 class 提供一个 explicit copy constructor 呢? 如果是编译器自动为你实施了最好的行为,那么就不需要自己定义 copy constructor,尤其是成员类型为 POD 类型的时候。不过,如果类的某个方法想要开启 NRV 优化,那倒是可以定义 copy constructor。甚至针对简单类型可以在 copy constructor 中直接用 memset 函数,但是memset 绝对不能用在 virtual class 中,因为别忘了 vptr 的存在。



四:成员初始化列表的队伍


下列情况下,为了让你的程序能够顺利被编译,你必须使用成员初始化列表:
  1. 当初始化一个 reference 成员时
  2. 当初始化一个 const 成员时
  3. 当调用一个基类的构造函数,而它拥有一组参数时
  4. 当调用一个成员类的构造函数,而它拥有一组参数时
并且要注意,成员初始化的顺序和初始化列表无关,只依赖于它们在类中声明的顺序。
(拓展知识:继承时,继承的类构造顺序也和继承的顺序一致,比如:public A, public B,那么构造顺序就是A,B。但是一旦有 virtual 继承出现,该 virtual base class 会首先被构造,因为我们要确保 virtual table 的位置正确放置)


0 0