Bootstrap

C++:右值引用与移动语义(C++11知识点——上)

为什么要学习C++11?

        C++11,是C++语言标准的一个重大更新。它在C++98标准的基础上进行了大量改进,修正了约600个语言缺陷,并添加了约140个新特性。这些更新使得C++11更像是从C++98/03中孕育出的一种新语言,为现代C++程序设计奠定了坚实的基础。


一、问题引入 

1.问题背景

        假设你用的编译器是一个非常古老的编译器,所以就不存在编译器对传参和传返回值的拷贝优化,也更不会存在对连续拷贝进行的合并优化。

  • 请问下面两个函数调用的的构造次数是多少?
  • addStrings():传参拷贝构造4次,传返回值拷贝构造2次,函数体构造一次
  • generate():传返回值拷贝构造2次(但深拷贝,每次都是numsrow + 1),函数体构造(numsrow + 1)次
  • 由此可见,在C++11之前和编译器不做任何优化的情况下,传值返回需要付出一定的性能消耗,甚至在某些深拷贝的情形下,代价会进一步增大
  • 如果你作为一名合格的C++程序员,你看到这么糟心的代码,你会作怎样的处理?
class Solution {
public:
	string addStrings(string num1, string num2) 
	{
		string str;
		int end1 = num1.size() - 1, end2 = num2.size() - 1;
		int next = 0;
		while (end1 >= 0 || end2 >= 0)
		{
			int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;
			int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;
			int ret = val1 + val2 + next;
			next = ret / 10;
			ret = ret % 10;
			str += ('0' + ret);
		}
		if (next == 1)
			str += '1';
		reverse(str.begin(), str.end());
		return str;
	}

	vector<vector<int>> generate(int numRows)  
	{
		vector<vector<int>> vv(numRows);
		for (int i = 0; i < numRows; ++i)
		{
			vv[i].resize(i + 1, 1);
		}
		for (int i = 2; i < numRows; ++i)
		{
			for (int j = 1; j < i; ++j)
			{
				vv[i][j] = vv[i - 1][j] + vv[i - 1][j - 1];
			}
		}
		return vv;
	}
};


int main()
{
	string ret = Solution().addStrings("12345649", "9872131");

	vector<vector<int>> ret = Solution().generate(5);
	return 0;
}
 2.解决方案一:传统方法

  • 这样的代码,确实解决了拷贝次数过多的问题,在一定程度了提高了程序的性能。但是代码的可读性却受到了当头一棒。如果在当时那个历史背景下,C++委员会无作为,编译器也不做任何改进。可能也只好为了效率去舍弃代码的可读性了。
  • 但问题总有得到解决的那一刻:
  • 编译器对传参和传返回值的拷贝优化,以及对连续拷贝进行的合并优化.
  • 以及C++11带来的右值引用与移动语义,使得传值返回的问题得到了很好的解决.
  • 传值返回问题的解决方案二:编译器的优化,我们已然早已熟知,则本篇文章的重头戏即将开幕:右值引用和移动语义,也就是我们的传值返回问题的解决方案三。
  • 移动语义的定义:它允许对象的资源在被移动的时候,避免不必要的拷贝,从而提高程序的性能。即,将当前对象指向的资源的所有权转给另一个对象——你对某个对象指向的资源的感兴趣,你通过某种手段(下面要讲的移动构造和移动赋值),进行资源的窃取(实现移动语义)。

二、右值引用和移动语义

1.左值和右值的定义(C++98)
  • 左值:可以取地址,可以出现在赋值操作符的左端和右端,通常是变量和解引用的指针,其具有持久性——所有可以定位的值(可以进行取地址操作)
  • 右值:不可以取地址,只能出现在赋值操作符的右端,通常是常量、临时对象(类型转换的中间值,表达式计算结果)、匿名对象,通常其生命周期很短——指那些不持有对象身份的值。
  • 左值的缩写lv,右值的缩写为rv,现在很多人都会为lv解读为 located value(有位置的值),将rv解读为 read value(只能读的值,反而言之不能寻其地址)。由此可见左值和右值的核心区别是是否可以取地址.而lv,rv的含义解读,更能帮助我们去理解左值与右值。

