为什么关注拷贝
将某种信息/数据从一个位置传递到另一个位置是程序中的常用操作,这一过程可以被视为(广义的)拷贝。在编写程序中,通常会涉及到两种形式的拷贝:
- 深拷贝:真正对原始的数据进行复制,得到一个原始数据的副本;
- 浅拷贝:复制了变量的字节内容,但对于指针变量而言,在新的位置上仍然是通过访问数据的地址来间接获取数据,原始数据只有一份。
可以看出,深浅拷贝的区分主要是针对指针而言。显然,后一种方法没有实现真正意义的复制,在进行浅拷贝后,对于后面数据的修改会影响到之前的原始数据,带来难以发现的影响。
在编写程序中,根据实际需要,这两种方式都会被用到,合理的使用拷贝可以有效利用资源。但在实际场景中,由于不清楚这两种操作的界限,在许多情况下会造成复杂的问题,C++设计了一些机制来约束自定义类中的拷贝行为。
拷贝基础
在正式开始介绍拷贝之前,需要关注几个与之相关的概念:
什么时候发生拷贝
- 使用=操作定义变量
- 将一个实参传递给非引用的形参
- 从函数返回非引用的变量
可以看出,第一种情况是较为引起注意的拷贝,但是后两种情况拷贝也会发生,并且很容易被忽略。
拷贝与引用
引用可以认为是原有数据的一种别名,它并不占用新的数据空间(此处是指不会占用与被引用数据一样大的空间)。引用可以通过一种类似常量指针的方式实现的,在引用被声明的时候就需要进行初始化。
通常不认为传递/返回引用的过程中进行了拷贝,即引用是我们所说的pass-by-reference,而拷贝指的是pass-by-value。
拷贝与移动
在许多时候,一个对象被拷贝之后很快就被销毁或不再使用了,那么这个拷贝操作显得有些浪费资源。在这种情况下,可以使用移动操作避免拷贝。为了定义移动操作,需要在程序中区分右值和左值。
左值与右值
左右值的区别主要在于所表示对象的生命周期不同,左值是一个具有持久状态的对象(变量),右值是一个字面量或者临时对象。该右值对象与其他对象毫无联系,可以被销毁或者进行变动,不会带来其他负面影响。因此,移动操作主要是针对右值进行的。
我们所说的普通引用可以被视为左值引用,在引入右值的概念后,相对应的也有右值引用,右值引用只能绑定到字面量或者返回右值的表达式上,而不能绑定到左值上。(但是反过来是可以的)
哪些表达式返回左值
- 返回左值引用的函数
- 赋值操作
- 下标操作
- 解引用
- 前置递增递减
哪些表达式返回右值
- 返回非引用类型的函数
- 算术运算
- 后置递增递减
移动的目的,就是将一个左值转化为右值引用从而避免拷贝。在这种情况下,需要符合之前的承诺,即完全切断源对象与其他对象的联系,当作真正的右值来处理。此后源对象只能销毁或者赋新值,这一操作应当由程序设计者保证。
自定义类中的拷贝控制
在一个自定义类中,有以下几个特殊的成员函数与拷贝行为有关:
- 构造函数
- 析构函数
- 拷贝构造函数
- 拷贝赋值函数
- 移动构造函数
- 移动赋值函数
在默认情况下,C++会为自定义类合成这几个特殊的成员函数,而根据类功能和设计不同,以上一些默认方法合成的函数可能会带来意外危害。
三五法则是一个C++中对以上成员函数设计策略的建议。首先这里要说明,Rule of Three/Five并不是法则的内容有三条或者五条,而是为了说明这个法则针对的对象是三个函数(以上2-4)还是五个函数(2-6),后者是C++11后开始支持的。三五法则的实质就是为了对拷贝行为进行控制,避免潜在的问题。
法则具体内容可以参考:https://smartkeyerror.oss-cn-shenzhen.aliyuncs.com/Phyduck/c%2B%2B/copy-control/4.%20%E4%B8%89%E4%BA%94%E6%B3%95%E5%88%99.pdf
总结
简要梳理一下要做的事,实际上就是区分几种操作,以及认识三种操作应当在什么场景下进行。
传递信息的操作分别为:
- 使用引用(pass-by-reference)
- 使用拷贝(pass-by-value)
- 使用浅拷贝(默认拷贝构造函数)
- 但是在使用浅拷贝的时候需要注意某些隐含的情况,比如指针拷贝后带来的两次内存释放问题
- 如果不能确定,最好是将拷贝构造定义为默认删除,或者使用智能指针
- 自己定义一个合理的拷贝构造函数最好
- 对于不会在其他处使用的变量,可以使用移动操作配合移动构造/移动赋值函数来实现拷贝功能
- 需要确保这个被移动的变量之后处于待销毁的状态,后续程序不再修改它,否则会带来错误
- 使用深拷贝
- 使用浅拷贝(默认拷贝构造函数)
不同情况需要不同的场景支持,这个并不是绝对的,需要个人结合情况分析。