C++11 右值引用和移动语义

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

作者@小萌新
专栏@C++进阶
作者简介大二学生 希望能和大家一起进步
本篇博客简介介绍C++11的右值引用和移动语义

右值引用和移动语义

基本概念

左值和右值

左值是什么

左值是一个表示数据的表达式 比如说变量名还有解引用的指针

他有下面的两个特点

  • 左值可以被取地址 可以被修改 被const修饰的左值除外
  • 左值既可以出现在表达式的左边 也可以出现在表达式的右边

下面给出一段代码示例

	// 以下的p b c *p都是左值
	int* p = new int(0);
	int b = 1;
	const int c = 2;

右值是什么

右值也是一个表示数据的表达式 比如说字符常量 表达式的返回值 函数的返回值不能是左值引用返回等等

他有下面的两个特点

  • 右值不能被取地址 也不能被修改
  • 右值只能出现在表达式的右边 不能出现再表达式左边

下面给出一段代码示例

	double x = 1.1;
	double y = 2.2;

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

	// 以下为错误示例 (右值不能出现在赋值符号的左边)
	// 10 = 1;
	// x + y = 1;
	// fmin(x, y) = 1;
  • 右值的本质是临时变量或常量 比如说上面代码中的10就是常量 x+y 和 fmin(x, y)就是临时变量
  • 临时变量和常量并没有被储存起来 这也是为什么它们不能被取地址的原因
  • 这里说的函数的返回值是右值 指的是传值返回的函数 因为传值返回的函数在返回对象的时候返回的是对象的拷贝 是一份临时变量

这里有一点需要特别说明的是 对于左值引用返回的函数来说 它们的返回值是左值

下面是一段代码示例

	class string
	{
	public:
		// 重载方括号运算符
		char& operator[](size_t i)
		{
			assert(i < _size);
			return _str[i];
		}
		// ..
	private:
		char* _str;
		size_t _size;
	};

我们这里使用【】运算符重载返回字符串中一个字符的引用 因为他要支持读写 所以采用的是左值引用返回

之所以说这里的返回值是一个左值 是因为返回的字符是被储存起来的 他是string类中_str数组中的某个字符 所以说它是可以被取地址的

左值引用和右值引用

传统的C++语法中就有引用的语法 而在C++11中更新了右值引用的语法

为了进行区分 我们我们将C++11之前的引用叫做左值引用 将C++11之后更新的引用叫做右值引用

不论是左值引用还是右值引用 它们的本质都是 “取别名”

左值引用

左值引用就是对于左值的引用 即对左值取别名 通过&来声明

下面是一段代码示例

	// 以下的p b c *p都是左值
	int* p = new int(0);
	int b = 1;
	const int c = 2;

	// 以下几个是对上面左值的左值引用
	int*& rp = p;
	int& rb = b;
	const int& rc = c;
	int& pvalue = *p;

右值引用

右值引用就是对于右值的引用 即对右值取别名 通过&&来声明

下面是一段代码示例

	double x = 1.1;
	double y = 2.2;

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

	// 以下为右值引用代码 
	int&& rr1 = 10;
	int&& rr2 = x + y;
	int&& rr3 = fmin(x, y);

这里有一点需要特别注意的 右值是不能取地址的 但是我们给右值取别名之后它会被储存到特定的位置 而此时这个右值就可以被取地址也可以被修改了

如果我们不想让右值可以被取地址和修改我们可以在前面使用const来修饰右值引用

下面是一段代码示例

	int&& rr2 = x + y;
	const int&& rr3 = fmin(x, y);

	rr2 = 20; // 修改成功
	rr3 = 30; // 因为被const修饰了 所以值不能被修改

左值引用可以引用右值嘛

  • 左值引用不能引用右值 这里涉及到一个权限放大的问题 左值是可以被修改的 而右值是不可以被修改的
  • 如果想要用左值引用来引用右值 需要用到const关键字来修饰左值引用 因为经过const修饰后左值引用就没有修改的权限了

因此const既可以引用左值也可以引用右值

template<class T>
void func(const T& val)
{
	cout << val << endl;
}

int main()
{
	string s("hello");
	func(s); //s为变量 为左值

	func("world"); // "world"是常量 为右值
	return 0;
}

右值引用可以引用左值嘛

  • 右值引用只能引用右值 不能引用左值
  • 如果想要用右值引用来引用左值 需要用到move函数

move函数是C++11标准提供的一个函数 被move后的左值能够被右值引用引用

