C++ 右值引用 | 左值、右值、move、移动语义、引用限定符


C++11为什么引入右值?

C++11引入了一个扩展内存的方法——移动而非拷贝,移动较之拷贝有两个优点:

  1. 效率更高: 在此之前,当数据结构申请的内存用尽时,一般是申请一块更大的内存,然后将旧内存中存储的元素拷贝到新内存中。但很多情况下,为了方便拷贝操作而建立的临时对象在拷贝完成后就被销毁了,因此不如直接将旧内存中的元素移动到新内存中,即省空间(临时对象也是要占内存的),还省时间(不用建立临时对象了)。
  2. IOunique_ptr 这样的类都包含不可被共享的资源(如指针或IO缓冲),因此,这些类不支持拷贝,仅支持移动。

PS:STLshared_ptr 既支持移动也支持拷贝。

而为了支持移动操作,就诞生了一种新的引用类型——右值引用(rvalue reference)

为了与左值引用进行划分,使用 & 时则代表是左值引用,而使用 && 则代表右值引用。

右值引用有一个重要的特性——只能绑定到一个将要销毁的对象。


区分左值引用、右值引用

左值

生成左值: 返回引用的函数、赋值、取下标、解引用、前置递增/递减运算符。

我们可以将一个 左值引用 绑定到这类表达式的结果上。

右值

生成右值: 返回非引用类型的函数、算术、关系、位、后置递增/递减运算符。

我们可以将一个 const的左值引用 或者一个 右值引用 绑定到这类表达式上。

举一些例子:

int i = 42;
int &r = i; // 正确:左值引用绑定变量
int &&rr = i; // 错误:不能将右值引用绑定到左值上
int &r2 = i * 42; // 错误:i*42是右值,不能将左值引用绑定到右值上
const int &r3 = i * 42; // 正确:可以将const左值引用绑定到右值上
int &&rr2 = i * 42; // 正确:右值引用可以绑定到算术结果上

详细来讲:

  1. 普通类型的变量,因为有名字,可以取地址,都认为是左值。
  2. const修饰的常量,不可修改,只读类型的,理论应该按照右值对待,但因为其可以取地址,C++11认为其是左值。(const类型常量初始化时,编译器不给其开辟空间,当对该常量取地址时,编译器才为其开辟空间。)
  3. 如果表达式的运行结果是一个临时变量或者对象,认为是右值。
  4. 如果表达式运行结果或单个变量是一个引用,认为是左值。

总的来讲,即为:左值持久、右值短暂,左值有持久的状态,而右值要么是字面常量、要么是在表达式求值过程中创建的临时对象。

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

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

这两个特性意味着:可以自由地接管右值引用绑定的资源,而不必担心发生错误。

有趣的是,右值引用本身是一个变量,因此它是一个左值,也就是说,不能将右值引用绑定到一个右值引用类型的变量上:

int &&rr1 = 42; // 正确:字面常量是右值
int &&rr2 = rr1; // 错误:表达式rr1是左值

move

按照语法来说,右值引用应该只能引用右值,但我们可以通过move函数显式地将一个左值转换为对应的右值引用类型

#include<utility> //move的头文件
int &&rr1 = 42; // 右值引用
int &&rr2 = std::move(rr1); // rr1是左值,绑定到右值引rr2上

调用move就意味着:可以销毁一个移后源对象(rr1),也可以赋予它新值,但不能使用一个移后源对象(rr1)的值。

与大多数标准库名字的使用不同,对 move 我们不提供 using声明。换言之,我们直接调用 std::move 而不是 move。因为 STL 还有另一个 move,那个的作用就是将一个范围中的元素搬移到另一个位置。


移动语义

移动构造函数

  • 类似拷贝构造函数,移动构造函数的第一个参数是该类类型的引用,任何额外的参数都必须有默认实参。
  • 不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用

除了完成资源移动,移动构造函数还必须确保移后源对象是可销毁的。 一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。

