深入了解默认构造、拷贝构造、移动构造函数

默认构造,拷贝构造,移动构造函数

背景

当我们自己定义class时不可避免接触到构造函数,如果我们自己定义了构造函数,那么当我们实例化对象时候就会调用相应的构造函数,但是如果我们没有定义自己的构造函数时候,编译器会自动为我们生成一个默认构造函数,这个构造函数的功能是怎么样的呢?同理,默认的拷贝构造和移动构造是怎么样的?

名词解释

本文须知如下名词含义:

隐式声明: 用户没有声明,编译器声明。
平凡(trivial): 编译器不会添加代码,编译器什么也不做
非平凡(no-trivial)的//隐式定义: 编译器会根据需要添加代码,生成函数

上述三个名词的解释只用于本文的默认构造、拷贝构造和移动构造。一般情况下,用户没有定义这三个函数的话编译器会隐式声明一个对应的函数(具体条件需读下文了解),编译器隐式声明函数并非一定添加代码,所以又分为平凡的和非平凡的,平凡的对应函数表示编译器不会添加任何代码,非平凡的表示编译器为了满足编译需求会添加一定代码。所以非平凡的对应函数又成为隐式定义的函数。其大致关系如下图:

函数
用户声明与定义
隐式声明
trivial 平凡:没有添加
no-trivial 非平凡的/隐式定义: 编译器会添加代码,生成函数

默认构造函数

默认构造函数定义

默认构造函数是可以无实参调用的构造函数。
从定义上看我们知道形如 MyClass()的函数为默认构造函数,其中MyClass是类名,默认构造函数可以是自己定义也可以是编译器自动生成。

隐式声明

若不对类类型(struct、class 或 union)提供任何用户声明的构造函数,则编译器将始终声明一个作为其类的 inline public 成员的默认构造函数。

平凡构造函数

平凡构造函数编译器不会做任何事情。

隐式定义/非平凡默认构造函数的情况

  • 类中有带有默认构造函数的对象成员
    类中成员对象若带有默认构造函数,那么为了调用成员对象的默认构造,编译器必须生成部分代码来调用其成员对象的默认构造:

    Class S{
        public:
        int x,y;
        S(){ x = 0; y = 0;} // 自己定义的默认构造函数
    };
    
    Class F{
        public:
        S a;    // 对象a为类F的成员对象
        int i;
    };
    

    如上所示,类S有用户定义的默认构造函数S(),类F用户并没有定义其默认构造函数,这时候编译器会生成一个 no-trivail默认构造函数,看起来像这样:

    // c++ 伪代码
    inline F::F(){
        a.S::S(); // 调用S 类的默认构造函数
    }
    

    那如果用户定义了F类的(默认)构造函数,如:

    Class F{
        public:
        S a;    // 对象a为类F的成员对象
        int i;
        F(){ i = 0;}
    };
    

    还会调用a成员对象的默认构造函数吗?答案是:yes。编译器会自动为你的(默认)构造函数添加代码进行扩张,扩张后的代码如:

    // c++ 伪代码
    F::F(){
        a.S::S(); // 调用S 类的默认构造函数
        i = 0;
    }
    

    总之,如果若类成员对象有非平凡(no-trivial)默认构造函数,则编译器会“想尽办法(添加代码)”调用其成员对象的默认构造函数。

  • 基类有非平凡默认构造函数
    与成员对象含有非平凡构造函数类似,其基类含有非平凡的默认构造函数时,编译器也会生成非平凡默认构造函数,并调用基类的非平凡默认构造函数;当用户定义了(默认)构造函数时,编译器也会对其进行扩张,来调用基类的非平凡默认构造函数

  • 带有虚函数(virtual function)的类或继承自带有虚函数的类
    满足这个条件的类,会有如下两个扩张行动在编译时期发生:

    1. 一个虚函数表会被编译出来,内存有虚函数地址指针
    2. 每个类对象中会有一个虚函数指针(vptr)被合成出来,存有虚函数表地址。

    所以这就需要对vptr的值进行设置,而此时编译器就会生成一个非平凡默认构造函数,来对vptr进行设置;同理,如果用户定义了(默认)构造函数,也会对其进行扩张。

  • 虚拟继承的类(菱形继承)
    使用visual Base Class 的类,能够实现基类的数据值共享,这就需要编译器产生一个指向基类数据的指针,与vptr类似,其数据指针也需要在默认构造函数中进行设置;或在自己定义的(默认)构造函数中进行扩张。


