一、定义

类的拷贝控制定义了类在拷贝、赋值、移动、销毁时的行为。

  1. T m, n;
  2. T p = m; // 拷贝:用m的数据拷贝构造一个p
  3. p = n; // 赋值:n的数据赋值给p,覆盖p原有的数据
  4. p = std::move(m); // 移动:将m的内部的数据移交给p,覆盖掉p原来的数据
  5. // move返回m的右值引用。
  6. p.~T(); // 显式执行析构,清除p所在内存的数据,之后不能在访问p的内容。
  7. delete p; // 触发析构

拷贝控制通过5个特殊的成员函数完成:

  • 拷贝构造函数
  • 拷贝赋值运算符(重载)
  • 移动构造函数
  • 移动赋值运算符(重载)
  • 析构函数 ```cpp

class Fuck { public: Fuck(const Fuck&); // 拷贝构造,既然是拷贝就无需修改原对象,所以用const Fuck(Fuck&&); // 移动构造,Fuck&&右值引用,参考链接:https://www.yuque.com/tvvhealth/cs/fpep24

  1. Fuck& operator=(const Fuck&); // 拷贝赋值运算符(重载)
  2. Fuck& operator=(Fuck&&); // 移动赋值运算符(重载)
  3. ~Fuck();

}

  1. 如果没有显式定义这些函数,编译器会默认合成。<br />这个5个函数应该看成一个整体,要么全部显式定义,要么就全部默认合成。
  2. 为何构造、赋值有拷贝和移动之分?因为拷贝和构造满足了不同的需求场景。
  3. - 拷贝
  4. - **需要复制对象的数据**(成员)的。
  5. - 比如stringvector的复制,这是一种很常见的需求。
  6. - 复制也就意味着额外的开销,会牺牲性能
  7. - 移动
  8. - **对象的数据不希望被复制**(共享)。
  9. - 比如iostream,显然不可以同时打开一个流多次,显然不能复制流,但是可以移动。
  10. - 比如unique_ptr智能指针对象之间的赋值,不要成员(动态分配对象的指针),但是可以移动。
  11. - **拷贝的性能问题**
  12. - 比如一个vectorpush_back时可能会触发内存在分配,这时将元素从旧空间拷贝到新空间的性能就远远比不上只移动元素。
  13. <a name="x8cKm"></a>
  14. # 二、拷贝构造函数
  15. 如果一个构造函数,第一个参数是自身类型的引用,且其他形参要么为空,要么都有默认实参,则它是一个拷贝构造函数。
  16. ```cpp
  17. class Foo {
  18. public:
  19. Foo();
  20. Foo(const Foo& s, ...);
  21. // 拷贝构造函数,要求如下:
  22. // 1、是构造函数
  23. // 2、第一个参数必须是Foo& s,一般都是const Foo &
  24. // 如果不是引用,则会无限循环触发拷贝构造。
  25. // 3、后续参数要么没有,要么全部都必须有默认实参。
  26. // 4、不能explicit
  27. };
  28. Foo you; // 默认构造。
  29. Foo fuck = you; // 直接初始化,函数匹配规则,调用的是拷贝构造。
  30. Foo fuck(you); // 拷贝构造:用you的数据拷贝构造另一个对象fuck。

如果没有显式定义拷贝构造,编译器会合成一个。内部实现就是调用各成员的拷贝构造,内置成员直接拷贝,类类型就调用拷贝构造,数组类型就逐个拷贝。

  1. // 假设T只有a、b、c三个成员。
  2. T::T(const T &instance) // 与的合成拷贝构造函数等价
  3. :a(instance.a)
  4. ,b(instance.b)
  5. ,c(instance.c){
  6. }

触发条件

