【C++杂货铺】一文总结C++11新特性:右值引用 | 移动语义 | 完美转发

文章目录

一、左值引用和右值引用

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

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

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

cpp 复制代码
//左值引用
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;
}

小Tips :左值也可以出现在赋值符号的右边,例如 int a = b;,这里的 b 任然是一个左值。这里我们可以得出一个结论:出现在赋值符号左边的一定是左值,出现在赋值符号右边的可能是右值也可能是左值。因此我们不能简单的通过观察其在赋值符号的左边还是右边就断定它是左值还是右值。

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

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

cpp 复制代码
//右值引用
int main()
{
	double x = 1.1, y = 2.2;
	// 以下几个都是常见的右值
	10;
	x + y;
	fmin(x, y);

	//字符串常量是左值,因为可以取它的地址
	cout << &("xxxxxxxx") << endl;
	
	// 以下几个都是对右值的右值引用
	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;
}

小Tips1 :数字常量(整型常量)被定义为右值,字符串常量被定义为左值。一种我认为比较合理的解释是:数字常量(整型常量),CPU 可以立即寻址,所以数字常量(整型常量)其实只存在寄存器中,没有放到内存地址。而字符串常量的话,CPU 是没办法立即寻址的,所以对于字符串常量预先就放到了内存当中。所以我们可以对一个字符串常量取地址,那它就是一个左值,但是,凡是都有一个但是,const char*& = "xxxxx"; 在 VS 编译器下会报错,得用右值引用 const char*&& ps = "xxxxx";,这样就没问题了。

小Tips2 :需要注意的是右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定的位置,且可以取到该位置的地址,也就是说,例如:不能取字面常量10的地址,但是上面代码中的引用 rr1 引用后,可以对 rr1 取地址,也可以修改 rr1(这里的修改是去修改 rr1 引用的内容,并不是让 rr1 变成其他右值的引用)。如果不想 rr1 被修改,可以用 const int&& rr1 去引用,是不是感觉很神奇,这个了解一下即可,实际中右值引用的使用场景并不在于此,这个特性也不重要。

小Tips3:可以将我们常见的右值分为两类,一类是内置类型的右值,也被叫做纯右值,一般会出现在字面常量、表达式返回值中。另一类是自定义类型的右值,一般出现在函数返回值中,该函数的返回值是一个自定义类型的对象,这个被返回的对象也被叫做将亡值,因为在执行完 return 语句后,该对象就随着函数栈帧的销毁而销毁了。其次,函数返回过程中产生的临时中间变量,也可以被叫做将亡值,因为这个临时的中间变量,它的生命周期往往只有一行。

四、左值引用与右值引用的比较

4.1 左值引用总结

  • 左值引用只能作为左值的别名,不能作为右值的别名。

  • 但是 const 左值引用既可以做左值的别名,也可以做右值的别名。

cpp 复制代码
//左值引用只能引用左值
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;
}

4.2 右值引用总结

  • 右值引用只能做右值的别名,不能做左值的别名。

  • 但是右值引用可以做经过 move 操作的左值的别名。

cpp 复制代码
int main()
{
	// 右值引用只能右值,不能引用左值。
	int&& r1 = 10;
	int a = 10;
	//int&& r2 = a;
	// error C2440: "初始化": 无法从"int"转换为"int &&"
	// // message : 无法将左值绑定到右值引用

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

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

左值引用可以做函数的参数和函数的返回值,这样可以避免在函数传参和函数返回的时候去调用拷贝构造函数,对于一些大对象和需要进行深拷贝的对象来说,这样做可以提高效率。

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

左值引用的缺陷:当函数返回的对象是一个局部变量时,出了函数的作用域该对象就被销毁了,就不能使用左值引用返回,只能通过传值返回。而传值返回会导致至少调用一次拷贝构造,如果是旧一点的编译器可能是调用两次构造函数。

cpp 复制代码
wcy::string Func()
{
	wcy::string str("xxxx");//构造

	return str;
}

int main()
{
	wcy::string str1 = Func();//连续的 拷贝构造 + 拷贝构造 ===> 拷贝构造

	cout << "========================" << endl;
	wcy::string str2;//构造
	str2 = Func();//拷贝构造 + 赋值 
	return 0;
}


小Tips:只有连续的构造才会被编译器优化,构造 + 赋值是不会被编译器优化的。为了解决左值引用存在的缺陷,下面我们引入右值引用和移动语义。

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

首先我们需要明确一点,两个同名函数,分别用左值引用和右值引用作为形参,它们两个是构成函数重载的。

cpp 复制代码
void Func(int& pa)//左值引用
{
	cout << "void Func(int& pa)" << endl;
}

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

int main()
{
	int x = 10;
	int y = 20;
	Func(x);
	Func(x + y);
	return 0;
}


小Tips :即使在 int& pa 的前面加上 const,此时 pa 即可以是左值的别名,也可以是右值的别名,那重载的两个 Func 函数在语法上都可以接收右值。但是当实参传递的是右值的时候,编译器会去走最匹配的,即去调用 void Func(int&& pa)

再来分析一下深拷贝对象传值返回存在的缺陷

小Tips :通过上图我们可以发现,函数在返回的过程中创建了一个临时的中间变量,这个临时的中间变量作为该函数的返回值,它就是一个右值。再观察我们可以发现,这个临时的中间变量,它的声明周期就是有一行,即 str2 = Func(); 这一行,到了下一行,这个临时的中间变量就被销毁了,我们将这种值也称作将亡值。那么,我们是否可以考虑让 str2 和这个临时的中间变量进行一个资源互换,让 str2 去继承这个临时变量里面的资源,再把 str2 的资源交给这个临时的中间变量,中间变量在销毁的时候顺便就把之前 str2 中的资源给清理了,其实我们说的这些就是移动赋值的原理。

移动赋值

cpp 复制代码
//my_string.h
string& operator=(string&& s)
{
	cout << "string& operator=(string&& s) -- 移动赋值" << endl;
	swap(s);
	return *this;
}
cpp 复制代码
//test.cpp
wcy::string Func()
{
	wcy::string str("xxxx");//构造

	return str;
}

int main()
{
	wcy::string str2;//构造
	str2 = Func();//拷贝构造 + 赋值 ===> 赋值
	return 0;
}


小Tips:在创建完临时的中间变量后,str 会被销毁,所以 str 的境地和这个临时的中间变量很像,所以这里可以考虑让这个临时的中变量去继承 str 中的资源,即编译器将 str 识别成右值中的将亡值,只不过这个临时的中间变量并没有被提前创建,所以需要调用移动构造。而 str2 因为已经提前被创建出来了,所以调用的是移动赋值。再加入移动构造和移动赋值之后,上面这段代码的从原来需要执行两次深拷贝,到现在只需要进行资源的转移。代码效率得到了极大的提升。

移动构造:其原理和移动赋值一样,这里就不过多赘述。

cpp 复制代码
//my_string.h
// 移动构造
string(string&& s)
	:_str(nullptr)
	, _size(0)
	, _capacity(0)
{
	cout << "string(string&& s) -- 移动语义" << endl;
	swap(s);
}
cpp 复制代码
//test.cpp
wcy::string Func()
{
	wcy::string str("xxxx");//构造

	return str;
}

int main()
{
	wcy::string str1 = Func();//连续的 拷贝构造 + 拷贝构造 ===> 拷贝构造
	return 0;
}


小Tips:上面这段代码,其实编译器进行了以下两个优化:

  • 对连续的构造、拷贝构造,合二为一。

  • 编译器把 str 识别成右值------将亡值。

需要注意 :这里把 str 识别成将亡值是编译器的行为,因为根据左值和右值的定义去判断,str 是一个左值,用它作为参数去调用拷贝构造创建 str1 理论上应该调用普通的拷贝构造函数(即左值引用的构造函数)进行深拷贝,但是,但是!!!这里的 str 在函数调用结束后就要销毁了,那么采用移动构造不是很香嘛。

另外另外 :以上两个编译器的优化是建立在传值返回的基础之上的,千万不能将 Func 函数的返回值修改成左值引用或右值引用类型,即 Func 函数的返回值不可以是 string& 或者 string&&。因为无论是左值引用还是右值引用,它们本质上都是别名,而这里的 str 在函数调用结束后就会被立即销毁,所以不能给它取别名。

七、对左值引用和右值引用的总结

左值引用的核心价值是减少拷贝,提高效率。左值引用减少拷贝的方法是,以函数返回左值引用为例,如果一个被返回的对象除了作用域还在,就可以采用左值引用返回,可以看出左值引用减少拷贝的策略是将该对象自身返回。但是如果该对象出了作用域就被销毁了,此时左值引用就不在适合了,在没有右值引用的时候,我们就只能老老实实的去调用拷贝构造,用这个被返回的对象去构造一个临时的中间对象,如果待拷贝的对象是一个内置类型或者是一个普通的自定义类型(即不需要进行深拷贝)那么它的拷贝代价并不大,我们无需担心。但是如果待拷贝的对象是一个需要进行深拷贝的自定义类型,此时拷贝的代价就会变得非常大。于是,为了解决这个问题,C++11 中就提出了右值引用和移动语义,C++11 中将普通传值返回的对象视为右值中的将亡值,然后提供移动构造(该构造函数的参数是一个右值引用),通过继承将亡值中的资源避免了深拷贝效率低下的问题。这里也说明了一点问题,内置类型和浅拷贝的自定义类型是不需要移动构造的。右值引用的出现弥补了左值引用解决不了的函数传值返回时需要进行深拷贝的问题。在左值引用和移动构造的加持下,大多数场景都不必再使用拷贝构造了,但是拷贝构造函数依然是必不可少的,如果被拷贝的对象是一个左值,此时我们还是只能老老实实的去调用拷贝构造函数进行深拷贝,如下面这段代码所示。

cpp 复制代码
int main()
{
	wcy::string str1("xxxxxxxxxxxxxxxxxxxx");
	wcy::string str2(str1);//此时只能老老实实的去调用拷贝构造函数进行深拷贝
}

八、右值引用引用左值及其一些更深入的使用场景分析

8.1 move的特性

按照语法,右值引用只能引用右值,但右值引用一定不能引用左值嘛?有些场景下,可能真的需要用右值引用去引用左值实现移动语义。在 1.4.2 小节提到过,右值引用可以做为一个经过 move 操作的左值的别名。C++11 中,std::move 函数位于 utility 这个头文件中。该函数的名字具有迷惑性,它并不搬移任何东西,唯一的功能就是返回一个右值引用(注意:move 并不是将一个左值变成右值)。以下面的代码为例:

cpp 复制代码
int main()
{
	wcy::string str1("xxxxxxxx");
	move(str1);
	wcy::string str2(str1);
	return 0;
}



小Tips:如上图所示,str2 并没有去继承 str1 的资源,这说明经过 move 操作的 str1 并没有从左值变成右值,因为这里 str1 如果变成右值的话,创建 str2 应该去调用移动构造,进行资源转移,但是通过监视窗口并没有发现资源转移。再来看下面这段代码。

cpp 复制代码
int main()
{
	wcy::string str1("xxxxxxxx");
	wcy::string str2(move(str1));
	return 0;
}



小Tips:上面这段代码在创建 str2 的时候进行了资源的置换。因为 str1 经过 move 操作后会返回一个右值引用,该引用就是 str1 的别名,然后再去创建 str2,创建过程中因为 move 的返回值是一个右值引用,所以就会去调用移动构造,而移动构造中完成的是自愿置换的工作,所以就出现了上面这一幕,创建完 str2 后,str1 中的内容消失了,其实并不是消失了,只是被置换到了 str2 中。正所谓,笑容并不会消失,只会从一个人身上转换到另一个人身上。

8.2 move真正的使用场景

没有使用move进行插入

cpp 复制代码
int main()
{
	list<wcy::string> ls;
	wcy::string str1("xxxxxxxxxxxxxxxxxxx");

	ls.push_back(str1);
	return 0;
}

使用move进行插入

cpp 复制代码
int main()
{
	wcy::list<wcy::string> ls;
	wcy::string str1("xxxxxxxxxxxxxxxxxxx");

	ls.push_back(move(str1));
	return 0;
}

最常见的插入

cpp 复制代码
int main()
{
	wcy::list<wcy::string> ls;

	ls.push_back("xxxxxxxxxxxx");
	return 0;
}


总结:右值引用还可以用在容器的插入接口中,如果插入的对象是右值,可以利用移动构造转移资源给数据结构中的对象,也可以减少拷贝。

九、完美转发

9.1 模板中的&&万能引用

cpp 复制代码
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }

template<typename T>
void PerfectForward(T&& t)
{
	Fun(t);
}
int main()
{
	PerfectForward(10);           // 右值
	int a;         
	PerfectForward(a);            // 左值
	PerfectForward(std::move(a)); // 右值
	const int b = 8;
	PerfectForward(b);      // const 左值
	PerfectForward(std::move(b)); // const 右值
	return 0;
}


小Tips :模板中的 && 不代表右值引用,而是万能引用,其既能接受左值,又能接受右值。模板的万能引用只是提供了能够同时接受左值和右值的能力(包括 const 左值和 const 右值)。如上代码:实参如果传递的是一个左值 a,那么模板实例化出来的就是左值引用 int& t,有的书上也管这个叫引用折叠;实参如果传递的是一个右值 10,那么模板实例化出来的就是 int&& t(const 左值和 const 右值也类似)。这里需要注意一点,const 左值经过 move 操作之后得到的是一个 const 右值。除了要知道模板中的 && 表示万能引用之外,我们还需要清楚以下几点,才能明白为什么会出现上面的打印结果。

9.2 右值引用自身并不是一个右值

cpp 复制代码
int main()
{
	int a = 10;
	int&& ra = move(a);

	cout << &a << endl;
	cout << &ra << endl;//对右值引用取地址

	cout << a << endl;
	ra++;
	cout << a << endl;

	return 0;
}