拷贝构造函数

拷贝构造函数调用场景

拷贝构造函数的调用场合有三种:

  1. 以一个对象初始化另一个对象如:
    class MyClass{......};
    MyClass a;
    MyClass b = a; // 对象初始化
    
  2. 函数参数的对象传递
    // MyClass b;
    // void fun(MyClass a);
    fun(b); // 将对象b作为参数传入fun
    
  3. 作为参数的返回值(无移动构造函数)
    MyClass fun(){ 
     MyClass b; 
     ......
     return b;
    }
    

拷贝构造函数语法

形如:

MyClass(const MyClass & t) {...... }

隐式声明

拷贝构造函数可以用用户定义也可以编译器自己生成。
若不对类类型(struct、class 或 union)提供任何用户定义的复制构造函数,则编译器始终会声明一个复制构造函数,作为其类的非 explicit 的 inline public 成员。
同默认构造函数一样,编译器产生的拷贝构造函数也分trivial 和no-trivial 拷贝构造函数。编译器只有需要生成非平凡拷贝构造函数的时候才会生成,否则的话就是平凡的拷贝构造函数。

平凡(trivial)拷贝构造

平凡拷贝构造会进行按位拷贝(Bitwise copy),类似于memcpy。当然,虽然有时按位拷贝能够满足编译器的需求,但是未必满足用户需求,比如类中定义指针,进行拷贝构造的时候会产生两个对象中的成员指针指向同一个字符串。

隐式定义的拷贝构造/非平凡拷贝构造

有时候按位拷贝不能满足编译器需要(无法完成语义的需求),需要编译器生成一个非平凡的拷贝构造函数。什么情况下会出现这种状况呢?

  1. 类成员对象中有非平凡拷贝构造
  2. 继承一个有非平凡拷贝构造函数的基类
  3. 类中有虚函数
  4. 虚拟继承

这四个条件与默认构造函数类似,解释也类似。第1、2种情况就不做解释,详细解释第3、4的场景。
在类中存在虚函数时,如下

class Base{
  public:
    int a;
    virtual void f(){cout<< "base f";}
};
class Derived: public Base{
  public:
    int b;
    void f() override {cout<< "derived f";}
};

代码所示,类Derived继承了Base,并复写了虚函数f,那么类Base与类Derived的对象中各有一个虚函数表指针,指向各自类的虚函数表,考虑如下代码:

Derived d;
Base b = d;  // 用Derived类d对象初始化Base类对象,会发生切割行为
b.f();  // 输出:base f

这种操作是合法的,但是会发生切割行为,但是这个时候如果发生按位拷贝,那么对象d中的vptr会拷贝给b,这样b调用虚函数时候则无法找到类Base的虚函数表。所以编译器为保证语法的合理性,为此生成一个拷贝构造函数,显示指定vptr的值。
同理,虚拟继承会产生指向共同继承类的指针,子类赋值初始化时,编译器必须生成拷贝构造函数设置指针的偏移等。


移动构造函数

移动构造函数与拷贝构造类似,不同的是初始化对象带有移动语义,不再详细分析,以下均来自cpprefference。

移动构造函数调用场景