拷贝初始化时触发拷贝构造,如何判断哪个是拷贝初始化?

  1. string dots(10, '.'); // 直接初始化
  2. string s(dots); // 直接初始化,注意不是拷贝初始化,虽然可能匹配的是拷贝构造
  3. string s = dots; // 拷贝初始化
  4. string s = "asdfadf"; // 拷贝初始化
  5. string s = string(10, "."); // 拷贝初始化
  6. // 直接初始化和拷贝初始化的差异
  7. // 直接初始化,是要求编译器使用普通的函数匹配来选择最匹配的构造函数。

拷贝初始化发生在以下情形:

  1. // 触发情形1:用=运算符初始化。
  2. T t1 = 1;
  3. T t2 = t2;
  4. // 触发情形2:参数值传递
  5. void fuck(T t){};
  6. T shit;
  7. fuck(shit);
  8. // 触发情形3:返回值是非引用类型
  9. T fuck(){
  10. T t;
  11. return t;
  12. }
  13. fuck();
  14. // 触发情形4:花括号初始化数组、聚合列中的成员。
  15. T t1, t2, t3;
  16. T t[3] = { t1, t2, t3 };
  17. // 特殊情况:
  18. std::vector<T> vec;
  19. vec.push_back(t1); // 用push方法都是拷贝初始化
  20. vec.insert(t1); // 用insert方法都是拷贝初始化
  21. vec.emplace_back(...); // 用emplace方法都是直接初始化

编译器可以绕过拷贝构造函数,直接初始化。

  1. string fuck = "fuck"; // 拷贝初始化
  2. string fuck("asdf"); // 略过了拷贝构造函数。

三、拷贝赋值运算符

  1. T t1, t2;
  2. t1 = t2; // 触发拷贝赋值运算符
  1. class T {
  2. public:
  3. // 重载赋值运算符
  4. T& operator=(const T& t){ // 形参必须是左值引用,一般加const修饰,因为是拷贝
  5. // 不会修改对象t的内容。
  6. ......;
  7. return *this; // 返回左值引用
  8. }
  9. }

如果没有显式定义拷贝赋值运算符,则编译器会合成一个,递归调用子成员的拷贝赋值。

  • 内置类型,直接拷贝
  • 类类型,调用各自的拷贝赋值运算
  • 数组类型,逐个拷贝赋值。 ```cpp

// 假设a、b、c是T的全部成员 T& T::operator=(const T& right){ // 等价于ClassA的合成拷贝赋值运算符 a = right.a; b = right.b; c = right.c;
return *this; // 返回一个此对象的引用 }

  1. ```cpp
  2. class T{
  3. using std::string;
  4. public:
  5. T& operator=(const T& right){
  6. //正确的逻辑设计应该是:
  7. // 0、查重判断,自己=自己,直接return
  8. // 1、生成副本:用临时对象拷贝一份=右侧对象的内容。
  9. // 2、销毁自身:销毁=左侧运算对象的数据(成员)。
  10. // 3、占有副本:把第1步的数据变成自己的数据。
  11. //
  12. //安全的体现:
  13. // 1、异常安全,就是出现异常,也不会有什么大问题(内存错误)
  14. // 2、不怕自己=自己。
  15. // 0、查重判断,自己 = 自己。
  16. if(this == &right) return *this;
  17. //1、生成副本
  18. string* pNewData = new string(*right.data);
  19. //2、销毁自身
  20. delete data;
  21. //3、占有副本
  22. data = pNewData;
  23. // 返回左值引用
  24. return *this;
  25. }
  26. private:
  27. string *data;
  28. };

实践案例

像值的类

通过定义拷贝构造,使一个类的行为看起来像一个值或者指针。关键点就在于对于底层数据(成员)的是拷贝还是共享。

  1. T p = q; // 像值的类:p从q拷贝了全部成员,p和q的数据(成员)完全独立。
  2. // 像指针的类:p和q的数据(成员)是同一份。
  1. class T{
  2. using std::string;
  3. public:
  4. T(const string &s = string())
  5. :ps(new string(s))
  6. ,i(0){}
  7. T(const T &p)
  8. :ps(new string(*p.ps)) //拷贝构造时,复制了数据(成员)。
  9. ,i(p.i){}
  10. T &operator=( const T &rhs){
  11. auto newp = new string(*rhs.ps); // 先生成副本:拷贝底层 string
  12. delete ps; // 在销毁旧数据
  13. ps = newp; // 最后执行拷贝。
  14. i = rhs.i;
  15. return *this; //返回=左侧对象。
  16. }
  17. ~T(){ delete ps; }
  18. private:
  19. string *ps;
  20. int i;
  21. }

像指针的类(shared_ptr)

类似shared_ptr引用计数原理。

  1. class T {
  2. friend void swap(T&, Tt&);
  3. public:
  4. T(const string &s = string()) // 构造函数
  5. :ps(new string(s)) // 初始全部成员
  6. ,use(new size_t(1)){ // 初始引用计数为1
  7. // 必须要动态对象
  8. // 因为一个对象,不管多少管理者,应该使用同一个引用计数
  9. // 这样才能做到引用计数同步。
  10. }
  11. T(const HasPtr &p) // 拷贝构造函数
  12. :ps(p.ps) // 拷贝所有数据成员
  13. ,use(p.use){
  14. ++*use; // 拷贝构造一次,引用计数+1
  15. }
  16. // 普通的重载拷贝赋值运算符设计。
  17. T& operator= (const HasPtr& q){
  18. //p = q触发此函数。类似于shared_ptr去理解。
  19. ++*rhs.use; // q的引用计数+1:q的数据多了一个管理者。
  20. // 赋值前,处理一下p当前管理的老数据,该删删
  21. if (--*use == 0) { // p的引用计数-1,
  22. delete ps; // p是唯一指向当前对象的了,就把对象删了。
  23. delete use;
  24. }
  25. ps = rhs.ps; //将数据从 rhs 拷贝到本对象
  26. use = rhs.use; //共享同一个引用计数
  27. return *this; //返回本对象
  28. }
  29. // 相比较于上面,更优化的拷贝赋值(拷贝并交换技术)
  30. T& operator= (T rhs){ // 生成=右侧对象的副本
  31. // 交换左侧运算对象和副本的内容
  32. swap(*this, rhs) ; // 数据,包括引用计数。
  33. return *this;
  34. // 局部变量rhs被销毁,调用析构函数,处理引用计数和数据
  35. // 此时rhs的内容就是p的老内容
  36. // 拷贝并交换计数的思路
  37. // 1、生成=右侧对象的副本,借助形参的拷贝构造来自动完成,妙哉
  38. // 2、与副本交换数据。
  39. // 3、销毁副本,借助局部变量销毁时自动调用析构函数来处理,妙哉。
  40. }
  41. ~T(){
  42. if(--*use == 0){ // 如果引用计数变为 0
  43. delete ps; // 释放string内存
  44. delete use; // 释放计数器内存
  45. }
  46. }
  47. private:
  48. string *ps;
  49. size_t *use ; // 用来记录有多少个对象共享*ps的成员
  50. };
  51. // 对于分配了资源的类,定义swap是一种非常重要的优化手段。
  52. inline void swap(T &lhs , T &rhs){
  53. using std::swap; // 这条代码非常巧妙,让下面的swap会自动匹配swap
  54. // 优先考虑使用成员类型特定的swap,如果没有则使用std::swap
  55. swap(lhs.ps, rhs.ps); // 交换指针,内置类型调用std:swap
  56. swap(lhs.use, rhs.use); // 交换int成员
  57. // ************************************************************
  58. // 有漏洞的代码设计
  59. //如果lhs的成员自定义了swap函数,依然还是使用标准库的,必然会出现问题。
  60. std::swap(lhs.ps, rhs.ps);
  61. std::swap(lhs.use, rhs.use);
  62. }

Message and Folder

Message是消息,Folder是消息目录。每个Folder可以有多条Message,每个Message只有一个副本。

  1. class Message {
  2. friend class Folder;
  3. public :
  4. // folders被隐式初始化为空集合
  5. explicit Message(const std::string &str = "")
  6. :contents(str) { }
  7. //拷贝控制成员,用来管理指向本Message的指针
  8. //拷贝构造函数
  9. Message(const Message& m){
  10. add_to_Folders(m); //将本消息添加到指向m的Folder中
  11. }
  12. Message& operator=(const Message& rhs); //拷贝赋值运算符
  13. {
  14. //通过先删除指针再插入它们来处理自赋值情况
  15. remove_from_Folders(); //更新已有Folder
  16. contents = rhs.contents; //从rhs拷贝消息内容
  17. folders = rhs.folders; //从rhs拷贝Folder指针
  18. add_to_Folders(rhs); //将本Message添加到那些 Folder 中
  19. return *this;
  20. }
  21. Message::Message(Message &&m) //因为可能bad_alloc异常,所以不是noexcept
  22. :contents(std::move(m.contents)) {
  23. move_Folders (&m); //移动folders 并更新 Folder 指针
  24. }
  25. Message& Message::operator=(Message &&rhs) {
  26. if(this != &rhs) { //直接检查自赋值情况
  27. remove_from_Folders();
  28. contents = std::move(rhs.contents); //移动赋值运算符
  29. move_Folders(&rhs); //重置Folders指向本Message
  30. }
  31. return*this;
  32. }
  33. ~Message(); //析构函数
  34. {
  35. remove from Folders();
  36. }
  37. void save(Folder& folders) //从Folder中添加message
  38. {
  39. folders.insert(&f); //将给定Folder添加到我们的Folder列表中
  40. f.addMsg(this); //将本Message添加到f的Message集合中
  41. }
  42. void remove(Folder& f) //从Folder中删除message
  43. {
  44. folders.erase(&f); //将给定Folder从我们的Folder列表中删除
  45. f.remMsg(this); //将本Message从f的Message集合中删除
  46. }
  47. //从本Message移动Folder指针
  48. void Message::move_Folders(Message *m) {
  49. folders = std::move(m->folders);//使用set的移动赋值运算符
  50. for(auto f : folders) { //对每个Folder
  51. f->remMsg(m); //从Folder中删除旧Message
  52. f->addMsg(this); //将本Message添加到Folder中
  53. }
  54. m->folders.clear(); //确保销毁m是无害的
  55. }
  56. private:
  57. std::string contents; //实际消息文本
  58. std::set<Folder*> folders; //包含本Message的Folder
  59. //拷贝构造、拷贝赋值、析构中使用到的工具函数
  60. void add_to_Folders(const Message&) //将本Message添加到指向参数的Folder中
  61. {
  62. for(auto f : m.folders) //对每个包含m的Folder
  63. f->addMsg(this); //向该Folder添加一个指向本Message的指针
  64. }
  65. void remove_from_Folders(); //从folders中的每个Folder中删除本Message
  66. {
  67. for(auto f : folders) //对folders中每个指针
  68. f->remMsg(this); //从该Folder中删除本Message
  69. }
  70. };
  71. void swap(Message &lhs, Message &rhs)
  72. using std::swap; //在本例中严格来说并不需要,但这是一个好习惯
  73. //将每个消息的指针从它(原来)所在Folder中删除
  74. for(auto f : lhs.folders)
  75. f->remMsg(&lhs);
  76. for(auto f : rhs.folders)
  77. f->remMsg(&rhs);
  78. //交换contents和Folder指针set
  79. swap(lhs.folders, rhs.folders); //使用swap(set&,set&)
  80. swap(lhs.contents, rhs.contents); //swap(string&,string&)
  81. //将每个Message的指针添加到它的(新)Folder中
  82. for(auto f : lhs.folders)
  83. f->addMsg(&lhs);
  84. for(auto f : rhs.folders}
  85. f->addMsg(&rhs);
  86. }

