复制语义和移动语义
C++11 之前只有复制(拷贝)语义,定义了析构函数,复制构造函数,赋值函数就称为有了拷贝控制。对对象的非指针,非引用的行为都会使用复制语义。在拥有内存资源对象中,都会定义这三个函数以实现深拷贝。
复制语义不会销毁用来复制的对象,同时还得到一个和其一模一样的对象。但是在某些场景中,我们希望对象拷贝后立即被销毁,这种情况我们就是移动语义,即只是移动而非拷贝对象以大幅度提升性能。
我们的重点还是集中在移动语义上,谈及复制语义是为了更清楚地知道移动语义出现的意义。移动语义就是从给定对象“窃取”资源而不是拷贝资源,源对象不再拥有资源,资源的所有权已经归属于新创建的对象。那么实现移动语义有哪些要求呢?
- 移动语义可以将一个对象中的资源移走,而不是赋值,所以它们并不分配内存
- 移动后的源对象会被销毁(形参是右值引用),所以内部资源会被置为无效(比如指针会被置为 nullptr )
- 它们都需要声明为noexcept(不能抛异常)
如果需要移动语义,建议自己定义,不要编译器合成。因为编译器会优先考虑使用复制语义,而不是移动语义,除非对象明确的定义了移动语义。
代码实现复制语义和移动语义
复制语义:拷贝构造函数,拷贝赋值函数
移动语义:移动构造函数,移动赋值函数
1 |
|
这些代码实现并不难,只有弄清楚实际的语义就好,说明如下:
- 构造函数是没有返回值的,不管是哪种构造函数(普通构造函数,拷贝构造函数和移动构造函数)
- 赋值函数 就是对赋值运算符 = 的重载
- 赋值操作要考虑不能自我赋值的情况,所以要判断是否为自我赋值,false 的情况下再进行赋值操作,true 的情况下直接返回 当前对象即可
- 移动语义(移动构造函数和移动赋值函数)下需要移动的对象必须是右值引用
- 类中的成员变量需要分配内存的务必分配内存之后再进行操作
- 复制语义只是把传递进来的对象中的成员变量拷贝到当前对象中,并不会销毁对象,代码中仅涉及拷贝操作。移动语义需要把传递进来的对象中的成员变量拷贝到当前对象中,同时需要销毁传递进来的对象,代码中不仅涉及拷贝操作,还涉及清理内存操作(传递进来的对象)
- 复制语义每次都为当前对象分配内存,然后把传递进来的对象拷贝到当前对象。移动语义是把一个对象的资源移交给另一个对象,无需为当前对象分配内存,所以移动语义发生在两个已经存在的对象之间。
测试代码:
1 |
|
输出结果:
1 |
|
move函数
std::move 的功能仅是将左值转换成右值引用。它本身不会产生移动操作,只是产生一个右值引用,真正的移动操作是在移动构造函数和移动赋值函数中完成的。总的来说,如果你没有实现移动语义,std::move 产生一个右值引用是无法触发移动语义的,从而去调用复制语义了。
移动语义的合理使用
似乎 移动语义带来的性能提升让我们觉得可以无处不在,实际情况也非如此:
- 编译器为自定义类型自动生成移动语义的是有要求的,必须没有声明复制操作,移动操作以及析构函数
- 即使在标准库中都已经支持移动操作,但是也可能不会像希望的那样带来那么大利好。这样取决于具体的实现
- 比如list,它的实现通常是会在堆上分配内存,将容器元素放在这个堆内存上,内部只是会维护指向堆内存的指针。那么对list的移动,只算交换指针,那么效率自然会有提升
- 比如array,它是C++ 11引入的新的容器类型,就是数组。它的内存就是对象内部的一个缓存区(比如是在栈上分配的一段顺序的空间),所以对它的移动操作,还是要将元素进行复制
补充:编译器自动生成移动操作的要求
如果我们在类中没有定义拷贝操作,那么编译器会自动为我们生成默认的拷贝操作,但是对移动操作,编译器是有条件满足才会生成的:
- 类中没有自定义拷贝控制成员(拷贝构造函数、赋值操作、析构函数)
- 它的所有数据成员都能够移动构造或移动赋值
如果需要移动语义,我们还是自己定义,免得去计较它需要的条件。
补充:为什么用右值引用实现移动语义,而不是左值引用?
在有拥有内存资源的对象中,通过复制语义(深拷贝)来转移内存,将源对象赋值给目标对象,源对象中资源很可能是不需要再保留的,这时直接将源对象中的资源转移给目标对象(浅拷贝,只移动指针),就更贴切。但是使用左值引用来实现有两个限制:
- 为了满足所有的表示移动的场景,它必须是一个构造函数,并且形参需为const &(为了能引用临时对象这样的右值),为了与其它构造函数作区分,形参个数需不同(显然是没有这样的语法的)
- 对 const 引用的形参,在函数中并不能改变的它
引入右值引用就是来实现移动语义的,解决实现上的限制。