当(以直接初始化或复制初始化)从同类型的右值(亡值或纯右值) (C++17 前)亡值 (C++17 起)初始化对象时,调用移动构造函数,情况包括

  1. 以一个对象初始化另一个对象如:
    class MyClass{......};
    MyClass a;
    MyClass b = std::move(a); // 对象初始化
    
  2. 函数参数的对象传递
    // MyClass b;
    // void fun(MyClass a);
    fun(std::move(b)); // 将对象b作为参数传入fun
    
  3. 作为参数的返回值,有移动构造函数
    MyClass fun(){ 
     MyClass b; 
     ......
     return b;
    }
    

移动构造函数语法

形如:

MyClass(const MyClass && t) {...... }

隐式声明的移动构造函数(编译器会自动生成的)

若不对类类型(struct、class 或 union)提供任何用户定义的移动构造函数,且下列各项均为真:

  • 没有用户声明的复制构造函数;
  • 没有用户声明的复制赋值运算符;
  • 没有用户声明的移动赋值运算符;
  • 没有用户声明的析构函数;

隐式声明的移动构造函数并未因为下一节所详述的条件而被定义为弃置的 (C++14 前),则编译器将声明一个移动构造函数,作为其类的非 explicit 的 inline public 成员,签名为 T::T(T&&)。

弃置的隐式声明的移动构造函数(不会产生移动构造函数)

若下列任何一项为真,则类 T 的隐式声明或预置的移动构造函数被定义为弃置的:

  • T 拥有无法移动(拥有被弃置、不可访问或有歧义的移动构造函数)的非静态数据成员;
  • T 拥有无法移动(拥有被弃置、不可访问或有歧义的移动构造函数)的直接或虚基类;
  • T 拥有带被弃置或不可访问的析构函数的直接或虚基类;
  • T 是联合式的类,且拥有带非平凡移动构造函数的变体成员;
  • T 拥有非静态数据成员或直接或虚基类,它无法平凡复制且没有移动构造函数。 (C++14 前)

重载决议忽略被弃置的隐式声明的移动构造函数(否则它会阻止从右值复制初始化)。 (C++14 起)

平凡移动构造函数

当下列各项全部为真时,类 T 移动构造函数为平凡的:

  • 它不是用户提供的(即它是隐式定义或预置的);
  • T 没有虚成员函数;
  • T 没有虚基类;
  • 为 T 的每个直接基类选择的移动构造函数都是平凡的;
  • 为 T 的每个类类型(或类类型数组)的非静态成员选择的移动构造函数都是平凡的;

T 没有 volatile 限定类型的非静态数据成员。 (C++14 起)
平凡移动构造函数是与平凡复制构造函数实施相同动作的构造函数,即它如同用 std::memmove 来进行对象表示的复制。所有与 C 兼容的数据类型(POD 类型)均为可平凡移动的。

隐式定义的移动构造函数(非平凡移动构造函数)

若隐式声明的移动构造函数既未弃置亦非平凡,则当其被 ODR 式使用时,它为编译器所定义(生成并编译函数体)。对于 union 类型,隐式定义的移动构造函数(如同以 std::memmove)复制其对象表示。对于非联合类类型(class 与 struct),移动构造函数用以亡值实参执行的直接初始化,按照初始化顺序,对对象的各基类和非静态成员进行完整的逐对象移动。若它满足对于 constexpr 构造函数的要求,则生成的移动构造函数为 constexpr。

总结

  • 如果用户没有定义这些函数,则编译器会隐式声明一个函数
  • 在编译器需要的情况下(如带有虚函数,虚拟继承等等),会隐式定义函数,这时函数为非平凡的(no-trivial);否则则编译器不会添加代码来定义一个函数,这时的函数为平凡的(trivial)
  • 移动构造隐式声明的条件比较苛刻,不但与用户定义的移动构造有关,还与用户复制构造等有关。
  • 平凡的默认构造什么也不做;平凡的拷贝构造和平凡的移动构造操作是相同的,进行按位拷贝。

你可能感兴趣的:(C++)