小Tips :如上面的代码所示,ra 是一个右值引用,但是它自身并不是一个右值。我们要时刻牢记,无论是左值引用还是右值引用它们的本质都是在取别名,这也是为什么 &a == &ra。其次因为 ra 并不是右值,所以我们可以对它进行修改,注意:对引用的修改是修改引用所指向的内容,并不是修改引用的指向,上面代码就通过对右值引用 ra++,去修改了 a 对应内存空间中存储的值。这里右值引用引用的是一个经过 move 操作的左值。如果,右值引用引用的是一个常量,那么还是可以对这个右值引用进行修改,虽然这个右值常量不能被修改,但是此时被引用的常量右值会被存储到特定的位置,这里去修改右值引用,本质上就是对这个特定空间中存储的值进行修改,这一点在三小节提到过,这里不再赘述。

总结:上面是从概念角度去证明了,一个右值引用它本身并不是一个右值,因为可以对它取地址,这说明一个右值引用它本质上是一个左值。其次,从实际角度再去理解,右值引用产生的目的是解决一些场景下的深拷贝问题,解决方式是通过右值引用,进行资源交换,那如果右值引用被编译器识别成右值,右值意味着不可被修改,那资源交换如何实现?这就违背了右值引用产生的价值。 这下再去看下面这段代码就不难理解了。

