《C++ Primer》 拾遗 第 13 章 拷贝控制
Notes Cpp Primer
Lastmod: 2021-08-01 周日 00:44:37

第 13 章 拷贝控制

拷贝控制 copy control 操作包括:

拷贝构造函数 copy constructor

拷贝赋值运算符 copy-assignment operator

移动构造函数 move constructor

移动赋值运算符 move-assignment operator

析构函数 destructor

13.1 拷贝、赋值与销毁

合成拷贝构造函数 synthesized copy constructor

#include <iostream>
#include <string>
using namespace std;

class A {
  public:
  	string name;
	A() : name("") { 
		cout << "default ctor" << endl; 
	}
	A(string name) : name(name) {
		cout << "ctor" << endl;
	}
	A(const A& a) : name(a.name) { 
		cout << "copy ctor" << endl; 
	}
	void print() {
		cout << name << endl;
	}
};
A func(A a) {
	return a;
}
int main() {
	cout << "a" << endl;
	A a = A();  // default ctor

	cout << "b" << endl;
	A b;        // default ctor

	cout << "c" << endl;
	A c("c");  // ctor

	cout << "d" << endl;
	A d = A("d");  // ctor

	cout << "e" << endl;
	A e(c);  // copy ctor

	cout << "f" << endl;
	A f = c; // copy ctor
    
    cout << "g = f(a)" << endl;
	A g = func(a); // copy ctor; copy ctor
	return 0;
}

关于 explicit 带来的变化

#include <iostream>
#include <string>
using namespace std;

class A {
  public:
  	int id = 0;
	A() { 
		cout << "default ctor" << endl; 
	}
	A(int id) : id(id) {
		cout << "ctor" << endl;
	}
	A(const A& a) : id(a.id) { 
		cout << "copy ctor" << endl; 
	}
	void print() {
		cout << id << endl;
	}
};
A func(A a) {
	return a;
}

class B {
  public:
  	int id = 0;
	B() { 
		cout << "default ctor" << endl; 
	}
	explicit B(int id) : id(id) {
		cout << "ctor" << endl;
	}
	B(const B& a) : id(a.id) { 
		cout << "copy ctor" << endl; 
	}
	void print() {
		cout << id << endl;
	}
};
B funcb(B a) {
	return a;
}

int main() {
	A a(123);              // ctor
	A b = 123;             // ctor
	A c = func(a);         // copy ctor; copy ctor
	A d = func(123);       // ctor; copy ctor

	cout << "-------------------------" << endl;

	B ba(123);             // ctor
	// B bb = 123;         // compile error
	B bc = funcb(ba);      // copy ctor; copy ctor
	// B bd = funcb(123);  // compile error

	return 0;
}
编译器可以绕过拷贝构造函数

编译器可以(但不是必需)跳过拷贝/移动构造函数直接创建对象。这也就是上面的程序为什么很多都没有实际打印出 copy ctor

例如

string s = "abc"; // 允许编译器改写为下面的代码
string s("abc");

虽然可以跳过,但是在这个程序点上,拷贝/移动构造函数必需是存在且可访问的。

13.1.2 拷贝赋值运算符

重载运算符 overloaded operator

重载运算符本质上是函数。

合成拷贝赋值运算符 synthesized copy-assignment operator 类似于拷贝构造函数他会将右侧每个非 static 成员赋予左边对象的对应成员。对于数组成员,会逐个赋值数组元素。

13.1.3 析构函数

构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和析构部分。

析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。

析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁。

什么时候会调用析构函数

变量离开作用域时被销毁。

当对象被销毁时其成员被销毁。

容器被销毁时,元素被销毁。

动态分配的对象,指向它的指针应用 delete 运算符时被销毁。

对临时对象,当创建它的完整表达式结束时被销毁。

13.1.5 使用 =default

我们可以通过将拷贝控制成员定义为 =default 来显式地要求编译器生成合成的版本。合成的函数将隐式的声明为内联的。如果不希望成员是内联函数,应该只对成员的类外定义使用 =default。

13.1.6 阻止拷贝

我们可以通过将这些可能被自动合成的函数定义为删除的函数 deleted function 来阻止拷贝。

struct NoCopy {
    NoCopy(const NoCopy&) = delete;
    NoCopy &operator=(const NoCopy&) = delete;
    ...
};

不同于 default 只能用于编译器能自动合成的函数,delete 关键字可以用于任何函数。有时需要利用 delete 引导编译器匹配正确的函数。

析构函数不能是删除的成员。删除析构函数本身并不会带来编译错误,但会导致不能创建对象(可以通过 new 动态分配,但不能 delete)。

合成的拷贝控制成员可能是删除的

如果类某个成员的析构函数是删除的或不可访问的(如 private 的),则合成析构函数被定义为删除的。

如果类的某个成员的拷贝构造函数/析构函数是删除的或不可访问的,则合成拷贝构造函数被定义为删除的。

如果类的某个成员的拷贝赋值运算符时删除的,或类有一个 const 的或引用成员,则类的拷贝赋值运算符被定义为删除的。

如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它某有类内初始化器,或是类有一个 const 成员,他没有类内初始化器且其类型未显式定义某人构造函数,则该类的默认构造函数被定义为删除的。

private 拷贝控制

新标准之前是靠把想删除的函数定义为 private 来实现拷贝控制的。

但注意这种方式,友元函数和成员函数依旧可以拷贝对象。

这里我们可以声明但不定义它们。这样我们如果尝试调用拷贝构造函数会导致一个链接时错误。

13.2 拷贝控制和资源管理

注意有的时候我们要让类的拷贝行为看起来像值,有的时候像指针,有的时候不能进行。

编写赋值运算符时应该检查:

一个对象赋值给自己时可以正常工作。

大多数赋值运算符组合了析构函数和拷贝构造函数的工作。

如果要一个类的拷贝行为看起来像指针,则我们需要实现一套引用计数机制,这时我们需要一个引用计数器。

class LikePointer {
  private:
    std::size_t *ref_cnt;
  public:
    LikePointer() : ref_cnt(new std::size_t(1)) {}
    LikePointer(const LikePointer &rhs) : ref_cnt(rhs.ref_cnt) {
        ++*ref_cnt;
    }
    LikePointer& operator=(const LikePointer &rhs) {
        ++*rhs.ref_cnt;
        if (!--*ref_cnt) {
            delete ref_cnt;
        }
        ref_cnt = rhs.ref_cnt;
        return *this;
    }
    ~LikePointer() {
        if (!--*ref_cnt) {
            delete ref_cnt;
            ...
        }
    }
};

13.3 交换操作

swap 的定义并不是必须的,但是对于分配了资源的类这可能会极大的提升效率。

class HasPtr {
    std::string *ps;
    int i;
    friend void swap(HasPtr& lhs, HasPtr& rhs);
};
inline void HasPtr::swap(HasPtr& lhs, HasPtr& rhs) {
    using std::swap;
    swap(lhs.ps, rhs.ps);
    swap(lhs.i, rhs.i);
}
class Foo {
    HasPtr h;
    friend void swap(Foo &lhs, Foo &rhs);
};
void Foo::swap(Foo &lhs, Foo &rhs) {
    using std::swap;
    swap(lhs.h, rhs.h);
}

注意这里不要直接调用 std::swap。因为有些类型可能自己定义了 swap。

注意这里的 using 声明并不会隐藏掉 HasPtr 版本的 swap 的声明。

在赋值运算符中使用 swap

这里有一个计数称为拷贝并交换 copy and swap

HasPtr& HasPtr::operator=(HasPtr rhs) {
    swap(*this, rhs);
    return *this;
}

注意这里参数不是引用。

注意这里自动处理了自身赋值的情况。同样这个操作也是异常安全的,抛出异常只会发生在拷贝构造时,而这也是在修改左侧对象之前。

13.6 对象移动

