右值引用和移动语义 ---- c++11

文章目录:

    • 左值?左值引用?
    • 右值?右值引用?
    • 左值引用与右值引用比较
    • 右值引用的使用场景和意义
    • 左值引用的使用场景和意义
    • 右值引用和移动语义
    • 右值引用引用左值
    • 完美转发
    • 完美转发实际中的使用场景

在这里插入图片描述
c++ 是一种通用编程语言,旨在为资源受限的大型系统提高性能和效率。随着时间的推移,该语言得到了扩展和改进,并定期发布新的标准。其中的 c++11 标准引入了右值引用和移动语义等特性提供了语言的性能。

左值?左值引用?

在 c++ 中,左值(lvalue)是指可以标识一个内存位置的表达式(如:变量名或解引用的指针)或对象。左值是可以放在赋值运算符左边的表达式,可以对其取地址。定义时 const 修饰符后的左值,不能给它赋值,但是可以取其地址。

左值引用(lvalue reference)是指对左值的引用。它通过使用 & 符号来声明,并且只能绑定到左值。左值引用允许对绑定的左值进行修改,并且可以用作函数参数或返回类型。通过左值引用,可以创建一个别名,即对某个左值的引用,通过这个别名可以对原对象进行操作。

左值和左值引用示例:


int main()
{
	// 左值的示例
	int x = 7;               // x 是一个左值
	int* p = &x;             // p 是一个左值
	int arr[7];              // arr 是一个数组,也是左值
	int result = x + arr[0]; // x 和 arr[0] 都是左值

	// 左值引用的示例
	int y = 5;
	int& ref = y;            // ref 是 y 的左值引用,即对 y 的引用
	ref = 7;                 // 修改 ref 的值也会修改 y 的值
	int& ref2 = ref;         // ref2 是对 ref 的左值引用

	// const左值引用 -> 通过const左值引用只能读取z的值,不能修改它
	int z = 9;
	const int& ret = z;      // ref 是 z 的 const 左值引用,即对 z 的常量引用
	int m = ret;             // 通过 const 左值引用可以将 z 的值赋值给其它变量
	return 0;
}

左值引用在函数参数传递中的应用:

void swap(int& x, int& y)
{
	int tmp = x;
	x = y;
	y = tmp;
}

int main()
{
	int x = 2, y = 4;
	swap(x, y); // 传入x,y的左值引用
	cout << "x = " << x << "  y = " << y << endl;
	return 0;
}

右值?右值引用?

右值是一个临时的、即将被销毁的表达式或对象。如:字面量常量、临时对象、表达式返回值、函数返回值(这个不能是左值引用返回)等。右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边,且右值不能取地址。

右值引用是对右值的引用。它是 c++11 引入的一种新的引用类型,用于表示对右值的引用。右值引用通过 && 来进行声明。

右值和右值引用示例:

int main()
{
	// 右值的示例
	int a = 5;                                // 5 是一个右值
	int b = a + 7;                            // a + 7 是一个右值表达式
	std::string str = "rvalue";               // "rvalue" 是一个右值字符串

	// 右值引用示例
	int&& rref1 = 5;                          // rref1 是对右值 5 的右值引用
	int&& rref2 = a + 7;                      // rref2 是对右值 a + 7 的右值引用
	std::string&& rref3 = "rvalue reference"; // rref3 是对右值字符串字面常量的右值引用
	return 0;
}

说明:

  • 右值引用是一种新的引用类型,它可以绑定到右值,并且允许我们通过移动语义来避免不必要的内存分配和拷贝操作。与左值引用不同的是,右值引用并不会改变绑定对象的存储方式。因此,不能将右值转换为左值。
  • 常量右值引用可以保证被引用的对象不会被修改,但是不能保证该对象的生命周期。右值引用一个重要的应用是实现移动语义,它可以提高程序的性能和效率。

左值引用与右值引用比较

左值引用能否引用右值?

  • 左值引用不能引用右值,因为左值引用通常用于修改数据,而右值是临时的、不可修改的值。
  • 但是,const 左值引用即可引用左值,又可以引用右值,并确保对被引用对象的修改是禁止的。

const 左值引用既可引用也可引用右值的示例:

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

int main()
{
	string s("lvalue");
	func(s);

	func("rvalue");

	// 左值引用只能引用左值,不能引用右值
	int a = 10;
	int& ra1 = a;  // ra1为a的别名
	// int& ra2 = 10; // 编译出错,因为左值不能引用右值

	// const左值引用既可以引用左值,又可以引用右值
	const int& ra3 = a;
	const int& ra4 = 10;
	return 0;
}

通过使用 const 左值引用,可以实现通用的函数接口,接受不同类型的参数(左值和右值),同时保证了参数的安全性和不可修改性。

右值引用能否引用左值?

  • 右值引用只能引用右值,不能引用左值。
  • 但是右值引用可以引用 move 之后的左值。

move 函数是 c++11 标准引入函数。它用于将左值转换为右值,从而允许将资源的所有权从一个对象转到另一个对象。示例:

#include
#include
using namespace std;

int main()
{
	// 右值引用只能引用右值,不能引用左值
	int&& r1 = 10;

	// error C2440: “初始化”: 无法从“int”转换为“int &&”
	// message : 无法将左值绑定到右值引用
	int a = 10;
	int&& r2 = a; // error

	// 右值引用可以引用move之后的左值
	int&& r3 = std::move(a);
	return 0;
}

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

虽然 const 左值引用既能接收左值,也能接收右值,但左值引用还是存在短板,c++11 中提出的右值引用就是用来解决左值引用的短板的。

为了能够理解这个问题,接下来我们使用一个深拷贝的类(简化的 string 类)。类中实现了一些基本的成员函数,拷贝构造和赋值,支持移动语义。如下:

#pragma once
#include
#include
using namespace std;

namespace hyr
{
	class string
	{
	public:
		typedef char* iterator;
		iterator begin()
		{
			return _str;
		}

		iterator end()
		{
			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);
		}

		// s1.swap(s2)
		void swap(string& s)
		{
			::swap(_str, s._str);
			::swap(_size, s._size);
			::swap(_capacity, s._capacity);
		}

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

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

			string tmp(s._str);
			swap(tmp);
		}

		// 移动赋值
		string& operator=(string&& s)
		{
			cout << "string& operator=(string&& s) -- 资源转移" << endl;
			swap(s);

			return *this;
		}

		// 赋值运算符重载函数
		string& operator=(const string& s)
		{
			cout << "string& operator=(const string& s) -- 深拷贝" << endl;
			string tmp(s);
			swap(tmp);

			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];
				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)
		string& operator+=(char ch)
		{
			push_back(ch);
			return *this;
		}

		const char* c_str() const
		{
			return _str;
		}
	private:
		char* _str;
		size_t _size;
		size_t _capacity; // 不包含最后做标识的\0
	};
}

接下来进行测试一下:

int main()
{
	hyr::string s1("lvalue");
	hyr::string s2(s1);       // string(const string& s) -- 深拷贝 -> 调用深拷贝函数进行拷贝构造

	hyr::string s3(move(s1)); // string(string && s) --资源转移  -> 调用移动构造函数构造s3对象
	
	return 0;
}

上面的两个函数(移动构造,移动赋值)是通过移动语义来提高对象的性能。通过移动资源而不是复制资源,可以避免不必要的内存拷贝和动态内存分配,从而提高程序的效率。移动构造函数和移动赋值运算符通常与临时对象和右值引用一起使用,可以有效地管理资源并减少资源的拷贝和销毁操作,从而提高性能。

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

左值引用有以下使用场景:

1.参数传递

使用左值引用作为函数参数可以提高效率,避免了对象的拷贝。当函数需要修改传入的对象时,使用左值引用可以直接修改原始对象而不需要创建副本。如下:

void modifyValue(int& value)
{
	value = 7;
}

int main()
{
	int a = 3;
	modifyValue(a);
	return 0;
}

2.函数返回值

在某些场景下,函数返回一个对象时,可以使用左值引用来优化性能。如下:

// 拼接字符串,使用引用返回
string& concatenateStrings(string& str1, const string& str2)
{
	str1 += str2;
	return str1;
}

int main()
{
	string s1 = "concatenate";
	const string s2 = "string";
	string& result = concatenateStrings(s1, s2);
	return 0;
}

左值引用的主要使用场景是在函数参数传递返回值优化中,可以提高效率并避免不必要的对象拷贝。

左值引用的短板

当函数返回对象是一个局部变量,出了函数作用域就不存在了,这样的情况就不能使用左值引用返回,只能使用传值返回。

