C++ 右值引用

发布于:2025-09-12 ⋅ 阅读:(14) ⋅ 点赞:(0)

1. 左值和右值

1.1 左值

左值是指可以出现在赋值运算符左边的表达式,它表示一个具有明确存储地址(可以是栈、堆等)和较长生命周期的对象

特点:

  • 左值既可以出现在赋值运算符=的左边,也可以出现在=的右边
  • 可以对左值取地址
  • 可以修改左值(被const修饰的左值除外)
  • 具有明确存储地址(可以是栈、堆等)和较长生命周期的对象

例如,以下都是左值:

int main()
{
	int a = 1;		
	int* p = &a;
	const int b = 0;

	return 0;
}

1.2 右值

右值是指只能出现在赋值运算符右边的表达式,它表示一个临时的、即将被销毁的对象。右值通常是一个字面量、临时对象或者函数返回的临时结果。

特点:

  • 右值只能出现在=的右边
  • 一般不能对右值取地址,因为它并没有被实际存储
  • 右值不能被修改

例如,以下都是右值

int main()
{
	"abcd";
	1;
	double a = 1 + 1;	//中间产生的临时变量也是一个右值
	std::min(1, 2);		//该函数的返回值会生成一个临时变量,是一个右值

	return 0;
}

注:如果一个函数的返回值是一个左值引用,那么该返回值就是是一个左值,而不是右值。例如:

class String
{
public:
	std::string& getString() { return _str; }

private:
	std::string _str;
};

2. 左值引用和右值引用

左值引用和右值引用本质上都是取别名

引用的意义就是为了减少拷贝

2.1 左值引用

左值引用就是对左值的引用,表示给左值取别名,用&声明

例如:

int main()
{
	int a = 1;		
	int* p = &a;
	const int b = 0;

	int& ra = a;
	int*& rp = p;
	const int& rb = b;

	return 0;
}

2.2 右值引用

右值引用就是对右值的引用,表示给右值取别名,用&&声明

注:

  • 如果给一个常量进行右值引用,那么就会延长该常量的生命周期,编译器会在栈上为这个临时对象分配一块内存,然后让这个右值引用使用这块内存。
  • 因为编译器会在栈上为这个临时对象分配一块内存,所以,我么可以对右值引用取地址,取到的就是这块栈上的内存
  • 甚至,我们也可以对右值引用取地址后解引用,对这块内存进行修改(注意,改的是内存的内容,而不是引用的常量)

例如:

int main()
{
	int&& ra = 10;
	int* p = &ra;
	*p = 1;

	std::cout << ra;

	return 0;
}

output:

1

如果我们不想让右值被修改,可以在前面加上const修饰

const int&& ra = 10;

2.3 左值引用 引用 右值

左值引用是否可以引用右值,例如:

double& a = 10;

不能,因为这涉及到引用权限放大 10是一个常量,不能被修改,而左值引用引用的是可以被修改的左值,这显然是不行的

如果要让左值引用引用右值,需要在前面加上**const**修饰,表示该左值引用不可修改,这样就不涉及权限放大的问题了

const double& a = 10;

2.4 右值引用 引用 左值

右值引用是否可以引用左值,例如:

double x = 1;
double&& a = x;

不能,右值引用只能引用右值,不能引用左值。

但是,对左值使用std::move后,就可以用右值引用了

double&& a = std::move(x);

2.4.1 std::move

std::move的主要作用为:将左值转换为右值引用

本质是通过函数模板,进行类型的转换

namespace std {
    template<typename T>
    typename remove_reference<T>::type&& move(T&& param) {
        return static_cast<typename remove_reference<T>::type&&>(param);
    }
}

3. 右值引用的意义

要讨论右值引用的意义,我们首先需要清楚左值引用及解决了哪些问题

  • 左值引用解决了传参时拷贝的问题

  • 左值引用解决了部分返回值拷贝的问题

    • 如果出了作用域,返回值还存在,就可以用左值引用防止拷贝
    • 如果出了作用域,返回值会被销毁(局部对象),那就不能用左值引用了

例如在string类的模拟实现中:

//赋值运算符重载(现代写法)
string& operator=(const string& s)
{
	string tmp(s);	//用s拷贝构造出对象tmp
	swap(tmp);		//交换这两个对象
	return *this;	//返回左值(支持连续赋值)
}

将参数设置为左值引用,并将不会被销毁的返回值对象也设置为左值引用,就可以避免拷贝构造,从而提高效率

但是,如果是下面的函数:

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

此时的返回值str是一个临时对象,出了作用域就会被销毁,因此返回值类型不能是左值引用。此时就无法避免临时对象拷贝构造一个新的****string****类对象的问题

例如:

string a = to_string(13);

上面的代码理论上就会产生两次拷贝构造,但是编译器会做优化,变成一次,如图:

在这里插入图片描述

那么为了解决 出了作用域,返回值会被销毁(局部对象),那就不能用左值引用了 这一问题,就需要右值引用

但是:

  • 我们又不能简单的将函数的返回值改为右值引用,因为返回的str是一个左值

    string&& to_string(int value)
    {
    	//.....
    	return str;
    }
    
  • 也不能同时将str通过std::move()强制转换为一个右值引用,因为str出了作用域后仍然会被销毁

    string&& to_string(int value)
    {
    	//.....
    	return std::move(str);
    }
    

3.1 移动构造

为了清楚右值引用是如何解决拷贝的问题,我们需要了解一个概念:将亡值

实际上C++11将右值分为了两类:

  • 纯右值:即内置类型的右值,如abcd10
  • 将亡值:自定义类型的右值,即那些处于生命周期末尾,但仍然拥有可以被利用资源的对象。

例如,string a = to_string(13)

  • to_string()函数返回后,会在栈区创建一个临时对象,这个临时对象在拷贝构造完对象a后,就会被销毁
  • 但是为了防止新对象和就对象指向的是同一份资源,我们目前实现的拷贝构造是深拷贝
  • 但是,这个临时对象是一个将亡值,使用完后就会被销毁,那它为什么还需要保留数据呢?
  • 所以,如果拷贝构造时的source是一个将亡值,那么我们就不需要进行深拷贝,而是可以直接将它的资源掠夺过来,从而提高效率

这就是使用右值引用的移动构造函数,例如:

string(string&& s)
{
	cout << "string(string&& s) -- 移动构造" << endl;
	swap(s);	//直接将将亡值s的资源进行转移
}

此时,对于同样的代码string a = to_string(13),理论上就只会进行一次拷贝构造和一次移动构造了,而编译器又会进行优化,只进行一个移动构造,如图:

在这里插入图片描述

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

  • 在没有增加移动构造之前,由于拷贝构造采用的是const左值引用接收参数,因此无论拷贝构造对象时传入的是左值还是右值,都会调用拷贝构造函数。
  • 增加移动构造之后,由于移动构造采用的是右值引用接收参数,因此如果拷贝构造对象时传入的是右值,那么就会调用移动构造函数(最匹配原则)
  • string的拷贝构造函数做的是深拷贝,而移动构造函数中只需要调用swap函数进行资源的转移,因此调用移动构造的代价比调用拷贝构造的代价小。

3.2 移动赋值

如果代码是这样的:

string a;
a = to_string(13);

那么这个过程中就会产生一次拷贝构造和一次拷贝赋值,并且编译器不会优化拷贝次数,但是会和上面一样,将返回的str识别成将亡值,如图:

在这里插入图片描述

那么,在后面的拷贝赋值的过程中,我没有会将将亡值深拷贝给a,这次深拷贝同样是没有必要的

因此,和移动构造一样,我们还需要一个移动赋值

string& operator=(string&& s)
{
	swap(s);	//直接将将亡值s的资源转移给自己
	return *this;
}

这样,拷贝赋值就会变成移动赋值,从而减少了一次深拷贝,提高了效率

在这里插入图片描述

3.3 STL中右值引用版本的插入函数

C++11标准给容器都添加了参数为右值引用的插入函数

例如list

std::list::push_back

void push_back (const value_type& val);		//左值引用版本
void push_back (value_type&& val);			//右值引用版本

例如:

int main()
{
	list<string> _list;
	string str = "abcde";

	_list.push_back(str);				//str为左值,使用左值引用版本

	_list.push_back("abcde");			//会用"abcde"构造一个临时的string对象,使用右值引用版本
	_list.push_back(string("abcde"));	//string("abcde")为匿名对象,将亡值,使用右值引用版本
	_list.push_back(move(str));			//move后为右值,使用右值引用版本

	return 0;
}

4. 完美转发

4.1 万能引用(引用折叠)

万能引用是一种特殊的引用类型,它既可以绑定到左值,也可以绑定到右值

万能引用的语法形式是通过模板参数和右值引用语法实现的,具体表现为 T&& ,其中 T 是一个模板类型参数

  • 如果传入的是一个左值那么 && -> &
  • 如果传入的是一个右值那么 && -> &&
  • 即传左值,就是左值引用,传右值,就是右值引用;传const就是const

例如:

template <typename T>
void perfectForward(T&& t)		//这里的T&&就不是一个右值引用,而是一个万能引用
{
	//....
}

注意:

只有在模板类型推导的上下文中,T&& 才是万能引用。如果 T 是一个具体的类型,那么 T&& 就是普通的右值引用

例如:

template<typename T>
class MyClass {
public:
    // 这里的 T 是模板类的模板参数,在类实例化时确定
    void func(T&& arg) {
        std::cout << "Called with rvalue reference" << std::endl;
    }
};

上面的代码中,void func(T&& arg)中的T&&不是万能引用,而是一个右值引用。

因为在实例化类对象的时候,T就已经确定了。而不需要在调用的时候进行推导

我们来看下面的代码:

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

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

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

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

template <typename T>
void perfectForward(T&& t) { func(t); }

int main()
{
	int a = 1;
	const int b = a;

	perfectForward(a);			//左值
	perfectForward(move(a));	//右值
	perfectForward(b);			//const左值
	perfectForward(move(b));	//const右值

	return 0;
}

output:

左值引用
左值引用
const 左值引用
const 左值引用

在上面的代码中,我们用模板参数T的万能引用接收不同类型的数据,就是希望它能将不同类型的数据正确匹配到不同的重载函数

但很明显,结果是错的。右值引用被t接收后,被识别成了左值;const右值引用被t接收后,也被识别成了const左值

4.2 右值引用的属性

为了弄清这一点,我们需要知道一个结论:

右值被右值引用后,这个右值引用的属性是左值

例如:

int main()
{
	int&& a = 10;
	++a;	//可以修改右值引用,说明右值引用的属性是左值

	return 0;
}

为什么要这样设计?我们可以这样理解:

  • 右值(将亡值)是不能被改变的,但是为了实现移动语义,能够将 将亡值 的资源转移给另外的对象,我们又需要对这个右值进行修改
  • 为了实现这一点,我们就可以让一个右值引用指向这个将亡值,并将右值引用的属性设计为左值,可以对其进行修改。这样就可以完成移动语义了

所以,在上面的代码中:

template <typename T>
void perfectForward(T&& t) { func(t); }

一个右值被t接收后,t就是一个右值引用,但是右值引用的属性是左值,因此它就会被传给形参为左值引用的func函数,从而导致结果错误

4.3 完美转发 std::forward

为了解决上面的问题,就需要用到完美转发:

lvalue (1)	template <class T> T&& forward (typename remove_reference<T>::type& arg) noexcept;

rvalue (2)	template <class T> T&& forward (typename remove_reference<T>::type&& arg) noexcept;

函数std::forward能够保持参数原有的属性,例如

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

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

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

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

template <typename T>
void perfectForward(T&& t) { func(std::forward<T>(t)); }

int main()
{
	int a = 1;
	const int b = a;

	perfectForward(a);			//左值
	perfectForward(move(a));	//右值
	perfectForward(b);			//const左值
	perfectForward(move(b));	//const右值

	return 0;
}

output

左值引用
右值引用
const 左值引用
const 右值引用

注意:

要想保持右值的属性,在每次右值传参时都需要进行完美转发


网站公告

今日签到

点亮在社区的每一天
去签到