【C++修炼之路】27.右值引用

在这里插入图片描述
每一个不曾起舞的日子都是对生命的辜负

前言

以下所要讲到的,以及右值引用的都是为了提高性能,这是其他语言所不具备的,而本文章就围绕了大量的场景将右值引用的细节分割并逐个击破。

一.左值引用和右值引用

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

1.1 左值与左值引用

既然要研究什么是右值引用,首先需要知道什么是左值引用。

难道说在赋值符号左边的就是左值,在赋值符号右边的就是右值?答案当然是否定的。

左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址+可以对它赋值,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边。 定义时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;
}

可以看出,不管有没有const修饰,即不管变量是否具有修改的权限,只有能够取它的地址,就是左值。

1.2 右值和右值引用

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

函数返回值指的是传值返回。之前提到过,在调用函数之后会销毁函数栈帧,会生成一个临时对象拷贝函数返回值,这个临时变量之所以有常性就是因为其是右值

  • 函数返回值就是右值!
int main()
{
	double x = 1.1, y = 2.2;//1.1和2.2都是右值

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

	//以下几个都是对右值的引用
	int&& rr1 = 10;
	double&& rr2 = x + y;
	double&& rr3 = fmin(x, y);

	//这里编译会报错:error c2016:因为符号左边不能为右值
	/*10 = 1;
	x + y = 1;
	fmin(x, y) = 1;*/

	return 0;
}

需要注意的是右值是不能够取地址的,但是给右值取别名后,会导致右值被存储到特定位置,就可以取到该位置的地址,也就是说:不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。如果不想rr1被修改,可以用const int&& rr1去引用。但实际中右值引用的使用场景并不在于此,这根特性也不重要。

1.3 左值引用与右值引用的比较

左值引用总结:

  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的作用,下面将会讲到,这里只知道即可。

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;

}

二.引用返回的意义

在了解右值引用之前,我们一直以来所提到的引用事实上都是左值引用,因此这个标题与其说是引用返回的意义,倒不如说成是左值引用返回的意义。

事实上,对于左值引用,最有意义的就是在函数传参以及传返回值时,通过左值引用可以减少拷贝,因为普通的变量实际上都是将传入的参数拷贝到函数参数上或者将返回值拷贝到一个临时变量中。即:

template<class T>
const T& func2(const T& x)
{
	// ...
	return x;
}

但天有不测风云,世事变化无常,对于返回值来说,如果是传入的参数作为返回值当然没有问题,因为出了函数栈帧之后该参数还在,静态变量也是如此;但如果在函数内部创建的变量作为返回值,即函数的局部变量,出了函数会被销毁,此时还能左值引用返回吗?

template<class T>
const T& func3(const T& x)
{
	T ret;
	// ...
	return ret;
}

image-20230307181009560

答案是否定的,因为出了函数ret所处的空间就被销毁了,因此这也是我们之前所学的引用即左引用尚未解决的问题场景。

对于这种场景,直接传值返回其实也没什么问题,拷贝的代价比较小。但如果返回的是一个空间消耗很大的变量呢?就拿之前学过的杨辉三角OJ来说,其返回的是vector<vector<int>>类型,不传引用返回,因其返回值造成的深拷贝会浪费很多的性能。还是要把杨辉三角的代码展示出来更好一些:

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;
}

这种实际上就是左值引用所处理不了的场景,就我们之前所学的,要想通过左值引用的方式不让函数返回值深拷贝,可以将函数改成输出型参数,即:

//解决方案:换成输出型参数
void generate(int numRows, vector<vector<int>>& vv) {
	
	vv.resize(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];
		}
	}

}

但这样用起来很挫,也很别扭,因此这种处理方式并不是我们想要的。因此为了补齐左值引用的最后一块短板,右值引用就派上用场了。

三.右值引用的作用

上面已经区分了什么是左值引用和右值引用,并且发现左值引用还有尚未解决的问题场景,为了补齐左值引用的最后一块短板,右值引用在实际场景中究竟有什么作用呢?下面就来看看。(注意:补齐左值引用的最后一块短板只是右值引用的价值之一。)

