23. 拷贝控制

拷贝控制(copy control)
  • 拷贝构造函数(copy constructor)

  • 拷贝赋值运算符(copy-assignment operator)

  • 移动构造函数(move constructor)

  • 移动赋值运算符(move-assignment operator)

  • 析构函数(destructor)

23.1. 拷贝构造函数

拷贝构造函数的第一个参数必须是引用类型

在函数调用中,具有非引用类型的参数要进行拷贝初始化。类似地,当一个函数具有非引用类型的返回类型时,返回值会被用来初始化调用方的结果。

拷贝构造函数被用来初始化 非引用类类型 (被初始化的是类的非引用对象)参数,如果拷贝构造函数的参数不是引用类型,为了调用拷贝构造函数, 我们必须拷贝它的实参,然而拷贝实参又需要调用拷贝构造函数,如此无限循环。

如果我们没有为类定义拷贝控制函数,编译器会为我们定义一个。与合成默认构造函数不同(如果定义了其他构造函数,则需要我们再显式定义默认构造函数), 即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。

如果有指针类型的成员数据,编译器合成的拷贝函数就只会复制指针(而不是复制指针指向的内容),从而得到一个相同指向的复制品(浅复制)。 很可能这并不是我们想要的,这时需要考虑自己动手定义拷贝构造函数。

拷贝构造函数的调用时机有如下三种:

  • 当用类的一个对象去初始化该类的另一个对象时。

  • 当函数的形参是类的对象,调用函数时。

  • 当函数的返回值是类的对象,函数执行完成返回时。

23.2. default 和 delete

使用 =default

将拷贝控制成员定义为 =default ,显式要求编译器生成合成的版本。

  • 类内使用 =default 修饰成员的声明,则合成的函数隐式地声明为内联函数(注:定义在类内的函数自动为内联函数)。

  • 类外使用 =default 修饰成员的定义,则合成的成员不是内联函数。

  • 只能对默认构造函数或拷贝控制成员使用 =default

使用 =delete

在函数参数列表之后加上 =delete 定义为 删除的函数 :虽然有声明,但是不能以任何形式使用它们。

将拷贝构造函数和拷贝赋值运算符定义为删除的函数,从而阻止拷贝操作。

23.3. 直接初始化和拷贝初始化

直接初始化直接调用与实参匹配的构造函数,拷贝初始化总是调用拷贝构造函数。

1string dots(10, '.');         // 直接初始化
2string s(dots);               // 直接初始化
3
4string s2 = dots;             // 拷贝初始化
5string s3 = "999-9999";       // 拷贝初始化
6string s4 = string(100, '9'); // 拷贝初始化
 1class ClassTest
 2{
 3public:
 4    ClassTest()
 5    {
 6        c[0] = '\0';
 7        cout << "ClassTest()" << endl;
 8    }
 9
10    ClassTest& operator=(const ClassTest &ct)
11    {
12        strcpy(c, ct.c);
13        cout << "ClassTest& operator=(const ClassTest &ct)" << endl;
14        return *this;
15    }
16
17    ClassTest(const char *pc)
18    {
19        strcpy(c, pc);
20        cout << "ClassTest (const char *pc)" << endl;
21    }
22
23    // private:
24    ClassTest(const ClassTest& ct)
25    {
26        strcpy(c, ct.c);
27        cout << "ClassTest(const ClassTest& ct)" << endl;
28    }
29private:
30    char c[256];
31};

调用:

 1ClassTest ct1("ab");          // 直接初始化
 2// 输出: ClassTest (const char *pc)
 3
 4ClassTest ct2 = "ab";         // 拷贝初始化
 5// 输出: ClassTest (const char *pc)
 6// 首先调用构造函数 ClassTest(const char *pc) 函数创建一个临时对象;然后调用拷贝构造函数,把这个临时对象作为参数,构造对象ct2
 7// 然而结果并没有输出 ClassTest(const ClassTest& ct)。有说法是编译器优化之后,直接匹配了 ClassTest(const char *pc),不再调用拷贝构造函数
 8
 9ClassTest ct3 = ct1;          // 拷贝初始化
