一、左值引用和右值引用
传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。
什么是左值?什么是左值引用?
-
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取左值的地址,可以对左值赋值,左值可以出现赋值符号的左边,也可以出现在赋值符号的右边。定义const修饰的左值,不能赋值,但是可以取地址。能取地址的就是左值。
-
左值引用就是给左值的引用,给左值取别名。
-
左值引用只能引用左值,不能引用右值。
-
但是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;
// 左值引用只能引用左值,不能引用右值。
int a = 10;
int& ra1 = a; // ra为a的别名
//int& ra2 = 10; // 编译失败,因为10是右值
// const左值引用既可引用左值,也可引用右值。
const int& ra3 = 10;
const int& ra4 = a;
return 0;
}
什么是右值?什么是右值引用?
-
右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(传值返回) 等等,右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边,右值不能取地址,不能赋值。
-
右值引用就是对右值的引用,给右值取别名。
-
右值引用只能引用右值,不能引用左值。
-
但是右值引用可以引用move以后的左值。
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;
10 = 1;
x + y = 1;
fmin(x, y) = 1;
// 右值引用只能右值,不能引用左值。
int&& r1 = 10;
//int a = 10;
//int&& r2 = a; // error:无法从“int”转换为“int &&”;无法将左值绑定到右值引用
// 右值引用可以引用move以后的左值
int&& r3 = std::move(a);
return 0;
}
二、右值引用的使用场景
左值引用既可以引用左值和又可以引用右值(const左值引用),那为什么C++11还要提出右值引用呢?
-
左值引用做函数参数可以减少拷贝次数,提高效率。但有时我们需要区分函数参数到底是左值引用还是右值引用。这是const左值引用无法做到的。
-
C++11提出右值引用之后,我们就可以重载一个参数是右值引用的函数,与左值引用进行区分处理。
-
const左值引用可以引用左值和也可以引用右值,但如果有专门的右值引用函数,编译器会优先选择后者。
区分出是右值引用后要干什么呢?
- 之前我们只有左值引用,由于无法区分引用对象是左值还是右值,所以如果函数内涉及到对复杂类型的拷贝工作时,我们只能统统进行深拷贝,效率底下。
- 而实际上右值对象通常都是一些匿名对象、函数返回值、表达式返回值等临时对象。其内部资源会在完成拷贝工作后立即被销毁。所以对右值复杂对象的深拷贝其实是一种浪费。
- 右值引用的应用场景主要是移动语义。简单来说,移动语义就是将原本对右值复杂类型(涉及资源申请)的深拷贝工作,转变为直接移动其内部资源(主要指动态内存)。 移动语义的实现,减少了拷贝次数,提高了程序效率。
- 移动语义包括:移动构造,移动赋值,移动插入。同时移动构造和移动赋值的实现解决了函数传值返回的深拷贝问题。
2.1 移动构造
以之前模拟实现的string类为例:【STL】模拟实现string类-CSDN博客
在拷贝构造的过程中:
-
如果拷贝对象是左值,则必须进行深拷贝。
-
但如果拷贝对象是右值,可以进行移动构造,提高效率。因为右值对象(又叫将亡值)会在完成构造后自动销毁,所以我们可以将右值对象的资源直接拿来占用,免去了开空间和拷贝数据的工作。
class string{
private:
char *_str = nullptr; //注意!一定要将指针初始化为nullptr,防止野指针错误。
size_t _size = 0;
size_t _capacity = 0;
public:
//拷贝构造
Mystring(const Mystring &str){ //左值引用
_size = str._size;
_capacity = str._capacity;
_str = new char[_capacity+1];
memcpy(_str, str._str, str._size+1);
}
//移动构造
Mystring(Mystring &&str){ //右值引用
swap(str);
}
void swap(Mystring &str){
::swap(_str, str._str);
::swap(_size, str._size);
::swap(_capacity, str._capacity);
}
};
int main(){
string str1 = "abc"; //构造(隐式类型转换)
string str2 = str1; //左值构造——拷贝构造
string str3 = str1 + str2; //右值构造——移动构造
return0;
}
提示:
注意!一定要将指针初始化为nullptr,防止野指针错误。
在拷贝构造中,左值引用加const,只是为了保证对象在拷贝过程中不被修改。
在移动构造中,右值引用不能加const,因为要在构造时移动右值对象的内部资源。
2.2 移动赋值
移动赋值也是同样的道理:
-
如果拷贝对象是左值,则必须进行深拷贝。
-
但如果拷贝对象是右值,可以进行移动赋值。因为右值对象(又叫将亡值)会在完成赋值操作后自动销毁,所以我们可以将右值对象的资源直接拿来占用,同时将赋值对象的原数据交换给右值对象让其帮助销毁。
class string{
//拷贝赋值
Mystring& operator=(const Mystring &str){ //左值引用
if(this != &str)
{
char *tmp = new char[str._capacity+1];
memcpy(tmp, str._str, str._size+1);
delete[] _str;
_str = tmp;
_size = str._size;
_capacity = str._capacity;
}
return *this;
}
//移动赋值
Mystring& operator=(Mystring &&str){ //右值引用
swap(str);
return *this;
}
};
int main(){
string str1 = "abc";
string str2 = "def";
str1 = str2; //左值赋值——拷贝赋值
str1 = "ghi"; //右值赋值——移动赋值
}
如果想让左值进行移动构造或者移动赋值怎么办?用move()!
move是一个函数模版,返回指定对象的右值引用,用于将左值临时转换为右值。
int main(){
string str1 = "abc";
string str2 = str1; //左值构造——拷贝构造
string str3 = move(str1); //move将str1临时转为右值——移动构造。
//完成移动构造之后,str1中的资源就被转移走了,此时str1为空。
return 0;
}
2.3 移动插入
C++11以后,STL中的所有容器都增加了移动插入接口。
-
原来C++98中的插入接口其实都是拷贝插入,即不管要插入的元素是左值还是右值都统统需要重新开空间并进行数据拷贝。
-
而C++11中的移动插入接口则不同,如果插入的元素是右值,则直接移动其资源,无需进行拷贝,提高效率。
以list为例:
int main(){
list<string> ls;
string str = "hello world!";
ls.push_back(str); //插入左值——拷贝插入
ls.push_back(move(str)); //move将左值临时转为右值——移动插入
ls.push_back("china"); //插入右值——移动插入
}
提示:list移动插入的模拟实现在【完美转发的使用场景】部分介绍。
2.4 解决传值返回的深拷贝问题
首先在讲解这个问题的解决方法之前,我们需要先回顾一下编译器是如何优化连续的构造和拷贝构造的:
【Object-Oriented C++】类和对象(下) {初始化列表,explicit关键字,匿名对象,static成员,友元,内部类,优化连续的构造和拷贝构造}_芥末虾的博客-CSDN博客
- 在一条语句中,连续的构造和拷贝构造一般都会被编译器优化,将两个过程合二为一。
- 但是编译器不会对连续的拷贝构造和赋值重载进行优化,不能将两个过程合二为一。
因此当函数传值返回时,构造接收返回值和赋值接收返回值的优化结果是不同的,因该一分为二的看待。
2.4.1 构造接收
如果函数的返回值是一个局部对象,出了函数作用域就会被销毁,就不能使用引用返回,只能传值返回。
例如:在bit::string to_string(int value)
函数中可以看到,这里只能使用传值返回。传值返回会导致至少1次拷贝构造(如果是一些旧一点的编译器可能是两次拷贝构造),效率较低。
但C++11引入了右值引用之后,传值返回的深拷贝问题得到了彻底的解决。
在bit::string类中增加移动构造函数,再去调用bit::to_string(1234)
:
- 编译器会在返回对象进行销毁之前(调用析构函数),先将其临时转换为右值(类似于move());
- 然后调用移动构造,将返回对象中的资源直接移动到接收对象中,完成接收对象的构造。
- 最后才销毁返回对象,释放空间 。
注意:
编译器在优化传值返回时,对析构函数的调用顺序做了特殊调整。
不能显示的返回局部对象的右值引用。如果是显示返回,会先析构,再返回。在函数外访问时,空间已经被销毁。
2.4.2 赋值接收
再在bit::string类中增加移动赋值函数,再去调用bit::to_string(1234)
,不过这次是将bit::to_string(1234)
返回的右值对象赋值给ret1对象,这时调用的是移动赋值。
注意:编译器不会对连续的拷贝构造和赋值重载进行优化,不能将两个过程合二为一。
- 这里运行后,我们会发现调用了一次移动构造和一次移动赋值。
- 因为如果是用一个已经存在的对象赋值接收,编译器就没办法优化了。bit::to_string函数中会先用str生成构造生成一个临时对象。
- 但是我们可以看到,编译器很聪明的在这里把str转换成了右值,调用移动构造来构造临时对象。
- 然后再把这个临时对象做为bit::to_string函数调用的返回值赋值给ret1,这里调用的是移动赋值。
总的来说,不管是构造接收还是赋值接收,不管会不会进行合并优化。由于移动构造和移动赋值的实现,使得复杂函数的传值返回不再需要进行深拷贝,大大提高了传值返回的效率。因此,STL中几乎所有的容器都增加了移动构造和移动赋值。
2.5 总结
左值引用和右值引用都是通过减少拷贝来提高效率的。
- 左值引用:
- 左值引用传参,左值引用返回,可以直接减少拷贝。
- 漏洞一:没有解决用右值(将亡值)进行构造、赋值、插入时的对象拷贝问题。
- 漏洞二:没有解决局部对象传值返回的深拷贝问题。
- 右值引用:
- 对于内置类型和没有动态内存申请的复杂类型,移动语义没有什么意义。
- 但是对于有动态内存申请的复杂类型,移动语义可以间接减少拷贝构造(针对左值引用的漏洞进行了补充)。
- 补丁一:如果是右值引用传参,则构造、赋值、插入不再进行深拷贝,而是直接移动资源,提高效率。
- 补丁二:如果有资源申请的复杂类型实现了移动构造和移动赋值,在函数中返回该类型的局部对象时,会将其资源直接移动到外部接收对象中(赋值接收需要移动两次),无需进行拷贝,提高效率。
三、完美转发
3.1 完美转发的概念
模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值:
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;
}
运行结果:
为什么全都调用的是左值引用版本的Fun函数呢?
给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址,并对值进行修改。
-
例如:不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1。
-
如果不想rr1被修改,可以用const int&& rr1 去引用,const右值引用可以取地址,不可修改。
-
可以这么理解:右值取右值引用后变为了左值,这么设计是因为要使用右值引用移动右值对象的资源,而移动资源就意味着要修改右值(矛盾),所以要将右值转为左值。
int main()
{
double x = 1.1, y = 2.2;
int&& rr1 = 10;
const double&& rr2 = x + y;
rr1 = 20;
rr2 = 5.5; // 报错,const右值引用不能修改
return 0;
}
那么如何在内外层函数传递参数的过程中保持参数的原生类型属性呢?这时就需要用到新语法:完美转发
//同样还是上面的代码,加入完美转发
template<typename T>
void PerfectForward(T&& t) //模板中的&&——万能引用
{
// std::forward<T>(t)在传参的过程中保持了t的原生类型属性。
Fun(std::forward<T>(t));
}
再次运行:
注意:在多层嵌套调用时,要想在内外层函数传递参数的过程中保持参数的原生类型属性,需要在所有的传参位置进行完美转发。
3.2 完美转发的使用场景
以之前模拟实现的list和string为例:
-
【STL】模拟实现list类模版 {深度剖析list迭代器,实现list深拷贝}_芥末虾的博客-CSDN博客
-
【STL】模拟实现string类-CSDN博客(已实现移动构造)
下面我们实现Mylist的移动插入:
template <class T>
struct list_node{
T _data;
list_node *_next;
list_node *_prev;
//节点的构造
list_node(const T &val = T()) //左值引用
:_data(val), //调用存储类型的拷贝构造
_next(nullptr),
_prev(nullptr)
{}
//重载了右值引用版本
list_node(T &&val = T()) //右值应用
:_data(forward<T>(val)), //完美转发3-->调用存储类型的移动构造
_next(nullptr),
_prev(nullptr)
{}
};
template <class T>
class List{
//拷贝插入
iterator insert(iterator pos, const T &val){
Node *cur = pos._pnode;
Node *prev = cur->_prev;
Node *newnode = new Node(val);
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(newnode);
}
//移动插入
iterator insert(iterator pos, T &&val){
Node *cur = pos._pnode;
Node *prev = cur->_prev;
//需要在所有的传参位置进行完美转发
Node *newnode = new Node(forward<T>(val)); //完美转发2
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(newnode);
}
//拷贝插入
void push_back(const T &val){
insert(end(), val); //val是左值引用,调用拷贝插入insert
}
//移动插入
void push_back(T &&val){
//insert(end(), val); //右值引用将val转换为左值,所以也调用拷贝插入insert
insert(end(), forward<T>(val)); //完美转发1
}
};
测试代码:
#include <iostream>
#include "list.hpp"
#include "string.hpp"
using namespace std;
int main(){
Mylist<Mystring> ls; //在创建头结点时会进行一次移动构造(用匿名对象初始化头结点)
cout << "----------------------------------" << endl;
Mystring str1 = "abcd";
cout << "----------------------------------" << endl;
ls.push_back(str1); //插入左值——拷贝构造
cout << "----------------------------------" << endl;
ls.push_back(Mystring("qwer")); //插入右值——移动构造
cout << "----------------------------------" << endl;
ls.push_back("1234"); //插入右值——移动构造
cout << "----------------------------------" << endl;
}
完美转发前:
由于右值引用会将右值的属性转换为左值,所以也去调用了拷贝插入insert 。因此我们需要将移动插入过程中所有涉及的函数都实现一份右值引用版本,并在所有的传参位置进行完美转发,以保持参数的右值属性。
完美转发后: