C++拷贝控制:右值引用、移动构造函数、移动赋值运算符

对象移动

​ 新标准一个最主要的特性是可以移动而非拷贝对象的能力。在某些情况下,对象拷贝后就立即被销毁了(如 vector 容量不足时)。在这些情况下,移动而非拷贝对象会大幅度提升性能。

​ 使用移动而不是拷贝的另一个原因源于 IO 类或 unique_ptr 这样的类。这些类不能被拷贝,但却可以移动。而且,旧标准中,容器中所保存的类型必须是可拷贝的,但是新标准可以用容器保存不可拷贝但可以被移动的类型。

右值引用

​ 为了支持移动操作,新标准引入了一种新的引用类型——右值引用,即绑定到右值的引用。我们通过 && 而不是 & 来获取右值引用右值引用有一个很重要的性质——只能绑定到一个将要销毁的对象

​ 类似任何引用,右值引用也只是一个对象的别名。我们知道,左值引用不能绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有完全相反的绑定特性,我们可以将一个右值引用绑定到这类表达式,但是不能直接绑定到一个左值上

int i = 42;
int &r = i;				// 左值引用,r 引用 i
int &&rr = i;			// 错误,右值引用不能绑定到左值上
int &r2 = i * 42;		// 错误,i * 42 是一个右值
const int &r3 = i * 42;	// 正确,我们可以将一个 const 的引用绑定到右值上
int &&r2 = i * 42;		// 正确,将 rr2 绑定到一个右值

​ 返回左值引用的函数,连同赋值、下标、解引用和前置递增递减运算符,都是返回左值的表达式。我们可以将一个左值引用绑定到这类表达式的结果上。

​ 返回非引用类型的函数,连同算术、关系、位以及后置递增递减运算符,都生成右值。我们可以将一个 const 的左值引用或者一个右值引用绑定到这类表达式上。

左值持久,右值短暂

​ 左值有持久状态,而右值要么是字面常量,要么是表达式求值过程中创建的临时对象。(即左值右值的区分在于表达式代表的是持久对象还是临时对象)。

​ 由于右值只能绑定到临时对象,我们得知:

  • 所引用的对象将要被销毁
  • 该对象没有其他用户

这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。

右值引用指向将要被销毁的对象。因此,我们可以从绑定到右值引用的对象“窃取”状态。

变量是左值

​ 变量是左值,这很好理解,毕竟变量是持久对象。但是需要注意的一点:我们不能将右值引用绑定到一个变量上,即使这个变量是右值引用类型也不行!!!很重要

int &&rr1 = 42;		// ok,字面常量是右值
int &&rr2 = rr1;	// 错误,变量都是左值
标准库 move 函数

​ 我们可以显式地将一个左值转换为对应的右值引用类型。我们可以调用 move 函数来获得绑定到左值上的右值引用。即:

int &&rr3 = std::move(rr1);		// 即使 rr1 是右值引用类型,但此变量仍是左值。如果我们想
								// 调用移动构造函数,就需要传入右值,即必须调用 std::move

但是需要注意:调用 move 后,我们只能对 rr1 赋值或者销毁,我们不能够在使用它。而且,对 move 我们不提供 using 声明。我们直接调用 std::move,而不是 move。

移动构造函数和移动赋值运算符

​ 我们知道,string 提供移动和拷贝操作,如果我们自己的类也同时支持这两种操作,那么也能从中受益。所以,我们可以为其定义移动构造函数和移动赋值运算符来达到这个目的。这两个成员类似对于的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源。

移动构造函数的第一个参数是该类类型的一个引用,但是右值引用。与拷贝构造函数一样,要求任何额外的参数都必须有默认实参

​ 除了完成资源移动,移动构造函数含必须确保移后源对象处于 <销毁它是无害的> 的状态

我们有这样的 StrVec 类:

// 这里 StrVec 只提供了一部分成员、、
class StrVec {
public:
private:
    static std::allocator<std::string> alloc;
    std::string *elements;      // 指向分配的内存中的首元素
    std::string *first_free;    // 指向最后一个实际元素之后的位置
    std::string *cap;           // 指向分配的内存末尾之后的位置
};
StrVec::~StrVec() {
    if(elements) {      // 不能传递给 deallocate 一个空指针,如果 elements 为 0,函数什么也不用做
        while (first_free != elements)
            alloc.destroy(--first_free);
        alloc.deallocate(elements, cap - elements);
    }
}

我们为其定义移动构造函数:

StrVec::StrVec(StrVec &&s) noexcept:		// 移动操作不应抛出任何异常
	elements(s.elements), first_free(s.first_free), cap(s.cap) {
        // 令 s 进入这样的状态——对其运行析构函数是安全的
        s.elements = s.first_free = s.cap = nullptr;
    }

分析一下移动构造函数的执行: 移动构造函数不分配任何新内存;它接管给定的 StrVec 中的内存。接管内存之后,它将给定对象中的指针都置为 nullptr,完成对象的移动。此后,移后源对象会执行析构函数。如果我们忘记了改变 s.first_free,则移后源对象就会释放掉我们刚刚移动的内存。

移动操作、标准库容器和异常

​ 移动操作是“窃取”资源,它通常不分配任何资源。因此,移动构造函数通常不会抛出异常。所以我们应该通过 noexcept 通知标准库不抛出异常,以避免处理可能抛出异常而做的额外工作。

​ noexcept 是我们承诺一个函数不抛出异常的一种方法。我们在一个函数的参数列表后指定 noexcept,或是在构造函数的参数列表与初始化列表开始的冒号之间指定 noexcept。

注意: 我们必须在类的声明和定义时都知道 noexcept。

不抛出异常的移动构造函数和移动赋值运算符必须标记为 noexcept


​ 这里将解释为什么需要 noexcept

​ 我们首先需要知道两点,虽然移动操作通常不抛出异常,但是抛出异常是允许的。其次,标准库容器能对异常发生时自身的行为提供保障。例如,vector 保证,如果我们调用 push_back 时发生异常,vector 自身不会发生改变。

​ 我们知道 vector 内存的分配方式:在 push_back 时,如果容量不足,vector 会请求新空间,然后将元素从旧空间移动到新内存中。

​ 我们假设移动时使用移动构造函数,假如在移动了部分而不是所有元素之后抛出了一个异常,那么就会出现问题。而我们利用拷贝构造函数移动元素时,即使出现上述情况,vector 可以释放新分配但未构造成功的内存并返回,vector 原有的元素依然存在。

​ 为了避免这种潜在的问题,我们必须显式的告诉 vector 我们的移动构造函数是安全的(即 noexcept),vector 才会在重新分配内存的过程中使用移动构造函数而非拷贝构造函数。否则,vector 会使用拷贝构造函数而非移动构造函数。

同理,移动赋值运算符也是需要 noexcept。

移动赋值运算符

​ 移动赋值运算符仍然需要标记为 noexcept,同样要能够处理自赋值的情况,以及完成销毁左侧运算对象的工作。

StrVec& StrVec::operator=(StrVec &&rhs) const {
    if(this != &rhs) {	// 直接检测自赋值
        free();		// 释放左侧运算对象
        elements = rhs.elements;
        first_free = rhs.first_free;
        cap = rhs.cap;
        rhs.elements = rhs.first_free = rhs.cap = nullptr;
    }
    return *this;
}
移后源对象必须可析构(即移后源对象处于销毁它是无害的状态)

​ 从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此,当我们编写一个移动操作后,必须确保移后源对象进入一个可析构的状态。

​ 除此之外,移动操作还必须保证对象仍然有效。如,我们忘记了将移后源对象的指针成员置为 nullptr,则销毁移后源对象就会释放掉我们刚刚移动的内存。

​ 另一方面,移动操作对移后源对象中留下的值没有任何要求,我们的程序不应该依赖于移后源对象中的数据。同时,我们可能希望移后源对象是空的,但实际上并没有保证是这样。

合成的移动操作

​ 编译器也可能会合成移动构造函数和移动赋值运算符。如果一个类定义了任何自己版本的拷贝控制成员,编译器就不会为它合成移动构造函数和移动赋值运算符。

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

// 编译器会为 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);	// 使用合成的移动构造函数

​ 移动操作永远不会隐式定义为删除的函数。但是,如果我们显式地要求编译器生成 =default 移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。还有一重要例外,什么时候将合成的移动操作定义为删除的函数遵循的原则(这里不赘述,见 p476)。

移动操作和合成的拷贝控制成员也有相互作用关系:定义了移动成员的类也必须定义自己的拷贝成员,否则这些成员默认地被定义为删除的。

移动右值,拷贝左值……

​ 如果一个类既有移动构造函数,又有拷贝构造函数,则编译器会使用普通函数匹配规则来确定使用哪个构造函数。赋值运算类似。如,StrVec 类的拷贝构造函数接受 const StrVec&,移动构造函数函数接受 StrVec&&:

StrVec v1,v2;
v1 = v2;		// v2 是左值,使用拷贝赋值
StrVec getVec(istream &);	// getVec 返回一个右值
v2 = getVec(cin);		// 使用移动赋值
……但如果没有移动构造函数,右值也被拷贝

​ 如果一个类有一个拷贝构造函数但未定义移动构造函数,那么编译器不会合成移动构造函数。此时,函数匹配规则保证该类型的对象会被拷贝,即使我们试图通过调用 std::move 来移动它们也是如此:

class Foo {
public:
    Foo() = default;
    Foo(const Foo&);
};
Foo x;
Foo y(x);		// 拷贝构造函数
Foo z(std::move(x));	// 拷贝构造函数,Foo&& 可以转换为 const Foo&

​ 而且,一般情况下,使用拷贝构造函数代替移动构造函数几乎是安全的。

拷贝并交换赋值运算符与移动操作

​ 假设我们有以下类:

class HasPtr {
public:
    HasPtr(const std::string &s = std::string()) :
            ps(new std::string(s)), i(0) {}
    HasPtr(const HasPtr &sour):
            ps(new std::string(*sour.ps)), i(sour.i) { }
    HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = nullptr; }
    HasPtr &operator=(HasPtr rhs)
        { swap(*this,rhs); return *this; }
    ~HasPtr() { delete(ps); }
private:
    std::string *ps;
    int i;
};

​ 首先,移动构造函数和之前几乎一样,这个不用多说。

让我们观察赋值运算符:我们可以发现,赋值运算符的参数是进行值传递,也就说是当传入实参时,此函数会 进行拷贝初始化。所以依赖于实参的类型(左值或右值),会选择拷贝初始化或者移动初始化。即:

// 假设 hp 和 hp2 都是 HasPtr 对象
hp = hp2;		// hp2 是一个左值;hp2 通过拷贝构造函数拷贝
hp = std::move(hp2);	// 移动构造函数移动 hp2

​ 所以我们可以发现,这里的赋值运算符即相当于拷贝赋值运算符,也相当于移动赋值运算符。

建议:更新三/五发展法则

​ 所有五个拷贝控制成员应该看成一个整体:一般来说,如果一个类定义了其中一个,它应该定义所有五个操作。因为有析构函数就一般会需要拷贝构造函数与拷贝赋值运算符,而多数情况下,为了避免拷贝开销,就会需求移动构造函数和移动赋值运算符。

建议:不要随意使用移动操作

​ 由于一个移后源对象具有不确定状态,所以保证在调用 move 后,移后源对象没有其他用户这是非常重要的。建议在确保需要移动操作且移动操作是安全的情况下使用 std::move

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值