拷贝控制(下)

C++11 中引入了一个新特性: 可以移动而非拷贝对象的能力

在某些发生对象拷贝情况下,对象拷贝后立即被销毁,此时,移动而非拷贝对象会大幅度提高性能

而为了支持移动操作,新标准引入了一种新的引用类型——右值引用,所谓的右值引用就是必须要绑定到右值的引用,通过 && 来获得右值引用,并且 只能绑定到一个将要销毁的对象上。所以理解移动,首先需要先知道左值和右值的概念,

左值和右值

在C语言中有个比较方便记忆的是:左值可以位于赋值语句的左侧,右值则不能,但是C++中这个二者的区别更复杂一些

  • 左值是存储单元内的值,即是有实际存储地址的;
  • 右值则不是存储单元内的值,比如它可能是寄存器内的值也可能是立即数。

而所谓右值引用就是必须绑定到右值的引用,通过 && 获得,右值引用只能绑定到一个将要销毁的对象上,因此可以自由的移动其资源

左值引用,也就是常规引用,不能绑定到要转换的表达式、字面常量或返回右值的表达式。而右值引用恰好想法,可以绑定到这类表达式,但不能绑定到一个左值上

  • 返回左值的表达式包括返回左值引用的函数及赋值、下标、解引用和前置递增/递减运算符
  • 返回右值的包括返回非引用类型的函数及算数、关系、位、和后置递增/递减运算符

举个例子看一下

int i = 42;         
int &r = i;              // 正确: r 引用 i
int &&r2 = i;            // 错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 42;        // 错误:i * 42 是个右值表达式,不能左值引用
const int &r3 = i * 42;  // 正确:我们可以讲一个 const 的引用绑定到一个右值上
int &&rr2 = i * 42;      // 正确: 将 rr2 绑定到乘法结果上

更简单点说就是 左值持久;右值短暂, 左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象

虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式的将一个左值转移为对应的右值引用类型

int &&rr1 = 42;              // 正确: 字面常量是右值
int &&rr2 = rr1;             // 错误: rr1 是左值
int &&rr3 = std::move(rr1);  // 正确

调用 move 就意味着承诺:除了对 rr1 赋值或销毁它外,我们将不再使用它

移动构造函数

#include <iostream>
using namespace std;
class demo{
public:
    demo():num(new int(0)){
        cout<<"construct!"<<endl;
    }
    // 添加拷贝构造函数
    demo(const demo &d):num(new int(*d.num)) {
        cout<<"copy construct!"<<endl;
    }
    // 添加移动构造函数
    demo(demo &&d) noexcept :num(d.num)  {
        d.num = NULL;
        cout<<"move construct!"<<endl;
    }
    ~demo(){
        cout<<"class destruct!"<<endl;
    }
private:
    int *num;
};

int main(){
    demo a;
    demo b(a);
    demo c(std::move(a));
    return 0;
}

运行结果

construct!
copy construct!
move construct!
class destruct!
class destruct!
class destruct!

移动构造函数不分配任何新的内存,它主要就是接管传入的右值引用,在接管内存之后,它将给定对象中的指针都置为 nullptr, 这样就完成了从给定对象的移动操作

小 tip : 一定要记得将给定对象中·的指针置为 nullptr, 避免销毁给定对象时,也会释放掉我们刚刚移动的内存

移动赋值运算符

在上面例子的基础上在添加一个移动赋值运算符

demo& operator=(demo &&d) {
    if (this != &d) {
        // 防止内存泄漏,需要释放旧的指针数据
        delete num;
        this->num = d.num;
        d.num = NULL;
    }
    cout<<"move opreate!"<<endl;
    return *this;
}

如果 this 指针与 d 的地址相同,我们不需要做任何事情,否则,我们释放左侧运算对象( this )使用的内存,并接管给定对象( d )的内存。最后再将给定对象的指针置为 nullptr

合成的移动操作

与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符,但是条件不太一样

如果我们不声明自己的拷贝构造函数或拷贝赋值运算符,编译器会为我们合成这些操作,拷贝操作要么定义为逐成员拷贝,要么被定义为对象赋值,要么被定义为删除的函数

合成移动操作条件比较苛刻,只有当一个类没有定义任何版本的拷贝控制成员,且类的每个非 static 数据成员都可以移动时,编译器才会合成移动构造函数和移动赋值运算符,编译器可以移动内置类型的成员,如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员。

定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则,这些成员默认地被定义为删除。

如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作,也就是说,如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来 “移动” 的,拷贝赋值运算符和移动赋值运算符的情况类似。

三五法则更新

所有五个拷贝控制成员应该看做一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。