Folder

  1. class Folder
  2. {
  3. public:
  4. Folder();
  5. ~Folder();
  6. Folder& operator=(const Folder&);
  7. Folder(const Folder&);
  8. void addMsg(Message *m3) // 上面需要使用this作为参数,所以这里需要用指针
  9. {
  10. messages.insert(m3);
  11. }
  12. void remMsg(Message *m4)
  13. {
  14. messages.erase(m4);
  15. }
  16. private:
  17. set<Message*> messages; // 保存Message的指针
  18. };

四、析构函数

析构函数执行与构造函数相反的操作。

构造:construct
析构:deconstruct

构造函数:初始化对象的非static成员,和一些其他工作。
析构函数:释放对象资源,然后销毁对象的非static成员。

  1. class Foo{
  2. public:
  3. // 析构函数,固定这一种形式,每个类只有唯一一个。
  4. ~Foo(){
  5. ......
  6. }
  7. ~Foo() = default; // 合成的析构函数,函数体为空。
  8. };

析构函数具体做了什么事情?

  1. class Foo{
  2. public:
  3. ~Foo(){
  4. // 析构函数完成的工作:
  5. // 第一步、执行这里的析构函数体。
  6. // 第二步、成员析构,按成员的类内声明顺序的逆序,也即是初始化顺序。
  7. //
  8. // 析构函数体内并不是销毁成员对象
  9. // 在执行完析构体之后会触发成员各自的析构。
  10. }
  11. };

在对象被销毁时调用。当指向对象的引用或指针被销毁时,不会触发对象的析构,这很好理解,对象又不一定会被销毁嘛。
当类未定义自己的析构函数时,编译器会合成一个。

触发条件

无论何时,一个对象被销毁,就会自动调用其析构函数:

  • 变量在离开其作用域时被销毁。
  • 当一个对象被销毁时,其成员被销毁。
  • 容器(标准库容器、数组)被销毁时,其元素被销毁。
  • delete时,被销毁。
  • 创建临时对象的表达式结束时,临时对象被销毁。 ```cpp

{ T p = new T; // 动态对象 auto p2 = make_shared(); // p2管理这一个动态对象 T item(p); // 直接初始化,匹配到拷贝构造函数 vector vec; vec.push_back(*p2); // 拷贝p2指向的对象到vec中 delete p; // 对p指向的对象执行析构函数 } // 退出局部作用域,对item、p2、vec执行析构 // p2引用计数为0,释放对象 // 销毁vec的元素。

  1. <a name="ErrS9"></a>
  2. # 五、=default
  3. 显式定义为编译器合成的版本。
  4. ```cpp
  5. class T {
  6. public:
  7. T() = default; // 合成的默认构造函数
  8. T(const T&) = default; // 合成的拷贝构造函数
  9. T& operator=(const T&) = default; // 错误拷贝赋值可没有default版本
  10. ~T() = default; // 合成的析构函数
  11. }

六、=delete

=delete修饰的函数是删除的函数,不能以任何方式调用它,其实就是告诉编译器不要定义这些函数。

  1. struct T {
  2. T() = default; // 使用合成的默认构造函数
  3. T(const T&) = delete; // 阻止拷贝
  4. T& operator=(const T&) = delete; // 阻止赋值
  5. ~T() = default; // Foo类型的对象只要被定义了,就无法被销毁。
  6. }

必须接在函数第一次声明的尾部,表示这个函数是删除的函数。
所有函数都可以=delete,主要用途还是用来禁止拷贝控制成员,比如iostream、unique_ptr,不能共享数据,不能拷贝数据,但可以移动。

如果析构函数=delete,则无法销毁此类型对象。因此析构函数不能是删除的。