作为一个例子,我们为 IntVec类 定义移动构造函数,实现从一个 IntVec 到另一个 IntVec 的元素移动而非拷贝:
在这里插入图片描述

class IntVec // IntVec是对标准库vector类的模仿,仅存储int元素
{
	int *begin; // 指向已分配的内存中的首元素
    int *end; // 指向最后一个实际元素之后的位置
    int *cap; // 指向分配的内存末尾之后的位置
public:
    IntVec(IntVec &&a) noexcept // noexcept通知标准库不抛出任何异常
    : begin(a.begin), end(a.end), cap(a.cap) // 成员初始化器接管a中的资源
    {
    	a.begin = a.end = a.cap =nullptr;
    	// 令a进入可销毁状态,确保对其运行析构函数是安全的。
    }
}; 

工作流程:

  1. 移动构造函数不分配任何新内存,而是接管给定的 IntVec 中的内存。
  2. 接管之后,将给定对象中的指针都置为 nullptr
  3. 函数体执行完毕自动调用析构函数销毁移后源对象。

在第三点中,如果我们没有进行第二点,此时移后源对象仍指向被接管的内存,此时调用析构函数会释放掉刚刚移动的内存,因此三步一步都不能少。

关于 noexcept

  • 由于移动操作不分配任何资源,因此不会抛出异常,我们可以通知标准库,这样他就不会因为需要等待处理异常而浪费资源。
  • noexcept 是通知标准库的方式之一,出现在参数列表和初始化列表开始的冒号之间。

为什么移动操作不会抛出异常?

首先明确一定,是允许移动操作抛出异常的,但是这么做反而有坏处。

vectorpush_back 操作来讲,当执行尾插操作但是内存空间已经满了,需要重新分配内存空间,此时:

  • 如果重新分配过程使用了移动构造函数,且在移动了部分元素后抛出了一个异常,就会产生问题——旧空间中的移动源元素已经被改变了,而新空间中移动源元素尚未构造好。在此情况下,vector 将丢失自身的部分元素。
  • 如果 vector 使用了拷贝构造函数,当在新内存中构造元素时,旧内存中的元素保持不变。如果此时发生了异常,vector 可以释放新分配的(但还未成功构造的)内存并返回。vector 原有的元素仍然存在。

因此,对于移动操作来讲,不抛出异常反而能保证数据的完整性。


移动赋值运算符

和移动构造函数一样——不抛出异常,但仍要注意处理所有赋值运算符逃不过的劫难——自赋值问题。

IntVec& IntVec::operator=(IntVec &&rhs) noexcept{
	if(this != &rhs){ // 处理非自赋值
		free(); // 释放已有资源
		begin = rhs.begin; // 从 rhs 接管资源
		end = rhs.end;
		cap = rhs.cap;
		// 将 rhs 置于可析构状态
		rhs.begin = rhs.end = rhs.cap = nullptr;
	}
	return *this;
}

这种写法其实是最常用也最简单的自赋值处理方法,像之前讲的 用临时量存右侧运算对象swap实现自赋值 。巧妙则巧妙,但是写起来一定要很小心,远不如直接 if-else 来的方便。


合成的移动操作

如果我们不声明自己的拷贝构造函数或拷贝赋值运算符,编译器总会为我们合成这些操作。但与拷贝操作不同,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作

只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个 非static数据成员 都可以移动时,编译器才会为它合成移动操作。

编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员:
在这里插入图片描述


小结

在移动操作之后,移后源对象必须保持有效的、可析构的状态。

移后源对象仍然保持有效

我们可以对它执行诸如 emptysize 这些操作。但是,我们不知道将会得到什么结果。我们可能猜测一个移后源对象是空的,但结果并不一定如我们猜测的那样。换言之,我们可以重新用它,但是我们不知道用之前它是什么状态。


同时存在拷贝控制操作和移动操作时的匹配规则

  • 拷贝构造函数接受一个 const 类型名& 的左值引用类型;
  • 移动构造函数接受一个 类型名&& 右值引用类型。