2.左值引用与右值引用的使用
  • 左值引用可直接引用左值:type& Ref = lv;
  • 右值引用可直接引用右值:type&& Ref = rv;
  • 左值引用也可以间接引用右值:const type& Ref = rv;
  • 右值引用也可以引用move(左值):type&& Ref = move(lv);
  • 左值引用的引用名,右值引用的引用名都被认为左值;
  • 特别的,针对const 左值,用const左值引用去绑定;
int func() { return 1314; }

void test1() // 引用的基本使用场景
{
	// 下面的'='左侧的都是左值~
	int* p = new int(0);
	int a = 3;
	double c = 8;
	*p = 10;
	string s("111111");
	s[0] = 'x';

	// 左值引用左值 ( (type&):左值引用,可引用右值,const 引用也可引用右值 )
	int*& lv1 = p;
	double& lv3 = c;
	string& lv4 = s;
	int& lv5 = a;

	// const 左值引用 右值
	const int& c_lv1 = 3;
	const int& c_lv2 = a + 3;
	const int& c_rv3 = (int)c;
	const int& c_rv4 = func();
	const string& c_rv5 = string();

	// 右值引用右值 ( (type&&):右值引用,可引用右值和move后的左值 )
	int&& rv1 = 3; // 常量
	int&& rv2 = a + 3; // 表达式求值 -> 的临时对象
	int&& rv3 = (int)c; // 类型转换 -> 的临时对象
	int&& rv4 = func(); // 传值返回 -> 的临时对象
	string&& rv5 = string(); // 匿名对象

	// 右值引用move(左值)
	int*&& m_rv1 = move(p);
	int&& m_rv3 = move(c);
	string&& m_rv4 = move(s);
	int&& m_rv5 = move(a);
}

void test2() // 针对 const 左值 的引用
{
	const string a = "苏苏要精通C++"; // 在C++中,对一个变量加上const,表示变量a不可被修改

	// string& ref_lv_a = a; a会通过引用修改,编译器报错,
	const string& ref_lv_a = a;

	//	那右值引用如何 绑定 const左值 呢?
	//	const string&& ref_rv_a = move(a); -> 这里会有一个警告不会对常量变量使用move
	//	既然是一个常量了,那能不能这样写呢?
	//	const string&& ref_rv_a1 = a; 
	//	a的类型是左值,把一个左值绑定给右值引用是错误的。

	// 总结:用const左值引用去绑定const 左值
	// 理解:根据右值和移动语义的理解,右值一般是临时对象和匿名对象,或者是一些将亡值(准备销毁的左值)
	// 而移动语义呢,是对右值进行资源的窃取,而针对常量,进行拷贝或者对其进行移动语义其差别并不大~
	// 而针对const 左值,其对象所指资源不能更改,也就没有窃取的实际意义 /
	// 也就能够理解move(a)的警告:不要对常量变量进行move,没有意义;

	// 如数字常量3,常量字符串"122333415",这些常量进行右值引用和移动语义也是没有意义~
	// 总结:用const左值引用去绑定const 对象

	// 那 const auto&& 和 const auto& 使用场景等价~
}

void test3() // 验证:左值引用的引用名,右值引用的引用名都被认为左值
{
	int a = 3, b = 4;
	double c = 13.14;

	int& lv1 = a;
	int& lv2 = b;
	double& lv3 = c;
	
	int&& rv1 = 3; 
	int&& rv2 = a + b;
	int&& rv3 = (int)c;

	// 左值与右值的区别是能否取地址~
    // 通过打印发现,左值引用和右值引用的引用名都有地址~
	cout << &lv1 << endl;
	cout << &lv2 << endl;
	cout << &lv3 << endl;
	cout << &rv1 << endl;
	cout << &rv2 << endl;
	cout << &rv3 << endl;
}