什么时候,编译器会为类合成=delete的成员函数。
一句话:无法被调用,就给它=delete。

  • 合成=delete的析构
    • 某个成员的析构无法被调用:=delete的、private的。
  • 合成=delete的拷贝构造
    • 成员的拷贝构造无法调用:=delete的、private的。
    • 成员的析构无法调用:=delete的、private的。
    • 定义了移动构造函数:不是=delete的且不是private的。
  • 合成=delete的拷贝赋值
    • 成员的拷贝赋值无法调用:=delete的、private的
    • 有const成员,或者引用类型成员。
    • 定义了移动赋值运算符:不是=delete的且不是private的。
  • 合成=delete的默认构造
    • 成员的析构是=delete的、private的
    • 成员有引用类型,且没有类内初始值。
    • 成员有const类型,且没有类内初始值,且没有显式定义默认构造函数。

总结就是,如果有成员不能默认构造、拷贝、赋值、销毁,则类的对应成员函数是删除的。

七、移动构造函数

移动而非拷贝对象是C++11新标准的一个重要特性。很多情况下需要用移动对象代替拷贝对象:

  • vector.push_back时,空间不够发生重组时,将旧内存的对象移动(而不是拷贝)到新内存区域。
  • iostream流对象不可以拷贝,但是可以移动。
  • unique_ptr不可以拷贝,但可以移动。 ```cpp

class T{

  1. // 移动构造函数
  2. // 第一个参数必须是对象类型的右值引用,后续参数要么为空,要么全部有默认实参。
  3. // 没有const,因为要改变rr的内部数据。
  4. T(T &&rr, ...) noexcept{ // 一般是noexcept声明,因为是移动数据
  5. // 一般不会出现问题,noexcept可以提升性能。
  6. // noexcept学习链接:
  7. // 函数体执行完毕之后,确保rr对象是可析构的。
  8. }
  9. // 右值引用学习链接:https://www.yuque.com/tvvhealth/cs/fpep24
  10. // noexcept学习链接:https://www.yuque.com/tvvhealth/cs/ngou4g#R9iYX

}

  1. 什么时候编译器自动合成移动构造函数?<br />只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非 static 数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。
  2. 什么时候合成的移动构造是删除的(delete),和拷贝构造类似的原则。成员不能移动,就=delete。<br />内置类型默认可以移动。
  3. ```cpp
  4. // 编译器会为 X 和 hasX 合成移动操作(没有定义任何拷贝控制成员)
  5. struct X {
  6. int i; // 内置类型可以移动
  7. std::string s; // string 定义了自己的移动操作
  8. };
  9. struct hasX {
  10. X mem; // X 有合成的移动操作
  11. };
  12. X x , x2 = std::move(x); // 使用合成的移动构造函数
  13. hasX hx, hx2 = std::move(hx); // 使用合成的移动构造函数

八、移动赋值运算符

就是重载赋值运算符。

  1. // 移动赋值运算符(重载)
  2. //
  3. // 返回值:类型的左值引用
  4. // 形参为:类型的右值引用
  5. // 一般有noexcept声明,移动现有数据一般不会出现异常。
  6. //
  7. T &T::operator=(T &&rhs) noexcept {
  8. // 移动赋值运算符重载逻辑模板
  9. // 第一步,检测自赋值
  10. if(this == &rhs) return *this; // 自己给自己赋值
  11. // 第二步,清除自身数据
  12. freeSelf();
  13. // 第三步,接管rhs的数据
  14. a = rhs.a;
  15. b = rhs.b;
  16. c = rhs.c;
  17. // 第四步,将rhs置于可析构状态
  18. rhs.a = nullptr;
  19. rhs.b = nullptr;
  20. rhs.c = nullptr;
  21. return *this ;
  22. }

九、拷贝、移动版本

成员函数也可以从拷贝版本、控制版本中受益。

  1. template<class X>
  2. class T {
  3. public:
  4. void push_back(const X& x); // 拷贝版本,拷贝一个x插入T中
  5. void push_back(X&& x); // 移动版本,将X移动到容器中。
  6. }

十、拷贝控制法则

五个拷贝控制成员,什么时候,定义哪几个?
需要析构函数,则几乎也需要拷贝构造、拷贝赋值。
需要拷贝构造,就需要拷贝赋值,反之亦然。