我们可以发现没有被move函数修饰之前是不可以被右值引用的
在这里插入图片描述
但是被修饰之后就可以引用了

在这里插入图片描述

右值引用的使用场景和意义

虽然使用const修饰的左值引用能够同时引用左值和右值 但是左值引用终究是存在一些缺陷的 而C++11提出的右值引用正是用来解决这些缺陷的

为了更好的暴露出左值引用的缺陷 我们写出一个简单string类出来

代码如下

namespace shy
{
	class string
	{
	public:
		typedef char* iterator;

		// begin迭代器返回第一个元素
		iterator begin()
		{
			return _str;
		}

		// end迭代器返回最后一个元素后一个元素的位置
		iterator end()
		{
			return _str + _size;
		}

		// 构造函数
		string(const char* str = "")
		{
			_size = strlen(str); // 初始化设置字符串大小
			_capacity = _size;
			_str = new char[_capacity + 1]; // 这里要多开一个空间来存储/0
			strcpy(_str, str);
		}

		// 交换两个对象的数据 
		void swap(string& s)
		{
			::swap(_str, s._str);
			::swap(_size, s._size);
			::swap(_capacity, s._capacity);
		}

		// 拷贝构造函数
		string (const string& s)
			:_str(nullptr)
			, _size(0)
			, _capacity(0)
		{
			cout << "string(const string& s)" << endl;

			string tmp(s._str); // 调用构造函数
			swap(tmp);  // 交换私有成员变量
		}

		// 赋值运算符重载现代写法
		string& operator=(const string& s)
		{
			cout << "string& operator=(const string& s)" << endl;
			
			string tmp(s); //拷贝构造出一个临时变量
			swap(tmp);// 交换这个两个对象
			return *this; // 返回左值
		}

		// 析构函数
		~string()
		{
			delete[] _str; // 释放str的空间
			_str = nullptr;
			_size = 0;
			_capacity = 0;
		}

		//[]运算符重载
		char& operator[](size_t i)
		{
			assert(i < _size);
			return _str[i]; // 返回左值引用
		}

		// 改变容量 大小不变
		void reserve(size_t n)
		{
			if (n > _capacity)
			{
				char* tmp = new char[n + 1];
				strncpy(tmp, _str, _size + 1); // 这里不使用strcpy的原因是字符串中哟i可能出现/0
				delete[] _str;
				::swap(_str,tmp);
				_capacity = n; // 容量改变
			}
		}

		// 尾插字符
		void push_back(char ch)
		{
			// 首先判断容量是否足够
			if (_size >= _capacity)
			{
				reserve(_capacity == 0 ? 4 : 2 * _capacity);
			}
			// 尾插到最后 最后加上\0
			_str[_size] = ch;
			_str[_size + 1] = 0;
			_size++; 
		}

		//+=运算符重载
		string& operator+=(char ch)
		{
			push_back(ch);
			return *this;
		}

		// 返回c类型的字符串
		const char* c_str() const //这里加const是修饰this指针 让它的权限变成只读 为了防止后面的只读对象调用这个函数
		{
			return _str;
		}
	private:
		char* _str;
		size_t _size;
		size_t _capacity;
	};
}

左值引用的使用场景

在说明左值引用的缺陷之前 我们先来看它的使用场景

  • 做参数 防止传参时进行拷贝构造
  • 做返回值 防止返回时对返回对象进行拷贝构造
void func1(shy::string s)
{}

void func2(const shy::string& s)
{}

int main()
{
	shy::string s("hello world");
	func1(s); // 值传参
	func2(s); // 左值引用传参
	s += 'X'; // 左值引用返回
	return 0;
}

首先是 func1 它传递的参数是形式参数 他是实际参数的一份临时拷贝

再者是 func2 它传递的参数是s的别名 是左值引用

最后是+=的返回 它返回的也是一份左值引用

我们都知道string的拷贝是深拷贝 深拷贝的代价是很高的 所以说这里的左值引用效果很明显

左值引用的缺陷

左值引用虽然能避免不必要的拷贝操作 但是缺不能完全避免

  • 左值引用做参数 能够完全避免传参时的拷贝操作
  • 左值引用做返回值 不能完全避免函数对象返回时的拷贝操作

如果函数返回对象是一个局部变量 那么该变量出了局部作用域就会被销毁了

这种情况下不能使用左值引用作为返回值 只能传值返回 这就是左值引用的短板

比如说我们实现一个to_string函数 将字符串转化为int类型 此时它的返回值就必须要是值拷贝 如果使用左值引用返回就会返回一个销毁的局部变量

