C++右值引用与移动语义和完美转发

x33g5p2x  于2021-10-18 转载在 C/C++  
字(4.4k)|赞(0)|评价(0)|浏览(286)

右值引用只不过是一种新的C语法,真正理解起来有难度的是基于右值引用引申出的2种C编程技巧,分别为移动语义和完美转发。

1. 左值、右值、将亡值:

1.1 左值、右值、将亡值的概念:

从以下几个概念逐步深入:

  1. 表达式:
    要说清“三值”,首先要要说明表达式。
    表达式的定义:
    由运算符和运算对象构造的计算式,称为表达式。
    举例:
    字面值和变量是最简单的表达式,“a+b”也是表达式,函数的返回值也被认为是表达式。
  2. 值类别:
    表达式是可求值的,对表达式求值将得到一个结果。这个结果有两个属性:“类型”和“值类别”。(“类型”指:int、char等数据类型;“值类别”指:左值、右值等)
    在C++11以后,如果表达式按“值类别”划分,则必然属于以下三者之一:左值、纯右值、将亡值。 其中,左值和将亡值合成“泛左值”,纯右值和将亡值合称“右值”。

详细说明:

  1. 左值:
    能够出现在“赋值号(=)”左侧,能够取地址 的表达式,称为左值表达式。
    举例:
    函数名、变量名(实际上是“函数指针”和“具名变量”)、前置自增/自减运算符连接的表达式(++i、–i)、由赋值运算符或符合赋值运算符连接的表达式(a=b、a+=b、a%=b)等。
  2. 纯右值:
    满足以下条件之一:
    ① 本身就是赤裸裸的、纯粹的“字面值”,如:3、false;
    ② 求值结果相当于 字面值 或是一个 不具名的临时对象。
    举例:
    除字符串字面值以外的字面值、返回非引用类型的函数调用(此时函数返回的是临时对象)、后置自增/自减运算符(i++、i–)、取地址表达式(&a)、算数表达式(a+b、a&b、a<<b,这些都是返回的临时对象)、逻辑表达式(a&&b、a||b)、比较表达式(a==b、a>=b、a<b等,同样是返回临时对象)等。
  3. 将亡值:
    在C11之前,右值和纯右值是等价的。在C11中的将亡值是随着右值引用的引入而新引入的。 也就是说,将亡值与右值引用息息相关。
    所谓的“将亡值表达式”,就是下列表达式:
    ① 返回一个右值引用的表达式;
    ② 转换为右值引用的转换函数的调用表达式。

1.2 “将亡值”到底指的是什么:

在C++11中,我们用 左值 去初始化一个对象或为一个已有对象赋值时,会调用 拷贝构造函数 或者 拷贝赋值运算符 来 “拷贝资源”(所谓资源,就是new出来的东西);

当我们用一个 右值 (包括纯右值和将亡值)来初始化或赋值时,会调用 移动构造函数 或者 移动赋值运算符 来 “移动资源”,从而避免拷贝,提高效率。 当该右值完成初始化或赋值的任务时,它的资源已经移动给了被初始化者或被赋值者,同时该右值也将马上被销毁(析构)。

也就是说,当一个右值准备完成初始化或赋值任务时(用这个右值去初始化其他变量或者给其他变量赋值),它已经“将亡”了。
又因为:

  1. 这种右值是与C++11新生事物 ---- “右值引用”相关的新右值;
  2. 这种右值常用来完成“移动构造”或者“移动赋值”的特殊任务,扮演着“将亡”的角色;

所以,C11给这类右值起了一个新的名字 ------ 将亡值。
即:将亡值是C
11中的一种特殊的右值,是一个准备将自己的值移动、赋值给其他人的右值。
(右值 = 纯右值 + 将亡值)

1.3 左值引用、右值引用:

在C98/03标准中就有左值引用(&),此时只能操作C中的左值,无法对右值添加引用:

int num = 10;
int &b = num;		//正确
int &c = 10;		//错误!

注意,虽然C++98/03标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值,也就是说,常量左值引用既可以操作左值,也可以操作右值:

int num = 10;
const int &b = num;		//正确
const int &c = 10;		//正确,常量左值引用可以操作右值

在C++11标准中引入了另一种引用方式,称为右值引用,用 “&&” 表示。

和左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化:

int num = 10;
int &&a = num;		//错误!右值引用不能初始化为左值
int &&b = 10;		//正确

和使用常量左值引用操作右值的方式不同的是,右值引用还可以对右值进行修改:

int &&a = 10;
a = 100;			//正确

C++语法上也支持定义 常量右值引用,虽然这种定义出来的右值引用并无实际用处:

const int&&a = 10;

2. 右值引用在C++11中的应用:

C++11中与右值引用相关的几个函数:

std::move();
std::forward();
emplace_back();

通过这些函数,我们可以避免不必要的拷贝,提高程序性能。

2.1 std::move() :

2.1.1 如何将一个右值引用绑定到左值上:

由于右值(指将亡值)引用只能绑定到临时对象,我们得知:
(1)所引用的对象将要被销毁;
(2)该对象没有其他用户。

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

也正因为如此:
C++不允许将一个右值引用 直接 绑定到一个变量上,即使这个变量是右值引用也不行(因为变量的资源不能随意接管)。

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

int val = 5;
int &&r3 = static_cast<int&&>(val);
cout << r3 << endl;

除了使用强制类型转换的方法,c++11中引入了一个新的标准库函数 std::move ,专门用来获得绑定到左值的右值引用,例如:

int val = 5;
int &&r3 = std::move(v);

std::move() 函数模板的原型为:

#include <utility>

template<typename T>
typename remove_reference<T>::type&& move(T&& arg) noexcept;
//返回值类型为右值引用

std::move() 是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝。

如图所示是深拷贝和std::move() 的区别:

另外需要注意:

与大多数的标准库名字的使用不同,对 std::move 函数不提供 using声明,需要直接调用 std::move 而不是 move。 《C++ Primer》中的解释是因为“move(以及forward)的名字冲突比其他标准库函数的冲突频繁的多,我们建议最好使用它们的带限定语句的完整版本,这样就能明确的知道想要使用的是函数的标准库版本”(P707)。

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

与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定对象中的内存

在接管内存之后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移后源对象会被销毁,意味着将在其上运行析构函数。

使用右值引用进行对象的移动构造时,有一点必须注意:
  我们使用右值引用,就意味着要接管源对象的内存,在右值引用完成后(资源完成移动后),源对象中的指针必须立即置为nullptr,源对象必须不再指向被移动的资源, 因为这些资源的所有权已经归属新创建的对象,必须防止程序后序操作中通过源对象释放资源导致出错。

举例:

//StrVec类原型:
class StrVec {
public:
	StrVec(StrVec &&s);		//移动构造函数
private:
	string  *elements;
	string  *first_free;
	string  *cap;
};

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

移动赋值运算符 的实现与移动构造函数的原理相似:

//移动赋值运算符:

StrVec& StrVec::operator=(StrVec&& rhs) nonexcept {
    //先进行异常判断:如果是自赋值,则直接返回*this即可
    if(this != &rhs) {
        free();		//释放已有元素

		elements = rhs.elements;
		first_free = rhs.first_free;
		cap = rhs.cap;

		rhs.elements = rhs.first_free = rhs.cap = nullptr;	//将rhs置为可析构状态
    }
    return *this;
}

合成的移动操作:

只有当一个类没有定义任何自己版本的拷贝成员(类没有定义任何拷贝构造函数),且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
所以大多数类如果没有显式定义移动构造函数和移动赋值运算符的话,它们都是不会支持移动操作的。

2.3 std::forward() :

右值引用类型是独立于值的,一个右值引用参数作为参数的形参,在函数内部再转发该参数的时候,它已经变成一个左值了,并不是它原来的类型了。

因此,我们需要一种方法能够按照参数原来的类型转发到另一个函数,这种转发被称为“完美转发”。

所谓完美转发(perfect forwarding),是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数。

C++11中提供了这样的一个函数 std::forward(),它是为转发而生的,它会按照参数本来的类型来转发出去,不管参数类型是 T&& 这种未定的引用类型还是明确的左值引用或者右值引用。

std::forward() 函数模板的原型:

template <typename T>
T&& forward(typename remove_reference<T>::type& arg) noexcept;

template <typename T>
T&& forward(typename remove_reference<T>::type&& arg) noexcept;

参考内容:
话说C++中的左值、纯右值、将亡值
C++11改进我们的程序之move和完美转发

相关文章