因此,左值只能匹配拷贝构造函数,但是右值却都可以匹配,只是调用拷贝操作时需要进行一次到 const 的转换,而移动操作精确匹配,因此,右值会使用移动操作。


swap实现一个赋值运算符既是拷贝操作也是移动操作

  • 移动赋值运算符接受一个 类型名&& 右值引用类型;
  • 拷贝赋值运算符接受一个 const 类型名& 的左值引用类型。

因此,我们可以在已经定义好移动构造函数的基础上,借助 swap函数 实现一个形参为 类型名 的赋值运算符:

class IntVec
{
public:
    IntVec(IntVec &&a) noexcept
    : begin(a.begin), end(a.end), cap(a.cap)
    {
    	a.begin = a.end = a.cap =nullptr;
    }
    IntVec& operator=(IntVec a){
    	swap(*this, a);
    	return *this;
    }
}; 

具体思想我们在上一篇博客的swap实现自赋值中讲过一次,这里简单再提一下。

  • 首先 swap函数 是类自己重载的,而不是标准库中的 swap函数,目的是避免浪费内存。
  • 一定要确保类已经定义好了移动构造函数,否则,像我们之前说过的那样,在有拷贝操作的情况下,类不会合成移动操作,则该赋值运算符只实现了拷贝操作而没有实现移动操作。
  • 该赋值运算符最终实现的操作由传入的实参类型决定:左值拷贝、右值移动

举个例子:

// 假定 v1、v2 都是 IntVec 对象
v1 = v2; // v2是左值,拷贝构造函数来拷贝v2
v1 = std::move(v2); // 移动构造函数移动v2

匹配详情就不多说了,在上文的匹配规则中讲的很详细了,这里主要想体现的是:不管使用的是拷贝构造函数还是移动构造函数,赋值运算符都可以将他们的结果作为实参来执行。换言之,配合上 swap函数赋值运算符 同时支持 移动操作拷贝操作


为什么拷贝操作的形参通常是 const X& 而不是 X&?移动操作的形参通常是 X&& 而不是 const X&&?

  • 当我们希望使用 将亡值 时,通常传递一个右值引用。为了在移动后释放源对象持有的资源,实参不能是 const 的。
  • 从一个对象进行拷贝的操作不应该改变该对象。因此,通常不需要定义一个接受一个 (普通的)X& 参数的版本。

引用限定符

规定this是左值or右值

有时会看到这样的代码:

string s1 = "hello", s2 = "world";
s1 + s2 = "!";

此处我们对两个 string 的连接结果——一个右值,进行了赋值。

在旧标准中,我们没有办法阻止这种使用方式。为了维持向后兼容性,新标准库类仍然允许向右值赋值。但是,我们有时需要阻止这种用法。在此情况下,我们希望强制左侧运算对象(即,this指向的对象)是一个左值。

我们指出 this 的左值/右值属性的方式与定义 const 成员函数相同,即,在参数列表后放置一个引用限定符(reference qualifier)

class IntVec
{
public:
    IntVec& operator=(IntVec a) & // 只能向可修改的左值赋值
    {
    	swap(*this, a);
    	return *this;
    }
}; 

引用限定符可以是 &&&,分别指出 this 可以指向一个左值或右值。类似 const 限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中。

一个函数可以同时用 const引用限定。在此情况下,引用限定符必须跟随在const限定符之后:

class IntVec
{
public:
    IntVec& operator=(IntVec a) const &}; 

引用限定符与重载

就像一个成员函数可以根据是否有 const 来区分其重载版本一样,引用限定符也可以区分重载版本。

举个例子:
在这里插入图片描述

编译器会根据调用 sorted 的对象的左值/右值属性来确定使用哪个 sorted 版本:
在这里插入图片描述

  • 当我们定义 const成员函数 时,可以定义两个版本,唯一的差别是一个版本有 const限定 而另一个没有。
  • 引用限定的函数则不一样。如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加。

在这里插入图片描述


版权声明:本文为Jormungand_V原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。