【C++庖丁解牛】C++11---右值引用和移动语义

🍁你好,我是 RO-BERRY
📗 致力于C、C++、数据结构、TCP/IP、数据库等等一系列知识
🎄感谢你的陪伴与支持 ,故事既有了开头,就要画上一个完美的句号,让我们一起加油


目录

  • 1 左值引用和右值引用
  • 2 左值引用与右值引用比较
  • 3 右值引用使用场景和意义
  • 4 概念总结

    1 左值引用和右值引用

    传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。

    什么是左值?什么是左值引用?

    左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址,一般可以对它赋值,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边。定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名。

    int main()
    {// 以下的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;
    	return 0;
    }
    

    什么是右值?什么是右值引用?

    右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引用返回)等等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能取地址。右值引用就是对右值的引用,给右值取别名。

    int main()
    {double x = 1.1, y = 2.2;
    	
    	// 以下几个都是常见的右值
    	10;      //常量
    	x + y;   //加减乘除返回的结果
    	fmin(x, y);  //函数传值返回值
    	
    	// 以下几个都是对右值的右值引用
    	int&& rr1 = 10;
    	double&& rr2 = x + y;
    	double&& rr3 = fmin(x, y);
    	
    	// 这里编译会报错:error C2106: “=”: 左操作数必须为左值
    	10 = 1;
    	x + y = 1;
    	fmin(x, y) = 1;
    	return 0;
    }
    

    需要注意的是右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址

    也就是说例如:不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。如果不想rr1被修改,可以用const int&& rr1 去引用,是不是感觉很神奇,这个了解一下实际中右值引用的使用场景并不在于此,这个特性也不重要。

    【总结】

    • 语法上:引用都是别名,不开空间,左值引用是给左值取别名,右值引用是给右值取别名
    • 底层:引用是用指针实现的,左值引用是存当前左值的地址。右值引用是把当前右值拷贝到栈上的一个临时空间,存储这个临时空间的地址

    2 左值引用与右值引用比较

    左值引用总结:

    1. 左值引用只能引用左值,不能引用右值。
    2. 但是const左值引用既可引用左值,也可引用右值
    int main()
    {// 左值引用只能引用左值,不能引用右值。
    	int a = 10;
    	int& ra1 = a;   // ra为a的别名
    	//int& ra2 = 10;   // 编译失败,因为10是右值
    	// const左值引用既可引用左值,也可引用右值。
    	const int& ra3 = 10;
    	const int& ra4 = a;
    	return 0;
    }
    

    右值引用总结:

    1. 右值引用只能右值,不能引用左值。
    2. 但是右值引用可以move以后的左值。

    move是C++11引入的一个特性,用于实现对象的移动语义。它通过将资源的所有权从一个对象转移到另一个对象,避免了不必要的拷贝操作,提高了程序的性能。

    在C++中,对象的拷贝通常会涉及到深拷贝和浅拷贝。深拷贝会复制对象的所有成员变量,包括指针指向的堆内存;而浅拷贝只是简单地复制指针,导致多个对象共享同一块内存。当对象较大或者包含大量资源时,频繁进行拷贝操作会带来性能上的损耗。

    而move语义则可以解决这个问题。通过使用移动构造函数和移动赋值运算符,可以将一个对象的资源所有权转移到另一个对象,而不需要进行深拷贝。移动操作只是简单地将指针指向资源的所有权转移给目标对象,并将源对象置为无效状态。

    使用move语义可以显著提高程序的性能,特别是在处理大型数据结构或者频繁进行资源管理的情况下。同时,也可以避免不必要的内存分配和释放操作,减少内存的开销。

    需要注意的是,使用move语义时需要确保源对象在移动后不再使用,否则可能会导致程序的错误行为。

    int main()
    {// 右值引用只能右值,不能引用左值。
    	int&& r1 = 10;
    	// error C2440: “初始化”: 无法从“int”转换为“int &&”
    	// message : 无法将左值绑定到右值引用
    	int a = 10;
    	int&& r2 = a;
    	// 右值引用可以引用move以后的左值
    	int&& r3 = std::move(a);
    	return 0;
    }
    

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

    前面我们可以看到左值引用既可以引用左值和又可以引用右值,那为什么C++11还要提出右值引用呢?是不是化蛇添足呢?下面我们来看看左值引用的短板,右值引用是如何补齐这个短板的!

    这是之前自己模拟实现的string

    namespace mystring
    {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(const string& s)
    			:_str(nullptr)
    		{cout << "string(const string& s) -- 深拷贝" << endl;
    			string tmp(s._str);
    			swap(tmp);
    		}
    		// 赋值重载
    		string& operator=(const string& s)
    		{cout << "string& operator=(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
    	};
    }
    

    左值引用的使用场景:做参数和做返回值都可以提高效率。

    void func1(mystring::string s)  //我们没有使用引用的时候是会进行拷贝的
    {}
    void func2(const mystring::string& s)
    {}
    int main()
    {mystring::string s1("hello world");
    	// func1和func2的调用我们可以看到左值引用做参数减少了拷贝,提高效率的使用场景和价值
    	func1(s1);
    	func2(s1);
    	// string operator+=(char ch) 传值返回存在深拷贝
    	// string& operator+=(char ch) 传左值引用没有拷贝提高了效率
    	s1 += '!';
    	return 0;
    }
    

    左值引用解决了什么问题:

    1. 传参的拷贝全解决了
    2. 传返回值的问题解决了一部分

    左值引用的短板:

    • 但是当函数返回对象是一个局部变量,出了函数作用域就不存在了,就不能使用左值引用返回,只能传值返回。
    • 例如:mystring::string to_string(int value)函数中可以看到,这里只能使用传值返回,传值返回会导致至少1次拷贝构造(如果是一些旧一点的编译器可能是两次拷贝构造)。

      我们添加一个函数

      namespace mystring
      {mystring::string to_string(int value)
      	{bool flag = true;
      		if (value < 0)
      		{flag = false;
      			value = 0 - value;
      		}
      		mystring::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;
      	}
      }
      
      • 这段代码是一个自定义的字符串类mystring中的成员函数to_string,它接受一个整数参数value,并将其转换为字符串形式返回。
      • 函数首先定义了一个布尔变量flag,并将其初始化为true。然后判断value是否小于0,如果是,则将flag设置为false,并将value取绝对值。接下来创建一个mystring对象str,用于保存转换后的字符串。
      • 接下来是一个循环,循环条件是value大于0。在每次循环中,取value的个位数x,并将value除以10,更新value的值。然后将x转换为字符形式,并将其添加到str中。
      • 如果flag为false,说明原始的value是负数,此时在str末尾添加一个负号。
      • 最后,使用std::reverse函数将str中的字符顺序反转,然后将其作为结果返回。
      int main()
      {// 在mystring::string to_string(int value)函数中可以看到,这里只能使用传值返回,传值返回会导致至少1次拷贝构造(如果是一些旧一点的编译器可能是两次拷贝构造)。
      	 mystring::string ret1 = mystring::to_string(1234);
      	 mystring::string ret2 = mystring::to_string(-1234);
      	 return 0;
      }
      

      在这里的问题就是我们右边的 mystring::to_string(1234);所构造的在出函数的时候就已经自动调用析构函数销毁了,所以我们右边返回的对象是一个销毁的对象。

      右值引用在这里不能直接解决问题,但是右值有一个问题就是生命周期比较短,我们就需要添加移动构造,添加了移动构造后,我们左值走的是拷贝构造函数,右值就会调用移动构造,右值通常都是临时对象,我们不做深拷贝,我们直接使用swap函数交换其资源,就不会出现拷贝自动析构的问题

      原调用

      // 拷贝构造
      		string(const string& s)
      			:_str(nullptr)
      		{cout << "string(const string& s) -- 深拷贝" << endl;
      			string tmp(s._str);
      			swap(tmp);
      		}
      		// 赋值重载
      		string& operator=(const string& s)
      		{cout << "string& operator=(string s) -- 深拷贝" << endl;
      			string tmp(s);
      			swap(tmp);
      			return *this;
      		}
      

      我们前面调用的是我们的拷贝构造函数,添加了这个函数后我们就会调用移动构造,移动构造本质是将参数右值的资源窃取过来,占为已有,那么就不用做深拷贝了,所以它叫做移动构造,就是窃取别人的资源来构造自己。

      移动构造以及移动赋值

      // 移动构造  --
      		string(string&& s)
      			:_str(nullptr)
      			, _size(0)
      			, _capacity(0)
      		{cout << "string(string&& s) -- 移动语义" << endl;
      			swap(s);
      		}
      		// 移动赋值
      		string& operator=(string&& s)
      		{cout << "string& operator=(string&& s) -- 移动语义" << endl;
      			swap(s);
      			return *this;
      		}
      

      再运行上面string::to_string的两个调用,我们会发现,这里没有调用深拷贝的拷贝构造,而是调用

      了移动构造,移动构造中没有新开空间,拷贝数据,所以效率提高了。

      也就是说,左值的时候就是深拷贝,进行老老实实的拷贝构造,而右值不做深拷贝,只是交换其资源。

      不仅仅有移动构造,还有移动赋值:

      在string::string类中增加移动赋值函数,再去调用string ::to_string(1234),不过这次是将

      string::to_string(1234)返回的右值对象赋值给ret1对象,这时调用的是移动构造。

      // 移动赋值
      string& operator=(string&& s)
      {cout << "string& operator=(string&& s) -- 移动语义" << endl;
      	swap(s);
      	return *this;
      }
      int main()
      {string ::string ret1;
      	ret1 = string ::to_string(1234);
      	return 0;
      }
      // 运行结果:
      // string(string&& s) -- 移动语义
      // string& operator=(string&& s) -- 移动语义
      

      这里运行后,我们看到调用了一次移动构造和一次移动赋值。因为如果是用一个已经存在的对象接收,编译器就没办法优化了。string ::to_string函数中会先用str生成构造生成一个临时对象,但是我们可以看到,编译器很聪明的在这里把str识别成了右值,调用了移动构造。然后在把这个临时对象做为string ::to_string函数调用的返回值赋值给ret1,这里调用的移动赋值。

      4 概念总结

      C++11正是通过引入右值引用来优化性能,具体来说是通过移动语义来避免无谓拷贝的问题,通过move语义来将临时生成的左值中的资源无代价的转移到另外一个对象中去,通过完美转发来解决不能

      按照参数实际类型来转发的问题(同时,完美转发获得的一个好处是可以实现移动语义)。

      1. 在C++11中所有的值必属于左值、右值两者之一,右值又可以细分为纯右值、将亡值。在C++11中可以取地址的有名字的就是左值,反之,不能取地址的、没有名字的就是右值(将亡值或纯右值)。举个例子,int a = b+c, a就是左值,其有变量名为a,通过&a可以获取该变量的地址;表达式b+c、函数int func()的返回值是右值,在其被赋值给某一变量前,我们不能通过变量名找到它,&(b+c)这样的操作则不会通过编译。
      2. C++11对C++98中的右值进行了扩充。在C++11中右值又分为纯右值(prvalue,Pure Rvalue)和将亡值(xvalueeXpiring Value)。其中纯右值的概念等同于我们在C++98标准中右值的概念,指的是临时变量和不跟对象关联的字面量值;将亡值则是C++11新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象(移为他用),比如返回右值引用T&&的函数返回值、std:move的返回值,或者转换为T&&的类型转换函数的返回值。将亡值可以理解为通过"盗取"其他变量内存空间的方式获取到的值。在确保其他变量不再被使用、或即将被销毁时,通过“盗取"的方式可以避免内存空间的释放和分配,能够延长变量值的生命期。
      3. 左值引用就是对一个左值进行引用的类型。右值引用就是对一个右值进行引用的类型,事实上,由于右值通常不具有名字,我们也只能通过引用的方式找到它的存在。右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。左值引用通常也不能绑定到右值,但常量左值引用是个"万能"的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。不过常量左值所引用的右值在它的"余生"中只能是只读的。相对地,非常量左值只能接受非常量左值对其进行初始化。
      4. 右值值引用通常不能绑定到任何的左值,要想绑定一个左值到右值引用,通常需要std:.move()将左值强制转换为右值。