一、定义
类的拷贝控制定义了类在拷贝、赋值、移动、销毁时的行为。
T m, n;
T p = m; // 拷贝:用m的数据拷贝构造一个p
p = 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>
# 二、拷贝构造函数
如果一个构造函数,第一个参数是自身类型的引用,且其他形参要么为空,要么都有默认实参,则它是一个拷贝构造函数。
```cpp
class 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; // 返回一个此对象的引用
}
```cpp
class 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); // 先生成副本:拷贝底层 string
delete 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){ // 如果引用计数变为 0
delete 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::swap
swap(lhs.ps, rhs.ps); // 交换指针,内置类型调用std:swap
swap(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(); //更新已有Folder
contents = 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) { //对每个Folder
f->remMsg(m); //从Folder中删除旧Message
f->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的Folder
f->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指针set
swap(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
显式定义为编译器合成的版本。
```cpp
class 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移动到容器中。
}
十、拷贝控制法则
五个拷贝控制成员,什么时候,定义哪几个?
需要析构函数,则几乎也需要拷贝构造、拷贝赋值。
需要拷贝构造,就需要拷贝赋值,反之亦然。