3.1 引出问题

右值引用如何补齐?难道说,直接像下面这样?image-20230307183149669

这样是直接会报错的,因此右值引用不是这样单用用的,它不能单独解决,还要配合其他的方法。

这就需要将我们之前实现过的string代码作为例子,不选择库中的string是为了能够更好的观察代码的实现,看看右值到底可以为这段代码增添什么作用:

3.2 string模拟实现的代码

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 = nullptr;
	size_t _size = 0;
	size_t _capacity = 0; // 不包含最后做标识的\0
};

3.3 右值引用如何补齐左值引用的短板

然后,我们在类外写这样的一个函数:to_string()

string to_string(int value)
{
    bool flag = true;
    if (value < 0)
    {
        flag = false;
        value = 0 - value;
    }
    cfy::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;//返回值是右值
}

int main()
{
    cfy::string ret = cfy::to_string(-1234);
}

image-20230307194408204

对于to_string的返回值,实际上会拷贝两次,即第一次是将str深拷贝给一个临时变量(比较小是临时变量,大就压到上一层栈帧中),第二次将这个临时变量拷贝给main函数中的ret,但编译器会将这两次优化成一次,即跳过这个临时变量,直接用返回值构造main中的ret。

当然,如果是下面的代码,就不能优化,因为连续的拷贝才能优化:(这是对VS系列而言,其他编译器或许很勇)

int main()
{
	cfy::string ret;
	ret = cfy::to_string(-1234);
	return 0;
}

image-20230307194542850

但即便是拷贝一次,对于数据大的对象及vector<vector<T>>类的仍然是一种负担,经过上面的描述,我们同样知道函数的返回值是右值,因此上述代码就可以重载一个右值引用的拷贝构造,即引用就不需要深拷贝了。增加一个:

string(const string&& s)
{
    cout << "string(const string& s) -- 右值引用" << endl;
}

因为我们知道函数的选取会根据参数类型临近的,这是我们之前就了解的。image-20230307200727473

move作用:将左值变成右值。 move后的值也就变成了将亡值。

但对于这段代码:

int main()
{
	cfy::string s1("hello world");//调用的const char*的拷贝构造
	cout << "s3: \n";
	cfy::string s3(s1);
	cout << "s4: \n";
	cfy::string s4(move(s1));
	return 0;
}

image-20230307201531682

s1通过字符串常量构造,字符串常量是右值,但是更贴近const char*,没有调用右值引用的构造,对于s4通过s1move之后变成的右值构造,就很好的调用了右值引用构造。

3.4 右值的划分

c++11中,将右值又详细的进行了划分:

  1. 纯右值(内置类型表达式的值)
  2. 将亡值(自定义类型表达式的值)

在构造函数中,重载一个这样的构造,我们称之为移动构造:

//移动构造
string(string&& s)
{
    cout << "string(const string& s) -- 移动构造" << endl;
    swap(s);
}

与正常的拷贝构造相比,这个并没有构造临时变量保存,因为我们知道,这个函数传入的参数一定是右值,并且是将亡值,一个要亡了的值也没有保存的意义了,因此通过移动构造可以减少一次拷贝构造。

但需要注意的是,如果是被move调用的左值,这样之后就会被偷家:

image-20230307203828908

image-20230307203842552

image-20230307203906667

因此还是谨慎使用。


还需要强调的是,对于这里:image-20230307204533286

编译器如果不进行优化,就会str先进行拷贝构造给一个临时变量,这个临时变量再通过移动构造传给ret2,但由于编译器会默认进行优化,因此直接将str识别成右值,所以一旦有了移动构造,str就会自动识别成右值直接进行一次移动构造传给ret2。这个时候的成本就会大大降低,就不用进行深拷贝,直接进行资源转移。

3.5 解决赋值的问题

上面叙述中,我们演示了一个这样的代码:

int main()
{
	cfy::string ret;
	ret = cfy::to_string(-1234);
	return 0;
}