10// 输出: ClassTest(const ClassTest& ct)
11// ct1 已经存在,直接调用拷贝构造函数
12
13ClassTest ct4(ct1);           // 直接初始化
14// 输出: ClassTest(const ClassTest& ct)
15// ct1 已经存在,直接调用拷贝构造函数
16
17ClassTest ct5 = ClassTest();  // 拷贝初始化
18// 输出: ClassTest()
19// 首先调用默认构造函数产生一个临时对象;然后调用拷贝构造函数,把这个临时对象作为参数,构造对象ct5
20
21ct3 = ct2;                    // 赋值
22// 输出: ClassTest& operator=(const ClassTest &ct)

当把拷贝构造函数设置为 private ,ct3、ct4、ct5的初始化都无法完成。

23.4. explicit

This keyword is a declaration specifier that can only be applied to in-class constructor declaration.
An explicit constructor cannot take part in implicit conversions. It can only be used to explicitly construct an object.

单个参数的构造函数(或者除了第一个参数外其余参数都有缺省值的多参构造函数)承担了两个角色:

  • 用于构建单参数的类对象;

  • 隐含的类型转换操作符。

explicit 指定转换函数(C++11 起)或构造函数为显式,即它不能用于隐式转换和拷贝初始化。

声明为 explicit 的构造函数不能在隐式转换中使用,只能显式调用去构造一个类对象。其好处在于可以禁止编译器执行非预期(往往也不被期望)的类型转换。 但是将拷贝构造函数声明成 explicit 并不是良好的设计。

 1struct A
 2{
 3    A(int) { }      // 转换构造函数
 4    A(int, int) { } // 转换构造函数 (C++11)
 5    operator bool() const { return true; } // 类型转换函数
 6};
 7
 8struct B
 9{
10    explicit B(int) { }
11    explicit B(int, int) { }
12    explicit operator bool() const { return true; }
13};
14
15int main()
16{
17    A a1 = 1;      // OK:复制初始化选择 A::A(int)
18    A a2(2);       // OK:直接初始化选择 A::A(int)
19    A a3 {4, 5};   // OK:直接列表初始化选择 A::A(int, int)
20    A a4 = {4, 5}; // OK:复制列表初始化选择 A::A(int, int)
21    A a5 = (A)1;   // OK:显式转型
22    if (a1) ;      // OK:A::operator bool()
23    bool na1 = a1; // OK:复制初始化选择 A::operator bool()
24    bool na2 = static_cast<bool>(a1); // OK:static_cast 进行直接初始化
25
26//  B b1 = 1;      // 错误:复制初始化不考虑 B::B(int)
27    B b2(2);       // OK:直接初始化选择 B::B(int)
28    B b3 {4, 5};   // OK:直接列表初始化选择 B::B(int, int)
29//  B b4 = {4, 5}; // 错误:复制列表初始化不考虑 B::B(int, int)
30    B b5 = (B)1;   // OK:显式转型
31    if (b2) ;      // OK:B::operator bool()
32//  bool nb1 = b2; // 错误:复制初始化不考虑 B::operator bool()
33    bool nb2 = static_cast<bool>(b2); // OK:static_cast 进行直接初始化
34}

23.5. initializer_list

#include <initializer_list>

initializer_list<T> 类的对象是一个访问 const T 类型对象数组的轻量代理对象。

以下场景会发生 initializer_list 对象的自动构造:

  • 用花括号列表初始化一个对象(列表初始化),对应的构造函数接受一个 initializer_list 参数。

  • 以花括号列表为赋值运算符的右运算数,或作为函数调用的参数,而对应的赋值运算符/函数接受 initializer_list 参数。

  • 绑定花括号列表到 auto ,在范围 for 循环(Range For Loop)中使用。

initializer_list 可由一对指针(分别指向列表首、尾)或指针+长度实现, 复制一个 initializer_list 不会复制其底层对象。