int main()
{
	test1();
	test2();
	test3();

	return 0;
}

3.类型分类(C++11)
  • 在C++11中,右值的定义得到一些扩充:右值 = 纯右值(C++98的右值) + 将亡值。
  • 在C++11中,左值的定义得到具体划分:泛左值(C++98的左值) = 左值 + 将亡值。
  • 泛左值(glvalue):C++98的左值定义
  • 纯右值:C++98的右值定义
  • 将亡值:C++11新引入的一种右值类型,它表示一个即将被销毁的对象,但其资源仍然可以被“窃取”或“移动"。通常是move(左值)的结果。
4.引用延长生命周期
  • 引用可以延长临时对象,匿名对象等的生命周期,但不超过其作用域。
class myclass
{
public:
	myclass()
	{
		cout << "myclass()" << endl;
	}
	~myclass()
	{
		cout << "~myclass()" << endl;
	}
};
myclass&& ref3 = myclass();
void test4() // 引用延长生命周期~
{
	myclass(); // 匿名对象,生命周期只在这一行~
	cout << "*****************************" << endl;
	myclass&& ref1 = myclass(); 
	const myclass& ref2 = myclass(); // 引用可以延长生命周期~
	cout << "*****************************" << endl;
}

int main()
{
	test4();
	cout << "*****************************" << endl;
	return 0;
}

5.左值和右值的参数匹配
  • 左值和右值的参数匹配:编译器会优先选择与参数类型最匹配的函数。
void Func(string& val) // 接受普通左值
{
	cout << "void Func(string& val)" << endl;
}
void Func(const string& val) // 接受const 左值
{
	cout << "void Func(const string& val)" << endl;
}
void Func(string&& val) // 接受右值
{
	cout << "void Func(string&& val)" << endl;
}

int main()
{
	string s1; // 普通左值对象
	const string s2; // const左值对象
	// string() 右值对象
	Func(s1);
	Func(s2);
	Func(move(s1));
	Func(string());
	return 0;
}

6.引用折叠
  • C++中不能直接定义引⽤的引⽤如 int& && r = i; ,这样写会直接报错,通过模板或 typedef
    中的类型操作可以构成引⽤的引⽤。
  • 通过模板或 typedef 中的类型操作可以构成引⽤的引⽤时,这时C++11给出了⼀个引⽤折叠的规则:右值引⽤的右值引⽤折叠成右值引⽤,所有其他组合均折叠成左值引⽤。
  • 形如下面的函数模板,可称之为万能引用:
void test()
{
	typedef int& lref;
	typedef int&& rref;

	int n = 0;
	lref& r1 = n; // r1 的类型是 int&
	lref&& r2 = n; // r2 的类型是 int&
	rref& r3 = n; // r3 的类型是 int&

	rref&& r4 = 1; // r4 的类型是 int&& —— 右值引用的右值引用
}
template<class T>
void func(T&& x) // 假设func只接受string类型的参数
{
	T x1 = x; // T是type
	x1 += "-ZMH";
	cout << "x" << x << endl;
	cout << "x1" << x1 << endl;
	cout << "若x与x1输出结果相同,则T&&为左值引用,反之为右值引用" << endl;
}
int main()
{
	string s1("NoOneButYou");
	func(s1);		// 传左值(类型为type),编译器推导T的类型为type&,根据引用折叠规则:T&&是左值引用
	string s2("NoOneButYou");
	func(move(s2)); // 传右值(类型为type),编译器推导T的类型为type,T&&是右值引用
	return 0;
}