代码表示如下

namespace shy 
{
	string to_string(int value)
	{
		bool flag = true;
		if (value < 0)
		{
			flag = false;
			value = 0 - value;
		}

		string str;
		while (value > 0)
		{
			int x = value % 10;
			value /= 10;
			str += (x + '0');
		}
		if (flag == false)
		{
			str += '-';
		}
		std::reverse(str.begin(), str.end());
		return str;
	}
}

我们在调用to_string函数返回的时候会调用拷贝构造函数
在这里插入图片描述
C++11提出右值引用就是为了解决左值引用的这个缺陷

但是它的解决方法并不是单纯的将右值引用作为返回值

右值引用和移动语义

右值引用和移动语句解决上述问题的方式就是增加移动构造和移动赋值方法

移动构造

移动构造是一个构造函数 它的参数是右值引用类型的

移动构造的本质就是将传入右值的资源窃取过来 占为己有

这样就避免进行深拷贝 所以叫他移动构造

我们在string类中增加一个移动构造函数 该函数要做的就是通过调用swap函数将传入右值的资源窃取过来

为了能够知道移动构造是否被调用 我们这里增加一条打印语句

代码表示如下

		// 移动构造 
		string(string&& s)
			:_str(nullptr)
			, _size(0)
			, _capacity(0)
		{
			cout << "string(string&& s)" << endl;
			swap(s);
		}

移动构造和拷贝构造的区别

  • 在没有增加移动构造之前 由于拷贝构造使用的是const左值引用来接受参数 因此无论拷贝构造的是左值还是右值 都会调用拷贝构造函数
  • 增加移动构造之后 由于移动构造采用的是右值引用来接受参数 因此如果拷贝构造对象时传入的是右值 那么就会调用移动构造
  • 拷贝构造进行的是深拷贝 而移动构造只需要调用swap函数进行资源转移即可 因此移动构造的代价比拷贝构造的代价小很多

给string类增加移动构造之后 对于返回局部string类对象的函数 返回string类对象的时候会调用移动构造进行资源的转移 不会像原来一样进行深拷贝了

演示效果如下

int main()
{
	shy::string s = shy::to_string(123);
	return 0;
}

在这里插入图片描述

这里说明一下

  • 对于to_string当中返回局部的string对象是一个左值 一个临时变量 由于它出了局部作用域就会被销毁 被消耗的值我们将它叫做 “将亡值” 匿名对象也可以被称为 “将亡值”
  • 既然 “将亡值” 马上就要被销毁了 那还不如直接把它的资源转移给别人用 因此对待这种 “将亡值” 编译器会将它识别为右值 这样就可以匹配搭配参数为右值的而移动构造函数

编译器优化

当一个函数在返回局部对象时 会先用局部对象拷贝出一个临时对象 然后再用这个临时拷贝的对象再来接受我们返回值的对象

效果图如下

在这里插入图片描述

在C++11标准出来之前 对于深拷贝的类会进行两次深拷贝 但是大部分编译器为了提高效率都对这种情况进行了优化 优化成了一次深拷贝 效果图如下

在这里插入图片描述
但是并不是所有编译器都做这个优化的

在C++11标准出来之后 编译器的这个优化仍然起到了作用

  • 如果不进行优化 这里应该会调用两次移动构造
  • 如果进行了优化 这里就只会进行一次移动构造了
  • 但是实际上有了移动构造之后这里优化的作用就不大了 因为移动构造本质上就是资源转移 很轻松就能做到 资源消耗不大

但是我们如果不是用函数的返回值来构造出一个对象 而是用一个之前已经定义过的对象来接受函数的返回值 这里就无法进行优化了

示例图如下

在这里插入图片描述
我们来看看实际代码运行过程中是什么样子的

	shy::string s;
	s = shy::to_string(123);

在这里插入图片描述
当函数返回局部对象的时候 会先用移动构造构造出一个临时对象 然后再调用赋值运算符重载函数将这个临时对象赋值给接收函数返回值的对象 而赋值运算符重载函数本质上是对于拷贝构造的复用 所以说最后还会调用一次拷贝构造函数

  • 编译器并没有对这种情况进行优化 因此在C++11标准出来之前 对于深拷贝的类来说这里就会存在两次深拷贝 因为深拷贝的类的赋值运算符重载函数也需要以深拷贝的方式实现
  • 但在深拷贝的类中引入C++11的移动构造后 这里仍然需要再调用一次赋值运算符重载函数进行深拷贝 因此深拷贝的类不仅需要实现移动构造 还需要实现移动赋值

