深入理解右值引用与移动语义

写在前面

本文主要为大家梳理以下几个问题:

  • 什么是右值
  • 右值引用的意义与使用场景
  • std::move 函数的本质
  • 如何编写移动构造函数
  • 万能引用与完美转发

参考资料:

  1. 《一文读懂C++右值引用与std::move》

  2. 《C++高阶知识:深入分析移动构造函数及其原理》

  3. 《Value categories》

  4. 《C++11的 value category 以及 move semantics》

由于作者才疏学浅,理解欠缺的地方欢迎大家指正


1. 什么是右值,什么是左值?

 每个 C++ 表达式(包括操作符和其操作数、字面值、变量名等)具有两个独立的属性:类型和值类别
 类型(type)大家都不陌生,指的是表达式的数据类型,它定义了表达式的取值范围和可执行的操作。例如,一个整数表达式的类型可以是 int,一个浮点数表达式的类型可以是 float。
 值类别(value category)可以理解为表达式的身份和可移动性。根据 C++ 标准,有三种最主要的值类别:右值(rvalue)、左值(lvalue)、将亡值(xvalue),他们三者的关系如下:
在这里插入图片描述

  • 身份决定了它是否具有表达式寻址性,即我们是否可以获取其在内存中的地址
  • 可移动性如果出现在赋值,初始化等语句中,是否会使语句呈现移动语义

上面说的有些抽象,我们结合具体的例子来分析,根据表现出的特征进行区分:

[右值与右值引用]:

// 以下都是常见的右值
10;
x + y;
fmin(x, y);

// 右值引用
int&& rr1 = 10;
double&& rr2 = x + y;             
double&& rr3 = fmin(x, y); 		  
  • 右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引用返回)等等
  • 右值不能放在赋值符号的左边,不信你试试 10 = x + y
  • 对右值的引用就是右值引用,用 && 表示
  • 不能对右值取地址,不信你试试 int* p = &10;

[左值与左值引用]:

// 以下a、b、pa都是左值
int a = 10;    
int b = a;
int* pa = &a; 

// 左值引用
int& rla = a;
  • 左值可以出现在赋值符号的左边和右边
  • 可以取它的地址,可以为他赋值

下面来考虑一些疑难问题:

1.1右值引用可以引用左值吗

可以。 std::move() 函数可以将左值强转为右值。没错,就是强制类型转换,我们后面将结合源码具体谈到:

int a = 10;
int&& rr = std::move(a);

1.2 左值引用、右值引用本身是左值还是右值?

 被声明出来的左、右值引用都是左值。因为根据C++语言规范,无论是左值引用还是右值引用,它们都被认为是具名对象,具有地址并且可以寻址。因此,在使用引用时,它们被视为左值。用下面的代码验证:

void check(int&& rr) {
    
    
	cout << "Yes" << endl;
}

int main() {
    
    
	int a = 5;	       		 // a是个左值
	int& ref_a_left = a;	   	// ref_a_left是个左值引用,本身是左值
	int&& ref_a_right = std::move(a); // ref_a_right是个右值引用,本身是左值

	check(a);              // 编译不过,无法将左值绑定到右值
	check(ref_a_left);     // 编译不过,左值引用ref_a_left本身也是个左值
	check(ref_a_right);    // 编译不过,右值引用ref_a_right本身也是个左值

	check(std::move(a));			// 编译通过
	check(std::move(ref_a_right));  // 编译通过
	check(std::move(ref_a_left));   // 编译通过
}

 右值引用本身是一个左值,那么也就不难理解,为什么右值不可以修改,但是右值引用可以修改:我们并不是修改右值,而是修改右值引用所引用的对象;右值引用是左值,它有自己的标识符和地址。

int&& rr = 10;
rr = 20;

1.3 特殊的 const 左值引用

 const 左值引用比较特殊,它既可以接受左值,也可以接受右值。和右值引用一样, const 左值引用能够延长右值的生命周期,以避免产生悬空引用。下面是cppreference 中的说明:

在这里插入图片描述

 当我们将一个右值绑定到 const 左值引用上时,编译器会自动创建一个临时对象,并将该右值绑定到这个临时对象上。这个临时对象的生命周期会与 const 左值引用的生命周期相同,从而确保了在 const 左值引用的作用域内能够安全地使用这个右值。
 这也是为什么经常会看到使用 const& 作为函数参数的原因之一。如果没有const,这样的代码就无法编译通过了:v.push_back(5)

void push_back (const value_type& val);

2. 右值引用与移动构造的意义

 左值引用做参数和返回值都可以提高效率。但是左值引用的短板在于,如果引用的对象出作用域销毁,那么就不能使用左值引用了。例如下面的例子中(不考虑编译器优化),hello()函数在返回时,首先会将 "hello world" 拷贝给临时变量。
  这个临时变量本质上属于将亡值,同时具有身份移动性。在没有移动构造前,s只能把临时变量的内容再拷贝复制一份,而眼睁睁的看着临时变量生命周期到了而销毁 —— 白白浪费!

string hello() {
    
    
	return "hello world";
}
string s = hello();

 但是对于一个这个即将被销毁的对象,我们为什么不聪明点,直接将其中的资源占为已有呢?将对方资源所有权转移过来,这就是移动构造的核心思想。如何转移?可以直观理解为指针做一个交换:

namespace my{
    
    
	string(string&& s)
	    :_str(nullptr) ,_size(0), _capacity(0){
    
    
        swap(str_, s.str_)        // 所有权转移 
	}
};

 注意,移动构造后一定要将原对象中的指针置为空,否则一块空间会被 delete 两次。而对于 nullptr, delete 多次也没有影响。

 既然移动构造这么香,那么我们进一步想,哪些资源可以被移动构造?C++的设计者们注意到,大多数情况下,右值所包含的对象都是可以安全的被移动的。

 那么问题来了,我们如何显式告诉编译器我们要接收一个右值呢?在C++11之前,只有 const 修饰的左值引用才能接收右值,这可是个大问题啊,你都被const修饰了,我还怎么“偷”你的资源呢?顺着这个逻辑思考,右值引用的出现也是一个必然,它为移动构造的出现洒下了肥沃的土壤。

 接下来我们来手动实现下移动构造函数

3. 移动构造函数的使用

namespace my {
    
    
	class string {
    
    
	public:
		string() : len_(0), cap_(0), data_(nullptr) {
    
    }

		string(const char* s) {
    
    
			// 略
		}

		string(const my::string& s) {
    
    
			cout << "拷贝构造函数" << endl;
			// 略
		}
		
		my::string& operator=(const my::string& s) noexcept {
    
    
			cout << "赋值运算符重载" << endl;
			// 略
		}
		
		string(my::string&& s) {
    
    
			cout << "移动构造函数" << endl;
			len_ = s.len_;
			cap_ = s.cap_;
			swap(data_, s.data_);
		}

		my::string& operator=(my::string&& s) noexcept{
    
    
			cout << "移动赋值运算符重载" << endl;
			len_ = s.len_;
			cap_ = s.cap_;
			if (data_) {
    
    
				delete data_;
				data_ = nullptr;
			}
			swap(data_, s.data_);
			return *this;
		}

	private:
		char* data_;
		int len_;
		int cap_;
	};
};

STL库中基本都支持了移动构造和移动赋值,例如string等等

string (string&& str) noexcept;
string& operator= (string&& str) noexcept;

swap函数也是
在这里插入图片描述
 给大家分享一个我初学时容易犯的错误:我们仍然沿用上面实现的 my::string类做测试,大家觉得 s = t.s 中有没有调用移动赋值呢?

class test {
    
    
	public:
		test() {
    
    }
		// ……
		test(test&& t) {
    
    
			s = t.s;
		}

	private:
		my::string s;
	};

在这里插入图片描述
答案是并没有。虽然t是一个右值,但是t.m确实是一个左值:
在这里插入图片描述

4. move的实现原理

 刚开始学习 std::move,大家总是容易对move函数抱有误解,认为move函数完成了内存上资源的移动,然而实际上move完成的工作只是强制类型转换,我们来看看相应的源码:

template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
    
    
    return static_case<typename remove_reference<T>::type&&>(t);
}

 虽然细节上我们并不了解,但我们大致可以看出 move 就是完成了强制类型转换的工作。为了更加透彻的理解,我会为大家说明其中的细节

  • 在模板中,&& 并不是代表右值引用,而是万能引用。它既可以接收左值,也可以接收右值。

  • 这个返回值 typename remove_reference<T>::type&& 是什么意思呢?type是定义在 remove_reference 中的类型成员,因此访问它时也与访问静态成员一样用::访问,而该类是一个模板类,所以在它前面要加typename关键字。

  • remove_reference 从它的名字也可以看出,它是通过模板去除引用
    在这里插入图片描述

  • 我们假定T为int&,即传入左值,那么最后可以将上面的代码简化成如下的形式:

    int && move(int& && t){
          
          
        return static_case<int&&>(t);
    }
    
  • 遇到 int& && 的时候,会发生引用折叠,折叠的规则如下图所示:
    在这里插入图片描述

  • 所以最终move其实就做了这么一件事:

    int && move(int& t){
          
          
        return static_case<int&&>(t);
    }
    

5. 完美转发

 我们前面谈到,在模板中,&& 既可以接收左值,也可以接收右值,但是当我们在函数内部,将val 传递给另一个函数的时候,val将发生退化。此时,val总是是被当作左值进行传递的。

void Fun(int &x){
    
     cout << "左值引用" << endl; }
void Fun(const int &x){
    
     cout << "const 左值引用" << endl; }
void Fun(int &&x){
    
     cout << "右值引用" << endl; }
void Fun(const int &&x){
    
     cout << "const 右值引用" << endl; }

template<typename T>
void PerfectForward(T&& t)
{
    
    
	Fun(t);
}

int main()
{
    
    
	PerfectForward(10); // 右值
	int a = 10;
	PerfectForward(a); // 左值
	PerfectForward(std::move(a)); // 右值
	const int b = 8;
	PerfectForward(b); // const 左值
	PerfectForward(std::move(b)); // const 右值
}

在这里插入图片描述

  • 为了保持参数原有的左右值属性,我们需要使用std::forward<模板参数>()函数来实现完美转发:
    在这里插入图片描述

  • 注意!为了保持参数原有的左右值属性,所有的向下转发都需要实现完美转发:

猜你喜欢

转载自blog.csdn.net/whc18858/article/details/132918508