7.完美转发
  • template<class T> void func(T&& x)的模板程序中:
  • 传左值实例化以后是左值引⽤的func(type& x)函数;传右值实例化以后是右值引⽤的func(type&& x)函数。
  • 变量表达式都是左值属性,也就意味着⼀个右值被右值引⽤绑定后,右值引⽤变量表达式的属性是左值。所有func()中变量x的属性只能是左值,若想继续保持x对象的右值属性,继续调用其它函数,则需要使⽤完美转发(forward)实现。
  • template <class T> T&& forward(typename remove_reference<T>::type& arg);
  • template <class T> T&& forward(typename remove_reference<T>::type&& arg);
  • 完美转发forward本质是⼀个函数模板,他主要还是通过引⽤折叠的⽅式实现:传给x的是左值,返回的左值,传给x的是右值,保持它的右值属性。
  • 完美转发是一个比较简单的语法机制,在实际中去感受它,会理解保持参数的(如x)右值属性的重要性。这里只需记住即可.
8.移动构造与移动赋值(移动语义的体现)
  • 移动构造函数是⼀种构造函数,类似拷⻉构造函数,移动构造函数要求第⼀个参数是该类类型的右值引用引⽤,如果还有其他参数,额外的参数必须有缺省值。
  • 移动赋值是⼀个赋值运算符的重载,他跟拷⻉赋值构成函数重载,类似拷⻉赋值函数,移动赋值函数要求第⼀个参数是该类类型的右值引⽤。
  • 移动语义的定义:通俗的讲是对右值进行资源的窃取,而不是复制;移动构造、移动赋值是移动语义的具体实现。
namespace ZMH
{
	class string
	{
	public:
		typedef char* iterator;
		typedef const char* const_iterator;
		iterator begin()
		{
			return _str;
		}
		iterator end()
		{
			return _str + _size;
		}
		const_iterator begin() const
		{
			return _str;
		}
		const_iterator end() const
		{
			return _str + _size;
		}
		string(const char* str = "")
			:_size(strlen(str))
			, _capacity(_size)
		{
			cout << "string(char* str)-构造" << endl;
			_str = new char[_capacity + 1];
			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)
		{
			cout << "string(const string& s) -- 拷贝构造" << endl;
			reserve(s._capacity);
			for (auto ch : s)
			{
				push_back(ch);
			}
		}
		// 移动构造
		string(string&& s)
		{
			cout << "string(string&& s) -- 移动构造" << endl;
			swap(s);
		}
		string& operator=(const string& s)
		{
			cout << "string& operator=(const string& s) -- 拷⻉赋值" << endl;
			if (this != &s)
			{
				_str[0] = '\0';
				_size = 0;
				reserve(s._capacity);
				for (auto ch : s)
				{
					push_back(ch);
				}
			}
			return*this;
		}
		// 移动赋值
		string& operator=(string&& s)
		{
			cout << "string& operator=(string&& s) -- 移动赋值" << endl;
			swap(s);
			return *this;
		}
		~string()
		{
			//cout << "~string() -- 析构" << endl;
			delete[] _str;
			_str = nullptr;
		}
		char& operator[](size_t pos)
		{
			assert(pos < _size);
			return _str[pos];
		}
		void reserve(size_t n)
		{
			if (n > _capacity)
			{
				char* tmp = new char[n + 1];
				if (_str)
				{
					strcpy(tmp, _str);
					delete[] _str;
				}
				_str = tmp;
				_capacity = n;
			}
		}
		void push_back(char ch)
		{
			if (_size >= _capacity)
			{
				size_t newcapacity = _capacity == 0 ? 4 : _capacity *
					2;
				reserve(newcapacity);
			}
			_str[_size] = ch;
			++_size;
			_str[_size] = '\0';
		}
		string& operator+=(char ch)
		{
			push_back(ch);
			return *this;
		}
		const char* c_str() const
		{
			return _str;
		}
		size_t size() const
		{
			return _size;
		}
	private:
		char* _str = nullptr;
		size_t _size = 0;
		size_t _capacity = 0;
	};
} 

int main()
{
	ZMH::string s1("xxxxx");
	ZMH::string s2 = s1;
	ZMH::string s3 = ZMH::string("yyyyy");
	ZMH::string s4 = "yyyyy";
	ZMH::string s5 = move(s1);
	cout << "******************************" << endl;
	return 0;
}