这里需要说明的是 对于返回局部对象的函数 就算只是调用函数而不接收该函数的返回值 也会存在一次拷贝构造或移动构造 因为函数的返回值不管你接不接收都必须要有 而当函数结束后该函数内的局部对象都会被销毁 所以就算不接收函数的返回值也会调用一次拷贝构造或移动构造生成临时对象

移动赋值

移动赋值是对于赋值运算符重载的一个重载函数 该函数的参数是右值引用类型的

移动赋值和移动构造一样 都是将临时对象的资源窃取过来据为己有 这样就避免了深拷贝

在当前的string类中增加一个移动赋值函数 就是调用swap函数将传入右值的资源窃取过来

代码表示如下

		// 移动赋值
		string& operator= (string && s)
		{
			cout << "string& operatpr=(string&& s)" << endl;
			swap(s);
			return *this;
		}

移动赋值和赋值运算符重载的区别

  • 在没有增加移动赋值之前 赋值运算符重在是使用const左值引用来接受参数 因为无论赋值时传入的是左值还是右值 都会调用拷贝构造函数
  • 增加移动赋值之后 由于移动赋值采用的是右值引用来接受参数 因此如果移动赋值传入的是右值 那么就会调用移动赋值
  • 原本赋值时是调用拷贝构造进行了深拷贝 而移动赋值只需要调用swap函数进行资源转移即可 因此移动赋值的代价比赋值运算符重载小的很多

写了移动赋值之后我们刚刚写的代码就会变成两次swap了

在这里插入图片描述

STL中的容器

C++11标准出来之后 所有的STL容器都增加了移动构造和移动赋值

以string容器为例

移动构造

在这里插入图片描述

移动赋值

在这里插入图片描述

右值引用引用左值

在上面我们也说过了 右值引用不能直接引用左值 但是可以通过move函数间接的引用

move函数这个名字很具有迷惑性 实际上它并不能移动过任何值 它唯一的功能就是将一个左值强制转化为右值引用 然后实现移动语义

move函数的定义如下

template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
	//forward _Arg as movable
	return ((typename remove_reference<_Ty>::type&&)_Arg);
}

这里有两点需要说明一下

  • move函数中_Arg参数的类型不是右值引用而是万能引用 万能引用和右值引用的形式一样 但是右值引用是需要确定的类型
  • 一个左值被move之后它的资源有可能被转移给别的数据了 所以说慎用被move后的左值

右值引用的其他使用场景

插入函数

C++11标准出来之后 STL中的容器除了增加移动构造和移动赋值之外 STL容器插入接口函数也增加了右值引用版本

我们这里以List容器的push_back函数为例

在这里插入图片描述

右值引用版本插入函数的意义

如果list容器中储存的是string对象 那么在调用插入函数的时候 会有以下几种插入方式

	list<shy::string> ls;
	shy::string s("1111");

	ls.push_back(s);  // 拷贝构造

	ls.push_back("2222"); // 移动构造
	ls.push_back(shy::string("3333")); // 移动构造
	ls.push_back(std::move(s)); // 移动构造

我们可以发现 如果我们插入的是右值 那么就是调用移动构造进行资源转移

演示效果如下

在这里插入图片描述

  1. 在C++11之前list容器的push_back接口只有一个左值引用版本的插入 因此在构造节点的时候 左右值都只能匹配到string的拷贝构造函数进行深拷贝
    在这里插入图片描述
  2. 在C++11版本之后 string类提出了移动构造函数 此时如果传入的值是右值就会采用移动构造 这时候就会进行一个资源转移而不是拷贝构造
  3. 我们上面的例子中 传入的第一个参数是左值 后面的参数全部是右值 所以说第一次是拷贝构造 后面的全部是移动构造了

完美转发

万能引用

模板中的&&不代表右值引用 而是万能引用 这样它既能接受左值又能接受右值 比如说这样子

template<class T>
void PerfectForward(T&& t)
{
	//...
}

右值引用和万能引用的区别就是 右值引用需要确定类型 而万能引用会根据传入的类型进行推导 如果传入的实参是一个左值 那么这里的形参t就是左值引用 如果传入的实参是一个右值 那么这里的形参t就是右值引用

下面重载了四个func函数 这四个func函数的参数分别左值引用 const左值引用 右值引用和const右值引用

我们在主函数中使用完美引用模板函数来调用func函数

代码表示如下

在这里插入图片描述
但是我们最后发现 不管传入何种类型 最后调用的都是左值引用而不是右值引用

