一、定义
类的拷贝控制定义了类在拷贝、赋值、移动、销毁时的行为。
T m, n;T p = m; // 拷贝:用m的数据拷贝构造一个pp = n; // 赋值:n的数据赋值给p,覆盖p原有的数据p = std::move(m); // 移动:将m的内部的数据移交给p,覆盖掉p原来的数据// move返回m的右值引用。p.~T(); // 显式执行析构,清除p所在内存的数据,之后不能在访问p的内容。delete p; // 触发析构
拷贝控制通过5个特殊的成员函数完成:
- 拷贝构造函数
- 拷贝赋值运算符(重载)
- 移动构造函数
- 移动赋值运算符(重载)
- 析构函数 ```cpp
class Fuck { public: Fuck(const Fuck&); // 拷贝构造,既然是拷贝就无需修改原对象,所以用const Fuck(Fuck&&); // 移动构造,Fuck&&右值引用,参考链接:https://www.yuque.com/tvvhealth/cs/fpep24
Fuck& operator=(const Fuck&); // 拷贝赋值运算符(重载)Fuck& operator=(Fuck&&); // 移动赋值运算符(重载)~Fuck();
}
如果没有显式定义这些函数,编译器会默认合成。<br />这个5个函数应该看成一个整体,要么全部显式定义,要么就全部默认合成。为何构造、赋值有拷贝和移动之分?因为拷贝和构造满足了不同的需求场景。- 拷贝- **需要复制对象的数据**(成员)的。- 比如string、vector的复制,这是一种很常见的需求。- 复制也就意味着额外的开销,会牺牲性能- 移动- **对象的数据不希望被复制**(共享)。- 比如iostream,显然不可以同时打开一个流多次,显然不能复制流,但是可以移动。- 比如unique_ptr智能指针对象之间的赋值,不要成员(动态分配对象的指针),但是可以移动。- **拷贝的性能问题**- 比如一个vector在push_back时可能会触发内存在分配,这时将元素从旧空间拷贝到新空间的性能就远远比不上只移动元素。<a name="x8cKm"></a># 二、拷贝构造函数如果一个构造函数,第一个参数是自身类型的引用,且其他形参要么为空,要么都有默认实参,则它是一个拷贝构造函数。```cppclass Foo {public:Foo();Foo(const Foo& s, ...);// 拷贝构造函数,要求如下:// 1、是构造函数// 2、第一个参数必须是Foo& s,一般都是const Foo &// 如果不是引用,则会无限循环触发拷贝构造。// 3、后续参数要么没有,要么全部都必须有默认实参。// 4、不能explicit};Foo you; // 默认构造。Foo fuck = you; // 直接初始化,函数匹配规则,调用的是拷贝构造。Foo fuck(you); // 拷贝构造:用you的数据拷贝构造另一个对象fuck。
如果没有显式定义拷贝构造,编译器会合成一个。内部实现就是调用各成员的拷贝构造,内置成员直接拷贝,类类型就调用拷贝构造,数组类型就逐个拷贝。
// 假设T只有a、b、c三个成员。T::T(const T &instance) // 与的合成拷贝构造函数等价:a(instance.a),b(instance.b),c(instance.c){}
触发条件
拷贝初始化时触发拷贝构造,如何判断哪个是拷贝初始化?
string dots(10, '.'); // 直接初始化string s(dots); // 直接初始化,注意不是拷贝初始化,虽然可能匹配的是拷贝构造string s = dots; // 拷贝初始化string s = "asdfadf"; // 拷贝初始化string s = string(10, "."); // 拷贝初始化// 直接初始化和拷贝初始化的差异// 直接初始化,是要求编译器使用普通的函数匹配来选择最匹配的构造函数。
拷贝初始化发生在以下情形:
// 触发情形1:用=运算符初始化。T t1 = 1;T t2 = t2;// 触发情形2:参数值传递void fuck(T t){};T shit;fuck(shit);// 触发情形3:返回值是非引用类型T fuck(){T t;return t;}fuck();// 触发情形4:花括号初始化数组、聚合列中的成员。T t1, t2, t3;T t[3] = { t1, t2, t3 };// 特殊情况:std::vector<T> vec;vec.push_back(t1); // 用push方法都是拷贝初始化vec.insert(t1); // 用insert方法都是拷贝初始化vec.emplace_back(...); // 用emplace方法都是直接初始化
编译器可以绕过拷贝构造函数,直接初始化。
string fuck = "fuck"; // 拷贝初始化string fuck("asdf"); // 略过了拷贝构造函数。
三、拷贝赋值运算符
T t1, t2;t1 = t2; // 触发拷贝赋值运算符
class T {public:// 重载赋值运算符T& operator=(const T& t){ // 形参必须是左值引用,一般加const修饰,因为是拷贝// 不会修改对象t的内容。......;return *this; // 返回左值引用}}
如果没有显式定义拷贝赋值运算符,则编译器会合成一个,递归调用子成员的拷贝赋值。
- 内置类型,直接拷贝
- 类类型,调用各自的拷贝赋值运算
- 数组类型,逐个拷贝赋值。 ```cpp
// 假设a、b、c是T的全部成员
T& T::operator=(const T& right){ // 等价于ClassA的合成拷贝赋值运算符
a = right.a;
b = right.b;
c = right.c;
return *this; // 返回一个此对象的引用
}
```cppclass T{using std::string;public:T& operator=(const T& right){//正确的逻辑设计应该是:// 0、查重判断,自己=自己,直接return// 1、生成副本:用临时对象拷贝一份=右侧对象的内容。// 2、销毁自身:销毁=左侧运算对象的数据(成员)。// 3、占有副本:把第1步的数据变成自己的数据。////安全的体现:// 1、异常安全,就是出现异常,也不会有什么大问题(内存错误)// 2、不怕自己=自己。// 0、查重判断,自己 = 自己。if(this == &right) return *this;//1、生成副本string* pNewData = new string(*right.data);//2、销毁自身delete data;//3、占有副本data = pNewData;// 返回左值引用return *this;}private:string *data;};
实践案例
像值的类
通过定义拷贝构造,使一个类的行为看起来像一个值或者指针。关键点就在于对于底层数据(成员)的是拷贝还是共享。
T p = q; // 像值的类:p从q拷贝了全部成员,p和q的数据(成员)完全独立。// 像指针的类:p和q的数据(成员)是同一份。
class T{using std::string;public:T(const string &s = string()):ps(new string(s)),i(0){}T(const T &p):ps(new string(*p.ps)) //拷贝构造时,复制了数据(成员)。,i(p.i){}T &operator=( const T &rhs){auto newp = new string(*rhs.ps); // 先生成副本:拷贝底层 stringdelete ps; // 在销毁旧数据ps = newp; // 最后执行拷贝。i = rhs.i;return *this; //返回=左侧对象。}~T(){ delete ps; }private:string *ps;int i;}
像指针的类(shared_ptr)
类似shared_ptr引用计数原理。
class T {friend void swap(T&, Tt&);public:T(const string &s = string()) // 构造函数:ps(new string(s)) // 初始全部成员,use(new size_t(1)){ // 初始引用计数为1// 必须要动态对象// 因为一个对象,不管多少管理者,应该使用同一个引用计数// 这样才能做到引用计数同步。}T(const HasPtr &p) // 拷贝构造函数:ps(p.ps) // 拷贝所有数据成员,use(p.use){++*use; // 拷贝构造一次,引用计数+1}// 普通的重载拷贝赋值运算符设计。T& operator= (const HasPtr& q){//p = q触发此函数。类似于shared_ptr去理解。++*rhs.use; // q的引用计数+1:q的数据多了一个管理者。// 赋值前,处理一下p当前管理的老数据,该删删if (--*use == 0) { // p的引用计数-1,delete ps; // p是唯一指向当前对象的了,就把对象删了。delete use;}ps = rhs.ps; //将数据从 rhs 拷贝到本对象use = rhs.use; //共享同一个引用计数return *this; //返回本对象}// 相比较于上面,更优化的拷贝赋值(拷贝并交换技术)T& operator= (T rhs){ // 生成=右侧对象的副本// 交换左侧运算对象和副本的内容swap(*this, rhs) ; // 数据,包括引用计数。return *this;// 局部变量rhs被销毁,调用析构函数,处理引用计数和数据// 此时rhs的内容就是p的老内容// 拷贝并交换计数的思路// 1、生成=右侧对象的副本,借助形参的拷贝构造来自动完成,妙哉// 2、与副本交换数据。// 3、销毁副本,借助局部变量销毁时自动调用析构函数来处理,妙哉。}~T(){if(--*use == 0){ // 如果引用计数变为 0delete ps; // 释放string内存delete use; // 释放计数器内存}}private:string *ps;size_t *use ; // 用来记录有多少个对象共享*ps的成员};// 对于分配了资源的类,定义swap是一种非常重要的优化手段。inline void swap(T &lhs , T &rhs){using std::swap; // 这条代码非常巧妙,让下面的swap会自动匹配swap// 优先考虑使用成员类型特定的swap,如果没有则使用std::swapswap(lhs.ps, rhs.ps); // 交换指针,内置类型调用std:swapswap(lhs.use, rhs.use); // 交换int成员// ************************************************************// 有漏洞的代码设计//如果lhs的成员自定义了swap函数,依然还是使用标准库的,必然会出现问题。std::swap(lhs.ps, rhs.ps);std::swap(lhs.use, rhs.use);}
Message and Folder
Message是消息,Folder是消息目录。每个Folder可以有多条Message,每个Message只有一个副本。
class Message {friend class Folder;public :// folders被隐式初始化为空集合explicit Message(const std::string &str = ""):contents(str) { }//拷贝控制成员,用来管理指向本Message的指针//拷贝构造函数Message(const Message& m){add_to_Folders(m); //将本消息添加到指向m的Folder中}Message& operator=(const Message& rhs); //拷贝赋值运算符{//通过先删除指针再插入它们来处理自赋值情况remove_from_Folders(); //更新已有Foldercontents = rhs.contents; //从rhs拷贝消息内容folders = rhs.folders; //从rhs拷贝Folder指针add_to_Folders(rhs); //将本Message添加到那些 Folder 中return *this;}Message::Message(Message &&m) //因为可能bad_alloc异常,所以不是noexcept:contents(std::move(m.contents)) {move_Folders (&m); //移动folders 并更新 Folder 指针}Message& Message::operator=(Message &&rhs) {if(this != &rhs) { //直接检查自赋值情况remove_from_Folders();contents = std::move(rhs.contents); //移动赋值运算符move_Folders(&rhs); //重置Folders指向本Message}return*this;}~Message(); //析构函数{remove from Folders();}void save(Folder& folders) //从Folder中添加message{folders.insert(&f); //将给定Folder添加到我们的Folder列表中f.addMsg(this); //将本Message添加到f的Message集合中}void remove(Folder& f) //从Folder中删除message{folders.erase(&f); //将给定Folder从我们的Folder列表中删除f.remMsg(this); //将本Message从f的Message集合中删除}//从本Message移动Folder指针void Message::move_Folders(Message *m) {folders = std::move(m->folders);//使用set的移动赋值运算符for(auto f : folders) { //对每个Folderf->remMsg(m); //从Folder中删除旧Messagef->addMsg(this); //将本Message添加到Folder中}m->folders.clear(); //确保销毁m是无害的}private:std::string contents; //实际消息文本std::set<Folder*> folders; //包含本Message的Folder//拷贝构造、拷贝赋值、析构中使用到的工具函数void add_to_Folders(const Message&) //将本Message添加到指向参数的Folder中{for(auto f : m.folders) //对每个包含m的Folderf->addMsg(this); //向该Folder添加一个指向本Message的指针}void remove_from_Folders(); //从folders中的每个Folder中删除本Message{for(auto f : folders) //对folders中每个指针f->remMsg(this); //从该Folder中删除本Message}};void swap(Message &lhs, Message &rhs)using std::swap; //在本例中严格来说并不需要,但这是一个好习惯//将每个消息的指针从它(原来)所在Folder中删除for(auto f : lhs.folders)f->remMsg(&lhs);for(auto f : rhs.folders)f->remMsg(&rhs);//交换contents和Folder指针setswap(lhs.folders, rhs.folders); //使用swap(set&,set&)swap(lhs.contents, rhs.contents); //swap(string&,string&)//将每个Message的指针添加到它的(新)Folder中for(auto f : lhs.folders)f->addMsg(&lhs);for(auto f : rhs.folders}f->addMsg(&rhs);}
Folder
class Folder{public:Folder();~Folder();Folder& operator=(const Folder&);Folder(const Folder&);void addMsg(Message *m3) // 上面需要使用this作为参数,所以这里需要用指针{messages.insert(m3);}void remMsg(Message *m4){messages.erase(m4);}private:set<Message*> messages; // 保存Message的指针};
四、析构函数
析构函数执行与构造函数相反的操作。
构造:construct
析构:deconstruct
构造函数:初始化对象的非static成员,和一些其他工作。
析构函数:释放对象资源,然后销毁对象的非static成员。
class Foo{public:// 析构函数,固定这一种形式,每个类只有唯一一个。~Foo(){......}~Foo() = default; // 合成的析构函数,函数体为空。};
析构函数具体做了什么事情?
class Foo{public:~Foo(){// 析构函数完成的工作:// 第一步、执行这里的析构函数体。// 第二步、成员析构,按成员的类内声明顺序的逆序,也即是初始化顺序。//// 析构函数体内并不是销毁成员对象// 在执行完析构体之后会触发成员各自的析构。}};
在对象被销毁时调用。当指向对象的引用或指针被销毁时,不会触发对象的析构,这很好理解,对象又不一定会被销毁嘛。
当类未定义自己的析构函数时,编译器会合成一个。
触发条件
无论何时,一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(标准库容器、数组)被销毁时,其元素被销毁。
- delete时,被销毁。
- 创建临时对象的表达式结束时,临时对象被销毁。 ```cpp
{
T p = new T; // 动态对象
auto p2 = make_shared
<a name="ErrS9"></a># 五、=default显式定义为编译器合成的版本。```cppclass T {public:T() = default; // 合成的默认构造函数T(const T&) = default; // 合成的拷贝构造函数T& operator=(const T&) = default; // 错误拷贝赋值可没有default版本~T() = default; // 合成的析构函数}
六、=delete
=delete修饰的函数是删除的函数,不能以任何方式调用它,其实就是告诉编译器不要定义这些函数。
struct T {T() = default; // 使用合成的默认构造函数T(const T&) = delete; // 阻止拷贝T& operator=(const T&) = delete; // 阻止赋值~T() = default; // Foo类型的对象只要被定义了,就无法被销毁。}
必须接在函数第一次声明的尾部,表示这个函数是删除的函数。
所有函数都可以=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{
// 移动构造函数// 第一个参数必须是对象类型的右值引用,后续参数要么为空,要么全部有默认实参。// 没有const,因为要改变rr的内部数据。T(T &&rr, ...) noexcept{ // 一般是noexcept声明,因为是移动数据// 一般不会出现问题,noexcept可以提升性能。// noexcept学习链接:// 函数体执行完毕之后,确保rr对象是可析构的。}// 右值引用学习链接:https://www.yuque.com/tvvhealth/cs/fpep24// noexcept学习链接:https://www.yuque.com/tvvhealth/cs/ngou4g#R9iYX
}
什么时候编译器自动合成移动构造函数?<br />只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非 static 数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。什么时候合成的移动构造是删除的(delete),和拷贝构造类似的原则。成员不能移动,就=delete。<br />内置类型默认可以移动。```cpp// 编译器会为 X 和 hasX 合成移动操作(没有定义任何拷贝控制成员)struct X {int i; // 内置类型可以移动std::string s; // string 定义了自己的移动操作};struct hasX {X mem; // X 有合成的移动操作};X x , x2 = std::move(x); // 使用合成的移动构造函数hasX hx, hx2 = std::move(hx); // 使用合成的移动构造函数
八、移动赋值运算符
就是重载赋值运算符。
// 移动赋值运算符(重载)//// 返回值:类型的左值引用// 形参为:类型的右值引用// 一般有noexcept声明,移动现有数据一般不会出现异常。//T &T::operator=(T &&rhs) noexcept {// 移动赋值运算符重载逻辑模板// 第一步,检测自赋值if(this == &rhs) return *this; // 自己给自己赋值// 第二步,清除自身数据freeSelf();// 第三步,接管rhs的数据a = rhs.a;b = rhs.b;c = rhs.c;// 第四步,将rhs置于可析构状态rhs.a = nullptr;rhs.b = nullptr;rhs.c = nullptr;return *this ;}
九、拷贝、移动版本
成员函数也可以从拷贝版本、控制版本中受益。
template<class X>class T {public:void push_back(const X& x); // 拷贝版本,拷贝一个x插入T中void push_back(X&& x); // 移动版本,将X移动到容器中。}
十、拷贝控制法则
五个拷贝控制成员,什么时候,定义哪几个?
需要析构函数,则几乎也需要拷贝构造、拷贝赋值。
需要拷贝构造,就需要拷贝赋值,反之亦然。