如下,当模拟实现一个 int 版本的 to_string 函数,该函数就不可以使用左值引用返回,因为 to_string 返回的是一个局部变量。这里只能使用传值返回,传值返回会导致至少一次拷贝构造(旧一点的编译器可能是2次拷贝构造):

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

		hyr::string str;
		while (value > 0)
		{
			int x = value % 10;
			value /= 10;

			str += ('0' + x);
		}

		if (flag == false)
		{
			str += '-';
		}

		std::reverse(str.begin(), str.end());
		return str;
	}
}

此时调用 to_string 函数返回时,就一定会调用 string 的拷贝构造函数:

int main()
{
	hyr::string s = hyr::to_string(12345);
	return 0;
}

右值引用和移动语义 ---- c++11_第1张图片

c++11 提出右值引用就是为了解决左值引用这个短板的。

右值引用和移动语义

右值引用和移动语义就是通过给模拟实现的 string 类增加移动构造和移动赋值来解决上述问题。

移动语义

移动构造(Move Constructor)是一种特殊的构造函数,它本质是将参数右值的资源窃取过来,占为己有,这样就避免了深拷贝,所以它叫做移动构造,即窃取别人的资源来构造自己。

通过移动构造,可以有效地利用已有对象的资源,而无需进行额外的数据复制。这对于管理动态分配的内存、大型数据结构或其它资源密集型对象都特别有用。移动构造的实现通常涉及将源对象的指针或句柄移交给目标对象,并将源对象的资源真正置为空或标记无效,以避免二次释放或资源泄漏。

在当前模拟实现的 string 类中新增一个移动构造函数,该函数要做的就是调用 swap 函数将传入的右值的数据窃取过来,string 类移动构造的代码如下:

namespace hyr
{
	class string
	{
	public:
		// s1.swap(s2)
		void swap(string& s)
		{
			::swap(_str, s._str);
			::swap(_size, s._size);
			::swap(_capacity, s._capacity);
		}

		// 移动构造
		string(string&& s)
			:_str(nullptr)
			, _size(0)
			, _capacity(0)
		{
			cout << "string(string&& s) -- 资源转移" << endl;
			swap(s);
		}
	private:
		char* _str;
		size_t _size;
		size_t _capacity; 
	};
}

说明:

  • string 类中没有增加移动构造之前,因为拷贝构造采用的是 const 左值引用接收参数,因此无论拷贝构造对象时传入的左值还是右值,都会调用拷贝构造函数。
  • 当增加移动构造后,由于移动构造采用的是右值引用接收参数,因此,若拷贝构造对象时传入的是右值,那么就会调用移动构造函数。
  • string 的拷贝构造函数是深拷贝,而移动构造函数中调用 swap 函数进行了资源转移,因此调用移动构造的代价比调用拷贝构造的代价小。

给 string 类增加移动构造函数后,对于返回局部的 string 对象的函数,在返回 string 对象时就会调用移动构造进行资源的移动,而不需要调用拷贝构造函数,从而提高性能。如下所示:

右值引用和移动语义 ---- c++11_第2张图片

虽然 to_string 函数中返回的局部 string 对象是一个左值,但是该 string 对象在当前函数调用结束之后就会被销毁,这种即将被销毁的值就叫做 “将亡值” 。“将亡值” 指的是即将销毁的对象,包括局部变量和匿名对象等。由于将亡值即将被销毁,因此可以将其资源转移给别的对象使用,而右值引用和移动构造函数正是用来实现这一目的的。编译器在识别将亡值时会将其识别为右值,从而匹配到参数类型和右值引用的移动构造函数,实现资源转移。

移动赋值

移动赋值运算符(Move Assignment Operator)是一个赋值运算符的重载函数,其参数为右值引用类型(&&),用于实现资源的移动赋值操作。移动赋值运算符的目的是将传入的右值对象的资源窃取过来,占为己有,避免进行深拷贝操作,从而提高效率,一种常见的实现方式是通过调用 swap 函数来实现资源的交换。

以下是在 hyr::string 类中增加移动赋值函数并使用 swap() 函数实现资源的转移示例:

namespace hyr {
    class string {
    public:
        // ... 其他成员函数的实现 ...

        // 移动赋值函数
        string& operator=(string&& s) {
            cout << "string::operator=(string&& other) - 资源转移" << endl;
            if (this != &s) {
                // 通过调用 swap() 函数将资源转移给当前对象
                swap(s);
            }
            return *this;
        }

