关于拷贝构造函数

使用一个对象的内容作为另一个类对象的初值,有三种情况:

  • 对一个对象做显式的初始化

    1
    2
    class X { ... };
    X xx = x;
  • 当对象被当作一个函数的参数时

  • 当函数返回一个类对象时

C++ 标准规定,如果类没有声明一个拷贝构造,就会有隐式的声明或者隐式的定义出现。同样的,C++标准会把拷贝构造函数分为“有用的”、和“无用的”两种。只有“有用的”的实例才会被合成于程序之中。决定一个拷贝构造函数是否有用的标准在于类是否展现出所谓的“bitwise copy semantics”

一、Bitwise copy semantics(逐位拷贝)

什么时候一个类不展现出 “bitwise copy semantics” 呢?有 4 种情况:

  • 当一个类含有一个成员类对象,并且这个成员类对象所在的类声明有一个拷贝构造时(无论是被程序猿显式声明、还是被编译器隐式合成)
  • 当一个类继承一个基类,并且这个基类存在一个拷贝构造函数时(无论是程序猿显式声明、还是被编译器隐式合成)
  • 当一个类声明了一个或多个虚函数时
  • 当一个类派生自一个继承串链,其中有一个或多个虚基类时

前两种情况中,编译器必须将 数据成员 或 基类 的拷贝构造函数插入到被合成的拷贝构造中。后两种情况解释下:

  • 如果一个类对象中存在虚表指针,因此不能“Bitwise copy semantics”,需要编译器合成出一个拷贝构造将虚表指针初始化

    当一个子类对象赋值给基类对象时,他的虚表指针的复制操作需要保证安全。

    1
    2
    3
    Derived derived;
    Base base = derived; // 子类对象直接复制给基类对象,会导致对象被切割
    // 同时 Base 类的拷贝构造对于虚表指针的设置,一定是基类的虚表指针,而不是子类的虚表指针。

    因此编译器如果需要合成拷贝构造,合成出来的拷贝构造函数中,一定会显式的正确设置对象的虚表指针,不会直接拷贝对象虚表指针(尤其对于存在基类和子类的情况)

    如果是相同的类,不同的对象之间进行拷贝,那么此时也可以进行“Bitwise copy semantics”

  • 一个类对象如果以另一个对象作为初值,而后者有一个虚基类,那么也会使 “Bitwise copy semantics” 失效。每一个编译器对于虚拟继承的支持承诺,都代表必须让子类对象中的 “虚基类子对象” 位置在执行期前就准备妥当。维护位置的完整性是编译器的责任。“Bitwise copy semantics” 可能会破坏这个位置,所以编译器必须在他自己合成出来的拷贝构造中做仲裁。

    问题发生在一个类对象以其 “子类对象” 作为初值时,不会进行 “Bitwise copy semantics”。而对于相同的类对象之间的赋值,则不会有影响。

二、对象作为函数参数

有两种实现方法,一种是:

1
2
3
4
5
6
X xx;
foo (xx);
===>
1. 编译器产生一个临时对象 X __tmp;
2. 编译器对这个临时对象进行拷贝构造 __tmp.X::X(xx);
3. 重新改写函数调用操作,使用这个临时对象 foo(__tmp);

他进行了一次默认构造,然后又进行了一次拷贝构造(“Bitwise copy semantics” 方式)。效率降低。我们不希望这种传参方式,而是希望以指针或引用的方式。

另一种是:

把实际参数直接建构在其应该的位置上,此位置视函数活动范围的不同,记录于程序堆栈中。在函数返回之前,局部对象的析构(如果有定义的话)会被执行。

在 STL 也是对应 push_back 和 emplace_back 的实现方式

三、对象作为函数返回值

1
2
3
4
5
X bar() {
X xx;
...
return xx;
}

这个 bar 函数的返回值如何从局部对象 xx 中拷贝过来呢?如下是一种解决方案。

  1. 首先加上一个额外参数,类型是类对象的一个引用。这个参数用来放置被 “拷贝构建” 而得的返回值
  2. 在 return 指令之前插入一个拷贝构造操作,以便将待传回对象的内容当作上述新增参数的初值。这个转化操作会重写函数,使他不传回任何值
1
2
3
4
5
6
void bar(X& __result) {  // 加上了一个额外参数
X xx;
xx.X::X(); // 编译器产生的默认构造函数
...
__result.X::XX(xx); // 编译器产生的拷贝构造
}

因此编译器会对代码做转换操作:

1
2
3
4
5
原来是如下这种对象作为函数返回值
X xx = bar()
经过转换后,变成了
X xx;
bar(xx);

或者还有其他逻辑实现,如下:

1
2
3
4
5
原来的代码直接使用返回值,用此返回值调用 memfunc 函数
bar().memfunc();
经过转换后,变成了
X __tmp; // 编译器会产生临时性对象
( bar(__tmp), __tmp).memfunc();

同理,如果程序声明了一个函数指针,如下:

1
2
3
4
5
X (*pf)();
pf = bar;
经过转换后,变成了
void (*pf)(X&);
pf = bar;