成员函数包括: size begin end

 1#include <iostream>
 2#include <vector>
 3#include <initializer_list>
 4using namespace std;
 5
 6template<typename T>
 7class Vec
 8{
 9public:
10    vector<T> v;
11    Vec(initializer_list<T>);
12    void append(initializer_list<T>);
13    pair<const T*, size_t> c_arr() const;
14};
15
16template<typename T>
17Vec<T>::Vec(initializer_list<T> lt): v(lt){}
18
19template<typename T>
20void Vec<T>::append(initializer_list<T> lt)
21{
22    v.insert(v.end(), lt.begin(), lt.end());
23}
24
25template<typename T>
26pair<const T*, size_t> Vec<T>::c_arr() const
27{
28    return {&v[0], v.size()};
29}
30
31template<class T>
32void print(T val_list)
33{
34    for(auto& val: val_list) cout << val << "#"; // range for loop
35    cout << endl;
36}
37
38int main()
39{
40    Vec<int> va = {1,2,3}; //  拷贝初始化
41    va.append({4,5});
42
43    print(va.v); // 1#2#3#4#5#
44
45    auto p = va.c_arr();
46    cout << *p.first << " " << p.second << endl; // 1 5
47
48    // print({1,2,3,4,5}); // 编译错误,{1,2,3,4,5} 不是表达式,无类型,因此 T 无法推导
49    print<vector<int>>({1,2,3,4,5}); // 1#2#3#4#5#
50    print<initializer_list<int>>({1,2,3,4,5}); // 1#2#3#4#5#
51
52    for(auto n: {1,2,3,4,5}) cout << n << ends;
53
54    return 0;
55}

23.6. push 和 emplace

在 19 章提到了 push 和 emplace 的区别,这里用一个例子说明。

\(\color{darkgreen}{Example}\)

 1#include <iostream>
 2#include <utility>  // std::move
 3
 4class Foo
 5{
 6public:
 7  Foo(std::string str) : name(str)
 8  {
 9    std::cout << "constructor" << std::endl;
10  }
11
12  Foo(const Foo& f) : name(f.name)
13  {
14    std::cout << "copy constructor" << std::endl;
15  }
16
17  Foo(Foo&& f) : name(std::move(f.name))
18  {
19    std::cout << "move constructor" << std::endl;
20  }
21
22private:
23  std::string name;
24};
25
26int main(int argc, char ** argv)
27{
28  std::vector<Foo> v;
29  int count = 10000000;
30  v.reserve(count);
31
32  {
33    Foo temp("test");
34    // constructor
35    v.push_back(temp);// push_back(const T&),参数是左值引用
36    // copy constructor
37  }
38
39  v.clear();
40  {
41    Foo temp("test");
42    // constructor
43    v.push_back(std::move(temp));// push_back(T &&), 参数是右值引用
44    // move constructor
45  }
46
47  v.clear();
48  {
49    v.push_back(Foo("test"));// push_back(T &&), 参数是右值引用
50    // constructor
51    // move constructor
52  }
53
54  v.clear();
55  {
56    std::string temp = "test";
57    v.push_back(temp);// 构造临时对象,push_back(T &&), 参数是右值引用
58    // constructor
59    // move constructor
60  }
61
62  v.clear();
63  {
64    std::string temp = "test";
65    v.emplace_back(temp);// 只有一次构造函数,不调用拷贝构造函数,速度最快
66    // constructor
67  }
68
69  return 0;
70}

Note

std::move 并不会真正地移动对象(真正的移动操作是移动构造函数、移动赋值运算符等完成的), std::move 只是将参数转换为右值引用。

我们可以销毁一个移动之后的源对象(moved-from),也可以赋予它新值,但是不能使用一个移后源对象的值。

如:上例中的 temp 被移动后,就不能再取它的值来使用。

23.7. 参考资料

1.《C++ Primer 第5版 中文版》 Page 440 – 442,449,470 – 475。

  1. C++的一大误区——深入解释直接初始化与复制初始化的区别

  1. C++11使用emplace_back代替push_back

  1. explicit 说明符

  1. initializer_list