        // 交换函数
        void swap(string& s)
        {
            ::swap(_str, s._str);
            ::swap(_size, s._size);
            ::swap(_capacity, s._capacity);
        }

        // ... 其他成员变量 ...

    private:
        char* _str;
        size_t _size;
        size_t _capacity;
    };
}

给 string 类增加移动赋值之后,使用一个已定义的 string 对象去接受 to_string 函数的返回值i,此时也不会存在深拷贝,如下:

右值引用和移动语义 ---- c++11_第3张图片

这里,我们看到调用了一次移动构造和一次移动赋值。因为如果是一个已经存在的对象接收,编译器就没有办法优化了。hyr::string 函数中会先用 str(to_string 里面的内容) 构造生成一个临时对象。在这里,编译器将 str 识别为右值,调用了移动构造。然后将这个临时对象作为 hyr::to_string 函数调用的返回值赋值给 ret,这里调用的是移动赋值。

说明:若没有实现移动赋值函数,该代码的执行顺序应该是先调用一次拷贝构造,再调用一次原有的 operator= 函数,但由于原有的 operator= 函数实现时复用了拷贝构造函数,因此代码执行之后会多调用一次拷贝构造函数。

如下是没有实现移动赋值和移动构造的运行结果:

右值引用和移动语义 ---- c++11_第4张图片

总结:增加了移动构造函数和移动赋值函数之后,可以通过移动语义实现资源的转移,避免了不必要的拷贝操作,减少动态内存分配的次数,并于现有的移动语义优化算法和容器进行配合,从而提高代码的效率和性能。这对于大型对象,资源管理类以及涉及频繁对象传递的情况非常重要。

STL中的容器也增加了移动构造和移动赋值:

string 类的移动构造函数:

右值引用和移动语义 ---- c++11_第5张图片

string 类的移动赋值函数:

右值引用和移动语义 ---- c++11_第6张图片

右值引用引用左值

按照语法,右值引用在声明时只能引用右值(临时对象、将亡值等),而不直接引用左值。但右值引用一定不能引用左值吗?然而,在某些情况下,确实需要使用右值引用去引用一个左值,以实现移动语义。为了实现该需求,c++11 引入了 std::move() 函数,位于头文件 中。std::move()函数不搬移任何东西,唯一的功能就是将一个左值强制转换为右值引用,从而实现移动语义。

使用 std::move() 函数将一个左值转换为右值引用后,原始对象的状态将被标记为可移动的(moved from),即它的值可能已被修改或资源已被转换。因此,在使用 std::move() 后,原始对象的状态可能是未定义的,因此需要谨慎操作。

std::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 使用示例:

int main()
{
	hyr::string s1("Test uses an rvalue referencte to reference an lvalue");
	// 这里s1是左值,调用拷贝构造函数
	hyr::string s2(s1);

	// 这里将s1进行move之后,会被当作右值,调用移动构造函数
	// 需要注意,一般不是这样用的,因为我们会发现s1的资源被转移给了s3,s1被偷家了
	hyr::string s3(std::move(s1));
	return 0;
}

STL容器插入接口函数也增加了右值引用版本

c++11 标准中,STL 中的容器不仅增加了移动构造和移动赋值,STL容器的插入接口也增加了右值引用版本:

在这里插入图片描述

若 vector 容器中储存的是 string 对象,则调用 vector 的 push_back 接口插入数据时,会有以下几种插入方式:

int main()
{
	vector<hyr::string> vs;
	hyr::string s1("111");
	// 调用拷贝构造
	vs.push_back(s1);

	// 调用移动构造
	vs.push_back("222");
	vs.push_back(hyr::string("333"));
	vs.push_back(std::move(s1));
	return 0;
}

对于 c++11 之前的版本,vector 容器的 push_back 只有一个左值引用版本,因此在插入数据时,需要进行深拷贝。而在 c++11 标准中,vector 容器的 push_back 接口提供了右值引用版本,可以避免不必要的拷贝操作,提高了效率。当传入的数据是一个右值时,可以通过移动构造进行资源的转移,避免了深拷贝的开销。因此,对于 string 这种可以移动的类型,在插入元素时尽量使用右值引用版本的 push_back 接口,可以获得更好的性能。

完美转发

