目录
- 前言:
- 一、左值引用和右值引用
- 1.1 什么是左值和左值引用
- 1.2 什么是右值和右值引用
- 二、左值引用和右值引用比较
- 三、右值引用使用场景
- 3.1 传值返回使用场景
- 3.2 移动构造
- 3.3 移动赋值
- 3.4 STL容器接口也增加右值引用
- 3.5 完美转发
前言:
引用是给对象取别名,本质是为了减少拷贝。以前我们学习的引用都是左值引用,右值引用是C++11新增的语法,它们的共同点都是给对象取别名。既然如此,有了左值引用,为什么还要有右值引用?右值引用具体是怎样的?以及它有哪些应用场景?接下来,会详细分析~~
一、左值引用和右值引用
1.1 什么是左值和左值引用
左值是一个表示数据的表达式,可以是变量名、解引用的指针和前置++。左值可以取地址和赋值,它出现在赋值符号的左边。如果定义的左值被const修饰,那么它就不能被赋值,但是可以取地址。
//左值
int a = 10;
const int b = 20;
int* p = new int(0);
前置++是左值是因为该运算符先进行自增,再使用,返回值还是它自己,所以是左值
左值引用就是给左值的引用,给左值取别名
//左值引用
int& c = a;
const int& d = b;
int*& pp = p;
1.2 什么是右值和右值引用
右值也是一个表示数据的表达式,可以是常量、表达式、函数返回值(不能是左值引用返回)和后置++。右值不可以被赋值和取地址,它出现在赋值符号的右边。
int x = 1, y = 2;
//右值
10;//常量
x + y;//表达式
func(x, y);//函数返回值
后置++是右值是因为该运算符先使用,再++,即它会返回当前没有自增的临时变量,然后再自己++
右值引用就是给右值的引用,给右值取别名
//右值引用
int&& r1 = 10;
int&& r2 = x + y;
int&& r3 = func(x,y);
总结:
左值是具有存储性质的对象,是要占内存空间的;右值是没有存储性质的对象,也就是临时对象
判断是左值还是右值,不能以是否可以赋值来确定,右值是不可以赋值的,左值没有const时可以,有const时不行,所以左值和右值的本质区别是能否取地址,左值可以取地址,右值不可以取地址。
二、左值引用和右值引用比较
前面说过,左值引用是给左值取别名,右值引用是给右值取别名,那么有个小问题,左值引用能给右值取别名吗?右值引用又能否给左值取别名呢?答案是可以的,这里作了特殊处理:
- const左值引用可以给右值取别名
- 右值引用可以给move(左值)取别名
const int& a = 10;
int&& p = move(x);
move函数的作用是强制把左值转换为右值
我们知道,引用的最主要的作用是给对象取别名,减少拷贝。既然左值引用都可以给左值和右值取别名,那右值引用的出现有什么意义?
先来看下左值引用有哪些应用场景:
- 解决函数传参的拷贝问题。函数传参时如果没有左值引用,就要进行拷贝;有左值引用,不需要拷贝。
- 解决部分返回对象拷贝问题。返回对象出了函数作用域还在,没有问题;如果出了作用域就销毁了,就有问题。
1️⃣函数传参
string& operator=(const string& s)
2️⃣返回的对象,出了作用域还在
// 赋值重载
string& operator=(const string& s)
{string tmp(s);swap(tmp);return *this;//this指针指向的成员变量的作用域在整个类中
}
3️⃣返回的对象是局部的,出了作用域就销毁
int& Func()
{int b = 10;return b;
}
第一个和第二个没问题,第三个就有问题,返回对象是一个局部对象,出了作用域就销毁,用其他变量接收会出问题。
从这里可以发现,函数返回一个对象时用左值引用在某些场景是不适合的,但把左值引用去掉,只能传值返回,要拷贝。对上面的例子,返回的是一个int类型的对象,没有多大的消耗;但是如果返回的对象消耗很大,就影响效率,比如:
yss::string to_string(int value)
{bool flag = true;if (value < 0){flag = false;value = 0 - value;}yss::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;//出了函数作用域就会销毁
}
既然左值引用返回不行,传值返回有拷贝存在,那换成右值引用返回呢?其实也是不行的。
因为就算把要返回的对象转换为右值,还是避免不了返回对象出了作用域就销毁的情况。
三、右值引用使用场景
3.1 传值返回使用场景
怎么解决前面的问题呢?先来看看传值返回的场景:
在编译器没有作优化的情况下,要返回的对象是局部的,出了作用域就会销毁,所以拷贝构造给临时对象,临时对象是右值,临时对象再拷贝构造给ret1。整个过程拷贝构造了两次,拷贝了就算了,第一次拷贝构造后,str销毁了;第二次拷贝构造后,临时对象销毁了。也就是说,产生的临时空间,用完就将被销毁,这样是不是太浪费资源了。所以编译器一般都会作优化处理,尽可能的减少拷贝次数。先看下运行结果:
只调用了一次拷贝构造:
3.2 移动构造
有了编译器的优化,拷贝的次数减少,但还是不够。因此,右值引用的就有它的用武之地了。先说明一下,在前面的例子中,用右值引用作返回值是不行,因为没有解决局部对象出作用域就销毁的根本问题;也就是说,右值引用并不是像左值引用那样,你用了,就直接起作用,右值引用是间接起作用的。
右值引用是怎么间接起作用的呢?对比以下两个函数:
//函数1
void func(const int& x)
{cout << "void func(const int& x)" << endl;
}
//函数2
void func(int&& x)
{cout << "void func(int&& x)" << endl;
}int main()
{int x = 2;func(x);func(10);return 0;
}
函数2是函数1的重载,函数1的参数是左值引用,函数2的是右值引用,先注释掉函数2,运行一下:
第一次调用传入参数X,第二次调用传入参数10都可以调用函数1,这里其实也顺便验证了左值引用既可以引用左值(参数x),也可以引用右值(常数10,特殊处理的要记得带const)。
取消注释,函数1和函数2都在的情况下如何:
传入参数x调用函数1,参数为10调用函数2,说明调用哪个函数是根据传的参数是左值还是右值决定的,也就是哪个更合适用哪个。
在上面例子的基础上,可以对拷贝构造进行重载,变成移动构造,移动构造的作用:窃取别人的资源来构造自己。下面是拷贝构造和移动构造:
// 拷贝构造
string(const string& s)
{cout << "string(const string& s) -- 深拷贝" << endl;string tmp(s._str);//调用构造函数swap(tmp);
}// 移动构造
string(string&& s)
{cout << "string(string&& s) -- 移动构造" << endl;swap(s);
}
/
yss::string ret1 = yss::to_string(1234);
在拷贝构造函数和移动构造函数都在的情况下运行,只有移动构造,也就是说没有拷贝了,这得益于编译器的优化。
在编译器没有优化的情况下:
编译器有优化的情况下:
对比下拷贝构造和移动构造:
- 根据函数调用匹配原则,如果传入的参数是左值,调用的是拷贝构造;如果传入的参数是右值,调用的是移动构造。
- 拷贝构造(深拷贝)是比较浪费资源的,产生的临时对象tmp用完就销毁了;移动构造只需将被拷贝对象的资源占为己有,不需要深拷贝,提高了效率。
- 如果没有移动构造,不管是左值还是右值都会调用拷贝构造,也就是前面例子中返回对象有两次拷贝构造的情况(假设没有优化)
是不是所有的类都要有移动构造呢?
首先要清楚的是,移动构造是为了减少拷贝。也不是所有的拷贝都需要移动构造来解决,如果是要开空间的(深拷贝),比如string类,list等就要移动构造减少拷贝,否则拷贝的消耗很大。如果是不需要开空间的(浅拷贝),比如日期类,成员变量都是int类型,像这样的内置类型直接拷贝即可。
总结:
- 浅拷贝的类不需要移动构造
- 深拷贝的类需要移动构造
3.3 移动赋值
右值引用不仅可以用在移动构造,还可以用在移动赋值。如果一个对象已经存在,调用的函数返回值赋值给这个对象,就会调用移动赋值。
比如:
yss::string ret1;
ret1 = yss::to_string(1234);
拷贝赋值(赋值重载)和移动赋值:
// 赋值重载
string& operator=(const string& s)
{cout << "string& operator=(string s) -- 深拷贝" << endl;string tmp(s);//调用拷贝构造swap(tmp);return *this;
}// 移动赋值
string& operator=(string&& s)
{cout << "string& operator=(string&& s) -- 移动赋值" << endl; swap(s);return *this;
}
运行一下:
接下来,作几组对比:
1️⃣没有移动构造和移动赋值
函数返回值是拷贝构造出来的临时对象,再赋值给已经存在的对象ret1,赋值的过程中调用赋值构造,赋值构造里又有拷贝构造,总共两次拷贝。
2️⃣有移动构造,没有移动赋值
返回对象时是移动构造,没有拷贝,但是赋值时要调用拷贝构造
3️⃣有移动构造,有移动赋值
返回对象时没有拷贝,str是出了作用域就销毁,直接给返回值,返回值也是临时对象,直接给ret1,不需要拷贝,减少了资源浪费,效率提高。
拷贝赋值与移动赋值对比:
- 如果没有移动赋值,那么无论是左值还是右值都会调用拷贝复制,这点与拷贝构造与移动构造相同
- 根据函数调用匹配原则,参数是左值调用拷贝赋值,参数是右值调用移动赋值
- 拷贝赋值会先调用拷贝构造,再进行资源交换,交换后那个临时的对象用完就销毁了,整个过程比较浪费资源。移动赋值直接将自己的资源与临时对象的资源进行交换,交换后自己原来的资源只需交给临时对象处理(销毁)
注:有可能要赋值的对象不是临时对象,即不是右值,有可能是左值,那么情况就会有变化(对应函数调用匹配原则),下面来看看是左值的:
yss::string ret1;
yss::string ret2;//左值
ret1 = ret2;
运行:
3.4 STL容器接口也增加右值引用
有了右值引用,STL容器接口也作出了调整。以list的构造为例:
不仅是构造函数,在其他接口也有增加与右值引用相关的功能。通过STL中list的尾插函数来看:
list<yss::string> lt;
yss::string s1("1111");lt.push_back(s1);//有名对象
cout << "-----------------" << endl;
lt.push_back(yss::string("2222"));//匿名对象
cout << "-----------------" << endl;
lt.push_back("3333");//隐式类型转换
有名对象是左值,调用拷贝构造;匿名对象和隐式类型转换(构造+拷贝构造-》构造)是右值,调用移动构造。当然,在调用对应的构造函数前,尾插函数传参的过程需要先看下:
注:有名对象——左值可以通过move转换为右值,但是不要轻易使用,因为一旦使用这个左值的资源将会被拿走
上面的list是C++标准库中的list,用我们之前模拟实现的list试下,看有没有同样的效果。
第一个有两次拷贝构造,是因为定义空的链表时也有拷贝构造,第一个下面的深拷贝才是按照图示走的,所以第一个上面的深拷贝暂时先忽略掉
发现全是深拷贝,因为我们没有重载拷贝构造函数的传参为右值引用,重载后再运行看看:
ListNode(const T& x = T()):_prev(nullptr), _next(nullptr), _val(x)
{}ListNode(T&& x):_prev(nullptr), _next(nullptr), _val(x)
{}
//
//尾插
void push_back(const T& x)
{insert(end(), x);
}
void push_back(T&& x)
{insert(end(), x);
}
///
//pos位置插入
iterator insert(iterator pos, const T& x)
{Node* newnode = new Node(x);//创建新节点//......
}
iterator insert(iterator pos, T&& x)
{Node* newnode = new Node(x);//创建新节点//......
}
为什么还全是深拷贝呢?先来看一小段代码:
右值引用接收右值常量10,右值引用r可以自增++,也就是说,右值引用r的属性是左值。根据这点,所以前面的代码用右值引用参数接收后,它的属性变成了左值,左值再调用到下一个函数,接收的是左值引用。这里需要修改下代码,传参时move下,让它的参数(进入右值引用的)变成左值后再重新变成右值
ListNode(T&& x):_prev(nullptr), _next(nullptr), _val(move(x))
{}
///
void push_back(T&& x)
{insert(end(), move(x));
}
///
iterator insert(iterator pos, T&& x)
{Node* newnode = new Node(move(x));//创建新节点//......
}
运行一下:正是我们想要的结果。
那为什么右值引用后它的属性要变成左值呢?
因为只有右值引用的属性是左值可以被改变,资源才可以转移。
3.5 完美转发
模板中的万能引用——&&
作用:可以接收左值,也可以接收右值
template<class T>
void PerfectForward(T&& t)
{cout << "void PerfectForward(T&& t)" << endl;
}int main()
{PerfectForward(10); // 右值int a = 1;PerfectForward(a);// 左值PerfectForward(move(a)); // 右值return 0;
}
注意:万能引用虽然和右值引用都是两个取地址符,但是要有所区分。右值引用接收右值,或者是move后的左值;万能引用左、右值都能接收,包括const左值和const右值
const int b = 8;
PerfectForward(b);// const 左值
PerfectForward(std::move(b)); // const 右值
这样来看好像万能引用很不错,但其实还是有些局限:
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;
}
以上代码中我们的思路是:传入右值,在PerfectForward函数中调用的函数打印右值引用;传入左值,在PerfectForward函数中调用的函数打印左值引用;传入const右值,在PerfectForward函数中调用的函数打印const右值引用;传入const左值,在PerfectForward函数中调用的函数打印const左值引用。
运行结果:
发现都是左值,为什么?因为万能引用只是接收了而已,对后面该引用是左值引用还是右值引用就不归它管了。前面提过,左值经过左值引用后,还是左值;右值经过右值引用后,属性改变为左值。所以这段代码里无论左值进来还是右值进来最后都是调用左值引用的函数(const对应const的)。
既然这样,那么在调用Fun函数时把参数move下行不行呢?
Fun(move(t));
全都是右值引用了……
为了解决该问题,有一新语法:完美转发——std::forward
作用:在传参的过程中保留对象原生类型属性
Fun(std::forward<T>(t));
对比move和forward
- move就是简单粗暴的把左值属性变成右值属性
- forward是保持原来的属性。如果本身是左值,就不变;如果本身是右值,右值引用后属性会变成左值,但是这里面的过程相当于被move了,又变成了右值