🌈欢迎来到C++专栏~~右值引用和移动语义
- (꒪ꇴ꒪(꒪ꇴ꒪ )🐣,我是Scort
- 目前状态:大三非科班啃C++中
- 🌍博客主页:张小姐的猫~江湖背景
- 快上车🚘,握好方向盘跟我有一起打天下嘞!
- 送给自己的一句鸡汤🤔:
- 🔥真正的大师永远怀着一颗学徒的心
- 作者水平很有限,如果发现错误,可在评论区指正,感谢🙏
- 🎉🎉欢迎持续关注!
文章目录
- 🌈欢迎来到C++专栏~~右值引用和移动语义
- 一. 基本概念
- 🌈左值 vs 右值
- 🌈左值引用 vs 右值引用
- 二. 右值引用的场景与意义
- 🎨左值引用的使用场景
- 🎨左值引用的短板
- 🎨右值引用和移动语义
- 🥑移动构造
- 🥑移动赋值
- 😎容器新增内容
- 🎨右值引用能引用左值吗?
- 🎨右值引用的其他使用场景
- 三. 完美转发
- ✨万能引用
- ✨完美转发保持值的属性
- ✨完美转发的使用场景
- 📢写在最后
一. 基本概念
🌈左值 vs 右值
什么是左值?
左值是一个表示数据的表达式(如 变量名或 解引用的指针)
- 左值可以被取地址,也可以被修改(const修饰的左值除外)
- 左值可以出现在赋值符号的左边,也可以出现在赋值符号的右边
int main()
{
//左值:可以取地址
int a = 10;
const int b = 20;//const不能修改(例外)
int* p = &a;
*p = 10;
return 0;
}
什么是右值?
右值也是一个表示数据的表达式,如字母常量、表达式的返回值、函数的返回值(不能是左值引用返回)等等
- 右值不能被取地址,也不能被修改
- 右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边
int main()
{
double x = 1.1, y = 2.2;
//以下几个都是常见的右值,不能取地址
10;
x + y;
fmin(x, y);
//错误示例(右值不能出现在赋值符号的左边)
//10 = 1;
//x + y = 1;
//fmin(x, y) = 1;
return 0;
}
- 右值本质就是一个临时变量或常量值,比如代码中的10就是常量值,表达式x+y和函数fmin的返回值就是临时变量,这些都叫做右值
- 这些临时变量和常量值并没有被实际存储起来,这也就是为什么右值不能被取地址的原因,因为只有被存储起来后才有地址
🌈左值引用 vs 右值引用
C++11中新增了右值引用的语法特性,为了进行区分,于是将C++11之前的引用就叫做左值引用。但是无论左值引用还是右值引用,本质都是给对象取别名
左值引用
左值引用就是对左值的引用,给左值取别名,通过“&
”来声明
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;
}
右值引用
右值引用就是对右值的引用,给右值取别名,通过“&&
”来声明
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);
return 0;
}
要注意的是
左值引用可以引用右值吗?
- 左值引用不能引用右值,因为这涉及权限放大的问题,右值是不能被改变的,而左值是可以修改的
- 有一个例外:
const
,const左值引用能够保证被引用的数据不会被修改,使得const左值引用可以引用右值
那就是const
左值引用既可以引用左值,也可以引用右值,这样的其实我们已经见多了
template<class T>
void func(const T& x)//x既能接收左值,也能接收右值
{
cout << x<< endl;
}
int main()
{
string s("hello");
func(s); //s为左值
func("world"); //"world"为右值
return 0;
}
🎃奇怪的现象
右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,这时这个右值可以被取到地址,并且可以被修改,如果不想让被引用的右值被修改,可以用const修饰右值引用
int main()
{
double x = 1.1, y = 2.2;
int&& rr1 = 10;
const double&& rr2 = x + y;
rr1 = 20;
rr2 = 5.5; //报错
return 0;
}
此处的rr1
就被转化成左值了,埋下伏笔哈哈哈,后面会遇到
右值引用可以引用左值吗?
- 右值引用只能引用右值,不能引用左值
- 但是右值引用可以引用
move
以后的左值(boss登场)
move
函数是C++11标准提供的一个函数,被move后的左值能够赋值给右值引用(斯国一)
int main()
{
int a = 10;
//int&& r1 = a; //右值引用不能引用左值
int&& r2 = move(a); //右值引用可以引用move以后的左值
return 0;
}
二. 右值引用的场景与意义
🎨左值引用的使用场景
我们先来看看左值引用的使用场景:
- 做参数:1️⃣减少拷贝,提高效率 2️⃣做输出型参数
- 做返回值:1️⃣减少拷贝提高效率,2️⃣引用返回,可以修改返回对象
🎨左值引用的短板
左值引用虽然能避免不必要的拷贝操作,但左值引用并不能完全避免
- 左值引用做参数,能够完全避免传参时不必要的拷贝操作
- 左值引用做返回值,并不能完全避免函数返回对象时不必要的拷贝操作
💥短板:如果函数返回的是一个局部对象,该变量出了函数作用域就被销毁了,这种情况下不能用左值引用作为返回值,只能以传值的方式返回,这就是左值引用的短板。
还好之前写了博客复习:复习传送门
举个例子:int版本的to_string函数,这个to_string函数就不能使用左值引用返回,因为to_string函数返回的是一个局部变量(出作用域销毁了)
namespace ljj
{
cl::string to_string(int value)
{
bool flag = true;
if (value < 0)
{
flag = false;
value = 0 - value;
}
cl::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;
}
}
此时调用to_string函数返回时,就一定会调用string的拷贝构造函数
int main()
{
ljj::string ret = to_string(3465);
return 0;
}
为此C++11就出手了,提出右值引用就是为了解决左值引用的这个短板的!
🎨右值引用和移动语义
那怎么样才能让编译器不优化,我们手动操作呢?那就要增加移动构造和移动赋值方法
ps:C++11对右值进行了划分
- 内置类型的右值 —— 纯右值
- 自定义类型的右值 —— 将亡值(即将死亡的值)
🥑移动构造
移动构造是一个构造函数,该构造函数的参数是右值引用类型的,移动构造本质就是将传入右值的资源窃取过来,占为己有,这样就避免了进行深拷贝,所以它叫做移动构造,就是窃取别人的资源来构造自己的意思
调用swap函数将传入右值的资源窃取过来,占为己有
//移动构造
string(string&& s)//右值引用
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(string&& s) -- 资源转移" << endl;
swap(s); //资源互换
}
移动构造的价值
- 没有引入移动构造之前,拷贝构造采用的是const左值引用接收,无论传入的是左值还是右值,都会调用拷贝构造
- 增加了移动构造之后,采用的是右值引用接收参数,如果传入的是右值的话,就会调用移动构造(最匹配原则)
- string的拷贝构造函数做的是深拷贝,而移动构造函数中只需要调用swap函数进行资源的转移,因此调用移动构造的代价比调用拷贝构造的代价小(少了一次深拷贝)
我们来看看编译器的优化:
当一个函数在返回局部对象时,会先用这个局部对象拷贝构造出一个临时对象,然后再用这个临时对象来拷贝构造我们接收返回值的对象(深拷贝)
编译器会优化成:一步到位:只需要一次拷贝构造,还要什么临时对象,我懂你意思,直接给给ret
在C++11标准出来之前这里应该调用两次string的拷贝构造函数,但最终被编译器优化成了一次,减少了一次无意义的深拷贝。(并不是所有的编译器都做了这个优化)
C++11出来后,编译器仍然保持了这种优化方式
“将亡值”str
马上就要被销毁了,那还不如把它的资源转移给别人用,因此编译器在识别这种“将亡值”时会将其识别为右值,这样就可以匹配到参数类型为右值引用的移动构造函数
可以理解成:
- 移动构造:在战争中,你穿上了别人不用的鞋子
- 拷贝构造:没有鞋子给你穿,你要自己去买(拷贝)一双
记住记住:右值引用
swap()
的是将亡值,拷贝构造中不能直接swap,因为对象不是将亡值,下面的例子中,swap完后s1就销毁了,那我们不可以这样做
int main()
{
ljj::string s1("1111111");
ljj::string s2(s1);
return 0;
}
🥑移动赋值
😎移动赋值是一个赋值运算符重载函数,该函数的参数是右值引用类型的,移动赋值也是将传入右值的资源窃取过来,占为己有,这样就避免了深拷贝,所以它叫移动赋值,就是窃取别人的资源来赋值给自己的意思
- 如果我们不是用函数的返回值来构造一个对象,而是用一个之前已经定义出来的对象来接收函数的返回值,这时编译器就无法进行优化了
编译器并没有对这种情况进行优化,因此在C++11标准出来之前,对于深拷贝的类来说这里就会存在两次深拷贝,因为深拷贝的类的赋值运算符重载函数也需要以深拷贝的方式实现
//移动赋值
string& operator=(string&& s)
{
cout << "string& operator=(const string&& s) -- 移动赋值" << endl;
swap(s);
return *this; //返回左值(支持连续赋值)
}
移动赋值的优势:
- 在没有增加移动赋值之前,由于原有
operator=
函数采用的是const左值引用接收参数,因此无论赋值时传入的是左值还是右值,都会调用原有的operator=函数 - 由于移动赋值采用的是右值引用接收参数,因此如果赋值时传入的是右值,那么就会调用移动赋值函数(最匹配原则)
- string原有的
operator=
函数做的是深拷贝,而移动赋值函数中只需要调用swap
函数进行资源的转移,因此 调用移动赋值的代价比调用原有operator=的代价小
此时当to_string函数返回局部的string对象时,会先调用移动构造生成一个临时对象,然后再调用移动赋值将临时对象的资源转移给我们接收返回值的对象,这个过程虽然调用了两个函数,但这两个函数要做的只是资源的移动,而不需要进行深拷贝,大大提高了效率
延长了资源的生命周期
😎容器新增内容
C++11标准出来之后,STL中的容器都增加了移动构造和移动赋值
以string类为例,这是string类增加的移动构造:
这是string类增加的移动赋值:
🎨右值引用能引用左值吗?
字面上是不可以的,但也不是完全不可以,当需要用右值引用引用一个左值时,可以通过move
函数将左值转化为右值
move函数的名字具有迷惑性,move函数实际并不能搬移任何东西,该函数唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义
move定义如下:
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
//forward _Arg as movable
return ((typename remove_reference<_Ty>::type&&)_Arg);
}
🎨右值引用的其他使用场景
右值引用版本的插入函数
C++11标准出来之后,STL容器插入接口函数也增加了右值引用版本
右值引用版本的意义:
如果vector容器当中存储的是string对象,那么在调用push_back向vector容器中插入元素
int main()
{
vector<ljj::string> v;
ljj::string s1("hello");
v.push_back(s1);//调用string的拷贝构造
cout << "——————————————————————————————————" << endl;
v.push_back("hello");//调用string的移动构造
return 0;
}
push_back函数需要先构造一个结点(在内存池中定位new),然后将该结点插入到底层的双链表当中
- C++11之前容器的push_back接口只有一个左值引用版本,因此在push_back函数中构造结点时,这个左值只能匹配到string的拷贝构造函数进行深拷贝
- C++11出来之后,string类提供了移动构造函数,并且容器的push_back接口提供了右值引用版本,此时如果传入push_back函数的string对象是一个右值,那么在push_back函数中构造结点时,这个右值就可以匹配到string的移动构造函数进行资源的转移,这样就避免了深拷贝,提高了效率
三. 完美转发
✨万能引用
模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值
template<class T>
void PerfectForward(T&& t)
{
//...
}
万能引用是根据传入实参的类型进行推导,如果传入的实参是一个左值,那么这里的形参t就是左值引用,如果传入的实参是一个右值,那么这里的形参t就是右值引用
举个例子:
void Func(int& x)
{
cout << "左值引用" << endl;
}
void Func(const int& x)
{
cout << "const 左值引用" << endl;
}
void Func(int&& x)
{
cout << "右值引用" << endl;
}
void Func(const int&& x)
{
cout << "const 右值引用" << endl;
}
template<class T>
void PerfectForward(T&& t)
{
Func(t);
}
int main()
{
int a = 10;
PerfectForward(a); //左值
PerfectForward(move(a)); //右值
const int b = 20;
PerfectForward(b); //const 左值
PerfectForward(move(b)); //const 右值
return 0;
}
PerfectForward函数时传入左值、右值、const左值、const右值,结果输出的全是左值,为什么呢?
- 根本原因是:右值被引用后会导致右值被存储到特定位置,这时这个右值可以被取到地址,并且可以被修改,所以在PerfectForward函数中调用Func函数时会将t识别成左值
就是说,右值经过一次参数传递后其属性会退化成左值,如果想要在这个过程中保持右值的属性,就需要用到完美转发
✨完美转发保持值的属性
要想在参数传递过程中保持其原有的属性,需要在传参时调用forward函数
template<class T>
void PerfectForward(T&& t)
{
//完美转发:保持t引用的属性
Func(std::forward<T>(t));
}
✨完美转发的使用场景
一个简化版的list类,类当中分别提供了左值引用版本和右值引用版本的push_back和insert函数
namespace ljj
{
template<class T>
struct ListNode
{
T _data;
ListNode* _next = nullptr;
ListNode* _prev = nullptr;
};
template<class T>
class list
{
typedef ListNode<T> node;
public:
//构造函数
list()
{
_head = new node;
_head->_next = _head;
_head->_prev = _head;
}
//左值引用版本的push_back
void push_back(const T& x)
{
insert(_head, x);
}
//右值引用版本的push_back
void push_back(T&& x)
{
insert(_head, std::forward<T>(x)); //完美转发
}
//左值引用版本的insert
void insert(node* pos, const T& x)
{
node* prev = pos->_prev;
node* newnode = new node;
newnode->_data = x;
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = pos;
pos->_prev = newnode;
}
//右值引用版本的insert
void insert(node* pos, T&& x)
{
node* prev = pos->_prev;
node* newnode = new node;
newnode->_data = std::forward<T>(x); //完美转发
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = pos;
pos->_prev = newnode;
}
private:
node* _head; //指向链表头结点的指针
};
}
只要右值每往下一层传,都要完美转发,否则统统变成左值
分别传入左值和右值调用不同版本的push_back
int main()
{
ljj::list<ljj::string> lt;
ljj::string s("1111");
lt.push_back(s); //调用左值引用
lt.push_back("2222"); //调用右值引用
return 0;
}
ps:代码中push_back和insert函数的参数T&&
是右值引用,而不是万能引用,因为在list对象创建时这个类就被实例化了,后续调用push_back和insert函数时,参数T&&中的T已经是一个确定的类型了
📢写在最后
中国奇谭真不戳