这是为什么呢

因为只要右值经过一次引用之后右值引用就会被储存到特定位置 这个右值就可以被取地址和修改了 所以在经过一次参数传递之后右值就会退化为左值 如果我们想要让他保持右值的属性 这个时候就要用到完美转发

完美转发保持值属性

要想在参数传递过程中保持其原有的属性 需要在传参时调用forward函数

代码表示如下

template<class T>
void PerfectForward(T&& t)
{
	Func(std::forward<T>(t));
}

经过完美转发之后 再次调用PerfectForward函数调用func就会各自匹配到对应的函数了

在这里插入图片描述

完美转发的使用场景

下面提供一个简单的list类 分别提供了左值引用和右值引用的接口函数


	template<class T>
	struct ListNode
	{
		T _data;
		ListNode* _next = nullptr;
		ListNode* _prev = nullptr;
	};
	template<class T>
	class list
	{
		typedef ListNode<T> node;
	public:
		//构造函数
		list()
		{
			_head = new node;
			_head->_next = _head;
			_head->_prev = _head;
		}
		//左值引用版本的push_back
		void push_back(const T& x)
		{
			insert(_head, x);
		}
		//右值引用版本的push_back
		void push_back(T&& x)
		{
			insert(_head, std::forward<T>(x)); //完美转发
		}
		//左值引用版本的insert
		void insert(node* pos, const T& x)
		{
			node* prev = pos->_prev;
			node* newnode = new node;
			newnode->_data = x;

			prev->_next = newnode;
			newnode->_prev = prev;
			newnode->_next = pos;
			pos->_prev = newnode;
		}
		//右值引用版本的insert
		void insert(node* pos, T&& x)
		{
			node* prev = pos->_prev;
			node* newnode = new node;
			newnode->_data = std::forward<T>(x); //完美转发

			prev->_next = newnode;
			newnode->_prev = prev;
			newnode->_next = pos;
			pos->_prev = newnode;
		}
	private:
		node* _head; //指向链表头结点的指针
	};

下面定义一个list对象 储存我们之前实现的list类 我们分别传入左值和右值调用不同版本的push_back函数

	shy::list<shy::string> lt;
	shy::string s("1111");
	lt.push_back(s);      //调用左值引用版本的push_back

	lt.push_back("2222"); //调用右值引用版本的push_back

我们可以发现运行结果如下

在这里插入图片描述

  • 我们在实现push_back的时候复用了insert的代码 对于左值引用的insert函数来说 它会先new一个节点 然后将对应的左值赋值给这个节点 调用赋值运算符重载 又因为赋值运算符重载本质上复用了拷贝构造 所以会打印出来两行文字
  • 对于右值版本的push_back函数 它复用了insert的代码 对于右值引用的insert函数来说 它会先new一个系欸但 然后将对应的右值赋值给这个节点 调用移动构造来进行转移资源
  • 这其中调用函数传参的时候多处用到了 完美转发 这是因为如果不使用完美转发就会让右值退化为左值 最终导致多一次深拷贝 从而降低效率

这里演示一下 如果不使用完美转发会是什么样子的

在这里插入图片描述

在这里插入图片描述
我们发现这里就变成了两次深拷贝

这就是完美转发的使用场景

如果我们想要保持右值的属性 每次传参的时候就必须要使用完美转发

这里还有一点需要注意的是 实现的list类实例化之后参数T&&就不再是万能引用而是右值引用了 因为这个时候它的类型已经确定了

如果想要使用万能引用而不是右值引用需要像上面那样不确定具体类型 在使用时进行推导

与STL中的list的区别

如果将刚才测试代码中的list换成STL当中的list

  • 调用左值版本的push_back插入节点时 在构造结点时会调用string的拷贝构造函数
  • 调用右值版本的push_back插入节点时 在构造结点时会调用string的移动构造函数

而我们实现的list代码却使用的是赋值运算符重载和移动赋值

这是因为我们是使用的new操作符来申请空间 new操作符申请空间之后会自动调用构造函数进行初始化

而初始化之后就只能使用赋值运算符重载了

而STL库中使用空间配置器获取内存的 因此在申请到内存后不会调用构造函数对其进行初始化 是后续用左值或右值对其进行拷贝构造 所以会产生这样子的结果

如果我们想要达到STL中的效果 我们只需要使用malloc开辟空间 然后使用定位new进行初始化就可以

关于定位new的细节可以参考我的这篇博客

C++内存管理 定位new

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: c++