三、移动语义如何完美解决传值返回的问题

  • 回到本篇的问题引入:无论左值还是右值引用,都存存在一个问题,那就是不能作函数返回局部对象的引用(因为局部对象出了作用域就会销毁)
  • 问题探究所要用的测试代码:
namespace ZMH
{
	class string
	{
	public:
		typedef char* iterator;
		typedef const char* const_iterator;
		iterator begin()
		{
			return _str;
		}
		iterator end()
		{
			return _str + _size;
		}
		const_iterator begin() const
		{
			return _str;
		}
		const_iterator end() const
		{
			return _str + _size;
		}
		string(const char* str = "")
			:_size(strlen(str))
			, _capacity(_size)
		{
			cout << "string(char* str)-构造" << endl;
			_str = new char[_capacity + 1];
			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)
		{
			cout << "string(const string& s) -- 拷贝构造" << endl;
			reserve(s._capacity);
			for (auto ch : s)
			{
				push_back(ch);
			}
		}
		string(string&& s)
		{
			cout << "string(string&& s) -- 移动构造" << endl;
			swap(s);
		}
		string& operator=(const string& s)
		{
			cout << "string& operator=(const string& s) -- 拷⻉赋值" << endl;
			if (this != &s)
			{
				_str[0] = '\0';
				_size = 0;
				reserve(s._capacity);
				for (auto ch : s)
				{
					push_back(ch);
				}
			}
			return*this;
		}
		string& operator=(string&& s)
		{
			cout << "string& operator=(string&& s) -- 移动赋值" << endl;
			swap(s);
			return *this;
		}
		~string()
		{
			delete[] _str;
			_str = nullptr;
		}
		char& operator[](size_t pos)
		{
			assert(pos < _size);
			return _str[pos];
		}
		void reserve(size_t n)
		{
			if (n > _capacity)
			{
				char* tmp = new char[n + 1];
				if (_str)
				{
					strcpy(tmp, _str);
					delete[] _str;
				}
				_str = tmp;
				_capacity = n;
			}
		}
		void push_back(char ch)
		{
			if (_size >= _capacity)
			{
				size_t newcapacity = _capacity == 0 ? 4 : _capacity *
					2;
				reserve(newcapacity);
			}
			_str[_size] = ch;
			++_size;
			_str[_size] = '\0';
		}
		string& operator+=(char ch)
		{
			push_back(ch);
			return *this;
		}
		const char* c_str() const
		{
			return _str;
		}
		size_t size() const
		{
			return _size;
		}
	private:
		char* _str = nullptr;
		size_t _size = 0;
		size_t _capacity = 0;
	};
} 

class Solution {
public:
	ZMH::string addStrings(ZMH::string num1, ZMH::string num2)
	{
		string str;
		int end1 = num1.size() - 1, end2 = num2.size() - 1;
		int next = 0;
		while (end1 >= 0 || end2 >= 0)
		{
			int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;
			int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;
			int ret = val1 + val2 + next;
			next = ret / 10;
			ret = ret % 10;
			str += ('0' + ret);
		}
		if (next == 1)
			str += '1';
		reverse(str.begin(), str.end());
		return str.c_str();
	}
};

int main()
{
	ZMH::string ret = Solution().addStrings("1123", "456");
	return 0;
}
2.解决方案二:编译器优化
  • 编译器的优化效果,如下所展示(其最终代码的运行结果,竟然和传统解法不谋而合,我个人感觉可能是蓄谋为之)

3.解决方案三:移动构造与移动语义
  • 使用的移动构造与移动语义,返回局部对象的时候调用的移动构造(对资源进行窃取),所以两次移动构造的销毁是对象在栈上的内存占比(拿string类来讲,不进行任何编译器的优化,返回一个string类,仅消耗了16 * 2的字节拷贝的性能)
  • 所以移动构造与移动语义在编译器不优化的前提下,也是解决了象拷贝次数过多的问题(因为浅拷贝的代价太小了)

;