如果就只加了移动构造而言,看看现在是什么结果:image-20230308104010629

通过我的一番调试,我发现三条都是在第二行赋值时调用的,当我f11进入时,首先进入了to_string函数,直到调试到return str,我知道这里会再次调用移动构造,因此我再次按住f11,不出所料,跳到了移动构造string(string&& s)里,接下来就是一直按住f10,当返回到main中的原语句时,我再次按住f11,结果不出所料,他进入到了赋值重载string& operator=(const string& s)中,此时就是调用了深拷贝,打印两个深拷贝的原因实际上是因为有一个tmp的构造,而第一次的深拷贝实际上不是深拷贝,只是进入的一个标志,即实际上只进行了一次深拷贝。但知道了大致过程,发现仍然存在着深拷贝,因此问题并没有得到完全解决。我们也发现,调试会进入到赋值运算符重载中,那如果将赋值运算符重载函数再重载一个右值引用的版本——即移动赋值,是不是就可以解决了呢?那接下来就来看看:

// 移动赋值
string& operator=(string&& s)
{

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

image-20230308105654941

参数会自动筛选是右值还是左值,一旦是右值,那么由于其在使用之后不起作用,即作为将亡值来说,没必要保存,因此没有必要进行上面那样的深拷贝。可见通过移动构造+移动赋值的方式,就完美的补齐了左值引用的短板——传值返回的问题

四.再次总结左值引用右值引用

通过右值引用的作用的学习,我们可以发现的是,右值引用和左值引用减少拷贝的原理不太一样,即:

  1. 左值引用是取别名,直接起作用。
  2. 右值引用是通过实现移动构造和移动赋值,在拷贝的场景中,如果是右值(将亡值),则进行资源的转移,这是间接起作用。

在C++11的标准中,vector以及其他类型的容器的构造函数中,也都出现了新的函数接口:image-20230308110421498

image-20230308110454545

这里只贴了两种常见的容器,对于其他容器,也都有这一新接口。


那么库中的string是否也调用的移动构造呢,那就尝试一下:

int main()
{
	std::string s1("hello world");
	std::string s2(s1);
	std::string s3(move(s1));

	return 0;
}

image-20230308111623344

image-20230308111651324

这是当然的,学这么长时间岂是白学?

也可以看出,如果是const类型,不能进行移动构造,因为需要交换,交换就不能是const。同时也需要注意,左值不要轻易的move,否则会被偷掉。

五.右值引用价值之二

在第三标题右值引用的作用中,明确的说过补齐左值的最后一块短板只是右值引用的价值之一,那么价值之二就在这里介绍:

右值引用的价值之二:对于插入的一些右值数据,也可以减少拷贝。

对于我们之前学过的插入,由于不区分是左值还是右值,因此也就无法知道其是否进行了深拷贝,但是现在我们知道,只要是右值的将亡值,就不需要插入时再深拷贝一份,而是直接移动拷贝,如果没有移动拷贝,那就只能退而求其次考虑深拷贝了:

int main()
{
	list<cfy::string> lt;
	cfy::string s1("11111");
	lt.push_back(move(s1));
	lt.push_back(cfy::string("22222"));
	lt.push_back("33333");
	return 0;
}

将移动构造注释掉:就是深拷贝image-20230308143632365

添加移动构造: image-20230308143711259

只能使用我们自己的string才能观察出来,库中的string虽然也是调用移动构造,但是我们看不出来。

六.const修饰的右值引用

6.1 右值引用变量的属性

我们知道,左值引用可以修改,const修饰的左值引用不能修改。但是对于右值引用来说,右值本身就不能修改,为什么还要加上const呢?按照正常的理解,这没有任何意义啊!有了这样的疑惑,说明右值引用的学习还不止步于此……

我们先来看看经过const修饰的左值引用和右值引用:image-20230310132704376rr2有问题可以理解,但是rr1竟然没有问题,这就是二者的区别,也就引出了如下知识:

需要注意的是,右值是不能取地址的,但是给右值取别名也就是右值引用后,会导致右值被存储到特定的位置,且可以取到该位置的地址,也就是说例如:不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。如果不想rr1被修改,就可以用const int&& rr1去引用。

因此,在给右值引用时,rr1就有了左值的属性,因为其具备了地址并可以进行访问修改,但需要注意的是,修改的并不是字面量10,10是右值,而是被存储到特定位置的变量。因此,const右值的引用的作用和const左值的作用是一样的,因此也可以看出我们之前进行的移动构造,其中的swap实际上就相当于将右值引用的变量进行了交换,这就是因为右值引用的变量具有左值的属性

image-20230310134043339

int main()
{
	std::list<cfy::string> lt;
	cfy::string s1("11111");
	lt.push_back(std::move(s1));
	lt.push_back(cfy::string("22222"));
	lt.push_back("33333");
	return 0;
}

如果将移动构造和移动赋值注释掉,就会调用深拷贝:image-20230310140959151

添加上就优先调用移动构造:image-20230310141036251

6.2 实例演示

这是调用的std中的list,如果是自己写的list呢,就拿我们之前实现的list来说,如果在insert和构造时都重载上下面的移动构造,看看会发生什么:

list_node(T&& x)//构造
    :_next(nullptr)
        , _prev(nullptr)
        , _data(x)
    {}
void push_back(T&& x)
{
    insert(end(), x);
}
iterator insert(iterator pos, T&& x)
{
    node* newnode = new node(x);
    node* cur = pos._pnode;
    node* prev = cur->_prev;

    prev->_next = newnode;
    newnode->_prev = prev;
    cur->_prev = newnode;
    newnode->_next = cur;

    ++_size;
    return iterator(pos);
}

image-20230310142720608

这样调用的还是深拷贝,我们明明已经加上移动构造和移动拷贝了,为什么仍然调用深拷贝?这就是我们在第六个标题说到的,在传参数的过程中,右值引用的变量接收了右值,但他会继续传到另一个函数,此时就会因这个值具有左值属性而被当成左值,所以在匹配函数的时候调用的仍然是深拷贝,因此,我们在这些移动构造的函数还有要将参数通过move再次转化成右值。

list_node(T&& x)//构造
    :_next(nullptr)
        , _prev(nullptr)
        , _data(std::move(x))
    {}
void push_back(T&& x)
{
    insert(end(), std::move(x));
}
iterator insert(iterator pos, T&& x)
{
    node* newnode = new node(std::move(x));
    node* cur = pos._pnode;
    node* prev = cur->_prev;

    prev->_next = newnode;
    newnode->_prev = prev;
    cur->_prev = newnode;
    newnode->_next = cur;

    ++_size;
    return iterator(pos);
}

image-20230310143650870

这样就解决了左值属性的问题。

七.完美转发

7.1 万能引用

在一开始的学习中,我们就说到,没有move的左值不能被右值引用,也就是说下面这样是不对的:image-20230310152116908

但此时,为了减少繁琐,不区分左值右值,此时就可以用下面这种模板,对于左值和右值都可以进行匹配:image-20230310152351099

在C++primer中被称为引用折叠(传入的是左值,参数就折叠一个&),当然也可以称之为万能引用。通过这种模板的方式,既可以引用左值,又可以引用右值,甚至可以引用const的左值和const的右值。当然,当const的和非const的值同时传入时,如果参数要修改,那么就会因调用类型的歧义发生错误,因此需要注意。


此外,我们知道对于这个函数,如果变成这样调用:

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

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

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

就会因为右值引用变量的左值属性出现这样的结果:

image-20230310163522277

这也是我们在右值引用变量的属性所提到的,如果通过move,又会全变成右值,这同样不是我们想要的,那如何正确调用?接下来的完美转发就可以解决这个问题:

7.2 完美转发

void PerfectForward(T&& t)
{
	//完美转发,保持它的属性
	Fun(std::forward<T>(t));
}

image-20230310164038563

以完美转发的方式,就能保持在它的属性。因此,我们同样可以将上面list实现中move的强制转换变成完美转发的形式解决。

评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

每天都要进步呀~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值