cpp 复制代码
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }

template<typename T>
void PerfectForward(T&& t)
{
	Fun(t);
}
int main()
{
	PerfectForward(10);           // 右值
	int a;         
	PerfectForward(a);            // 左值
	PerfectForward(std::move(a)); // 右值
	const int b = 8;
	PerfectForward(b);      // const 左值
	PerfectForward(std::move(b)); // const 右值
	return 0;
}


小TipsPerfectForward 函数形参中的 t 无论是被实例化成左值引用还是右值引用,最终 Fun(t) 在执行函数调用的时候,t 都会被识别成一个左值,只有普通左值和 const 左值的区别。

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

要想在进行函数调用 Fun(t) 的过程中,让 t 的类型保持不变,就需要用到 forward 进行完美转发。

cpp 复制代码
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }

template<typename T>
void PerfectForward(T&& t)
{
	Fun(forward<T>(t));//进行完美转发
}
int main()
{
	PerfectForward(10);           // 右值
	int a;
	PerfectForward(a);            // 左值
	PerfectForward(std::move(a)); // 右值
	const int b = 8;
	PerfectForward(b);      // const 左值
	PerfectForward(std::move(b)); // const 右值
	return 0;
}


小Tips :要让 t 保持原生类型属性的场景,其实在上面链表插入的时候就碰到过,只不过当时还没有提出完美转发这个概念,如下图所示,有了完美转发的概念之后,再碰到这种场景我们就可以使用 forward 了。

9.4 &&虽好,可不要贪杯哦

模板中的 && 不代表右值引用,而是万能引用。此时就会有小伙伴会像,既然 && 可以表示万能引用,那么可不可以把类中原来的拷贝构造(形参是一个左值引用)给删了,只写一下 && 的版本呢?答案是可以的。但是 && 版本一定得是函数模板,因为 T&& tt 表示万能引用是建立在 T 的类型是根据形参推导出来的,而作为类的成员函数,他如果不是一个函数模板,那么 T 的类型是在模板实例化的时候就被确定好了,此时 t 就被确定为 T 类型的右值引用,就不再是万能引用了。

cpp 复制代码
template<class T>
struct ListNode
{
	ListNode<T>* _next;
	ListNode<T>* _prev;
	T _val;

	template<class TY>
	ListNode(TY&& val)
		:_next(nullptr)
		, _prev(nullptr)
		, _val(move(val))
	{}
};

小Tips:虽然这样写没毛病,但是还是不建议大家这样写,还是老老实实的再写一个左值引用的版本。

十、结语

今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,春人的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是春人前进的动力!

相关推荐
小蜗牛慢慢爬行5 分钟前
有关异步场景的 10 大 Spring Boot 面试问题
java·开发语言·网络·spring boot·后端·spring·面试
新手小袁_J29 分钟前
JDK11下载安装和配置超详细过程
java·spring cloud·jdk·maven·mybatis·jdk11
呆呆小雅30 分钟前
C#关键字volatile
java·redis·c#
Monly2131 分钟前
Java(若依):修改Tomcat的版本
java·开发语言·tomcat
小俊俊的博客32 分钟前
海康RGBD相机使用C++和Opencv采集图像记录
c++·opencv·海康·rgbd相机
Ttang2333 分钟前
Tomcat原理(6)——tomcat完整实现
java·tomcat
钱多多_qdd44 分钟前
spring cache源码解析(四)——从@EnableCaching开始来阅读源码
java·spring boot·spring
waicsdn_haha1 小时前
Java/JDK下载、安装及环境配置超详细教程【Windows10、macOS和Linux图文详解】
java·运维·服务器·开发语言·windows·后端·jdk
_WndProc1 小时前
C++ 日志输出
开发语言·c++·算法
薄荷故人_1 小时前
从零开始的C++之旅——红黑树及其实现
数据结构·c++