c++ 中的完美转发(Perfect Forwarding)是指在模板中将一个函数参数传递给另一个函数,并保留原始参数的左值/右值属性以及 const 和 volatile 属性的技术。完美转发通常用于实现泛型函数或类的转发机制,让原始的函数参数在传递过程中能够保持其值类别和常量属性,从而减少不必要的拷贝和副本,并提高代码的效率。

模板中的 && 不代表右值引用,而是万能引用,其既能接收左值又能接收右值:

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

当模板函数的参数类型为 T&& 时,编译器会根据传入的实参的值类别(左值或右值)来确定 T 的类型。若传入的参数是一个左值,则 T 被推导为左值引用类型;若传入的参数是一个右值,则 T 被推导为右值引用类型。

下面代码重载了四个 func 函数,这四个函数的参数类型依次是:左值引用、const 左值引用、右值引用、const 右值引用。在 main 函数中调用 PerfectForward 函数,PerfectForward 函数用于再次调用 func 函数,如下所示:

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

// 模板中的 && 不代表右值引用,而是万能引用,其既能接收左值又能接收右值
// 模板中的万能引用只是提供了能够同时接收左值引用和右值引用的能力
// 但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化为了左值
// 我们希望能够在传递过程中保持它的左值或右值的属性,就需要使用完美转发
template<class T>
void PerfectForward(T&& t)
{
	func(t);
}

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

	const int b = 20;
	PerfectForward(b);		    	// const 左值
	PerfectForward(std::move(b));   // const 右值
	return 0;
}

运行结果如下:

右值引用和移动语义 ---- c++11_第7张图片

我们希望传入的是什么类型的值就匹配对应的函数,运行的结果和预期不太一样。在上述代码中调用 PerfectForward 函数时传入的左值和右值,最后都匹配到了左值引用版本的 func 函数,PerfectForward 函数中传入的 const 左值和 const 右值,都匹配到了 const 左值引用版本的 func 函数。出现该现象的原因是:右值被引用之后会被储存到特定的位置,而这个储存位置在函数参数中是可以被取地址和修改的,因此实际上已经退化为左值了。

如果想要在传递右值时保持其右值属性,即保持它的临时性质和不能取地址的特定,就需要使用完美转发。完美转发可以将函数原封不动地转发给另一个函数,这样就可以保持参数的原有属性。通过使用 std::forward 函数,可以根据参数的类型来选择调用哪一个版本的函数,从而实现参数的完美转发。

std::forward 完美转发在传参的过程中保留对象原生类型的属性:

// std::forward(t); 在传参的过程中保持了t的原生类型属性
template<class T>
void PerfectForward(T&& t)
{
	func(std::forward<T>(t));
}

运行结果如下:

右值引用和移动语义 ---- c++11_第8张图片

注意:在使用完美转发时,需要谨慎使用,避免出现悬垂引用(dangling reference)的问题。悬垂引用是指引用了一个已经被销毁的对象的引用,这回导致未定义的行为。

完美转发实际中的使用场景

下面模拟实现了一个简单的 list 类,类中分别提供了左值引用版本和右值引用版本的 push_back 和 insert 函数:

namespace hyr
{
	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;
		}
		
		// 左值引用版本
		void push_back(const T& x)
		{
			insert(_head, x);
		}
		
		// 右值引用版本
		void push_back(T&& x)
		{
			insert(_head, std::forward<T>(x)); 
		}
		
		// 左值引用版本
		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;
		}
		
		// 右值引用版本
		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 对象,该对象中存储的数据是之前定义的 string 类,调用 push_back 函数向对象中插入数据,在函数中分别传入了左值和右值版本的数据,如下:

int main()
{
	hyr::list<hyr::string> lt;
	hyr::string s("111"); 
	lt.push_back(s);      // 调用左值引用版本的push_back

	lt.push_back("222");  // 调用右值引用版本的push_back
	return 0;
}

在调用右值引用版本的 push_back 函数时,会调用 string 类的移动赋值进行资源的移动,而为了保持原有的右值属性,需要在函数调用时使用完美转发,保持其右值属性不变。这样,在调用 insert 函数时,就可以调用右值引用版本的 insert 函数,从而在右值引用版本的 insert 函数中用右值给新结点赋值时也需要用到完美转发,保持右值属性不变,最终调用移动赋值函数进行资源的移动。即在 STL 库中,也会使用完美转发来保持右值的属性。

你可能感兴趣的:(c++,c++,算法)