13.6.1 右值引用

右值引用 rvalue reference:必须绑定到右值(一个将要销毁的对象)。

我们可以自由地将一个右值引用的资源“移动”到另一个对象中。

常规引用对应于右值称为左值引用 lvalue reference

int i = 42;
int &r = i;
// int &&rr = i; // 不能绑定到左值
// int &r2 = i * 42; // 不能绑定到右值
const int &r3 = i * 42; // 可以绑定到右值
int &&rr2 = i * 42;

左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。

变量是左值。不能将一个右值引用绑定到一个右值引用类型的变量上。

标准库 move 函数

我们可以通过 move 函数将一个左值转化为右值使用。但使用 move 后意味着我们除了对其赋值或销毁外,不能再使用它。

使用 move 的代码应当使用 std::move。

13.6.2 移动构造函数和移动赋值运算符

移动构造函数需要保证移动后源对象处在“销毁无害”的状态。

移动操作、标准库容器和异常

一般而言因为移动操作不需要分配资源因此不会抛出异常。

此时我们可以声明 noexcept 来通知标准库。

class A {
    A(A&&) noexcept;
};
A::A(A&& rhs) noexcept {
    ...
}

例如 vector 如果在 push_back 时发生异常则 vector 自身不会变化。

但是对于移动构造函数,如果移动发生了一半,部分元素移动而不是全部元素移动后发生异常,旧空间的源元素已经被改变而新空间中未够早的元素尚不存在,则 vector 不能保证自身不变的要求。

所以如果 vector 不知道元素移动构造函数不会抛出异常时,会使用拷贝构造函数。

移动赋值运算符执行与析构函数和移动构造函数相同的工作。注意因为考虑 move 的情况,还是需要考虑自赋值的可能性。

合成的移动操作

如果一个类定义了自己的拷贝构造函数、拷贝赋值函数或析构函数,编译器就不会合成移动构造函数和移动赋值运算符了。

只有当一个类没有定义任何拷贝控制成员,且每个非 static 数据成员都可以移动时,才会合成移动构造函数或移动赋值运算符。

移动操作不会隐式定义为删除的函数。如果显式要求编译器生成 =default 的移动操作,且编译器不能移动所有成员,则编译器会奖移动操作定义为删除的函数。???

拷贝并交换赋值运算符和移动操作
class HasPtr {
  public:
    HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) {
        p.ps = 0;
    }
    HasPtr& operator=(HasPtr rhs) {
        swap(*this, rhs);
        return *this;
    }
};

注意这个 operator= 可以同时实现拷贝赋值和移动赋值。

移动迭代器

我们可以用 make_move_iterator 函数将一个普通迭代器转换为一个移动迭代器。

13.6.3 右值引用和成员函数

允许成员函数使用和拷贝、移动构造函数相同的参数模式。

void push_back(const X&);
void push_back(X&&);

有时我们限定一些成员函数只能在左值上使用,这样我们可以使用引用限定符 reference qualifier

class Foo {
  public:
    Foo& operator=(const Foo&) &;
};
Foo &Foo::operator=(const Foo &rhs) & { ... }

注意限定符可以是 & 也可以是 && 分别表示限定为左值还是右值。应用限定符类似于 const 需要在声明和定义都包含。

重载和引用函数

可以同时使用 const 和引用限定,注意顺序

Foo func() const &;

我们可以重载不同引用限定符的版本。

class Foo {
  public:
    Foo sorted() &&;
    Foo sorted() const &;
  private:
    vector<int> data;
};
Foo Foo::sorted() && {
    sort(data.begin(), data.end());
    return *this;
}
Foo Foo::sorted() const & {
    Foo ret(*this);
    sort(ret.data.begin(), ret.data.end());
    return ret;
}

注意对于多个相同参数列表的重载函数,要么都使用引用限定符,要么都不使用。

Prev: 《C++ Primer》 拾遗 第 12 章 动态内存
Next: [模板][数据结构] 下标池 IdPool