本文共 11905 字,大约阅读时间需要 39 分钟。
先来看一下经典的左值引用
int main(){ int a = 0; int &b = a; b = 20; return 0;}
反汇编代码
int a = 10; // 这条mov指令把10放到a的内存中0112436E mov dword ptr [a],0Ah int &b = a; /* 下面的lea指令把a的地址放入eax寄存器 mov指令把eax的内容放入b内存里面*/01124375 lea eax,[a] 01124378 mov dword ptr [b],eax b = 20; /* 下面的mov指令把b内存的值放入eax寄存器(就是a的地址) mov指令再把20放入eax记录的地址的内存里面(就是把20赋值给a)*/0112437B mov eax,dword ptr [b] 0112437E mov dword ptr [eax],14h
从上面的指令可以看出,定义一个左值引用在汇编指令上和定义一个指针是没有任何区别的,定义一个引用变量int &b=a,是必须初始化的,因为指令上需要把右边a的地址放入一个b的内存里面(相当于定义了一个指针的内存),当给引用变量b赋值时,指令从b里面取出a的地址,并把20写入该地址,也就是a的内存中(相当于给指针解引用赋值),所以也说,使用引用变量时,汇编指令会做一个指针自动解引用的操作。
所以在汇编指令层面,引用和指针的操作没有任何区别!
再思考下面的代码:int &b = 20;
上面的代码是无法编译通过的,现在你应该知道原因,因为定义引用变量,需要取右边20的地址进行存储,但是20是立即数字,没有在内存上存储,因此是无法取地址的,但是解决这个问题还是有办法的,如下:
const int &b = 20;
用常引用可以引用20这个常量数字,难道此时20就能取地址了吗?当然不是,因为现在在内存上产生了一个临时量保存了20,b现在引用的是这个临时量,相当于下面的操作:
/*这里temp是在内存上产生的临时量const int temp = 20; const int &b = temp;*/const int &b = 20;
如果你还不明白,可以通过查看汇编指令进行确认,如下:
const int &b = 20;010517C8 mov dword ptr [ebp-14h],14h 《= ebp-14h就是内存栈上产生的临时量的内存地址010517CF lea eax,[ebp-14h] 《= 取临时量的内存地址放入寄存器eax010517D2 mov dword ptr [b],eax 《= 再把eax寄存器的值(放的是临时量地址)存入b中
好了,到现在我们可以得到这样一个结论,上面的C++引用就是我们常用的左值引用,
左值引用要求右边的值必须能够取地址,如果无法取地址,可以用常引用, 如const int &b = 20;但是这样一来,我们只能通过b来读取数据, 无法修改数据,因为b被const修饰成常量引用了,怎么办?解决办法当然就是使用右值引用了,先看下面的这段代码分析:
int &&b = 20; // 通过指令可以看到,原来const int &b=20和int &&b=20一模一样!!! 这里mov指令相当于是产生了临时量,起始地址ebp-14h00CA18B8 mov dword ptr [ebp-14h],14h //把临时量的地址放入eax寄存器当中00CA18BF lea eax,[ebp-14h] //再把eax的值(临时量的地址)放入b内存中(一个指针大小的内存)00CA18C2 mov dword ptr [b],eax b = 40;00CA18C5 mov eax,dword ptr [b] 00CA18C8 mov dword ptr [eax],28h
看上面代码,定义一个右值引用变量是这样的int &&b=20,从汇编指令来看,依然要产生临时量,然后保存临时量的地址,也就是说const int &b=20和int &&b=20在底层指令上是一模一样的,没有任何区别,不同的是,通过右值引用变量,可以进行读操作,也可以进行写操作。
所以,可以给一个这样的结论,有地址的用左值引用,没有地址的用右值引用;有变量名字的用左值引用,没有变量名字的(比如临时量没有名字)用右值引用。
从C++98和C++0x标准一路走来,一直在用左值引用解决问题;那么从C++11开始支持右值引用后,除了上面的好处,在实际的面向对象编程上,对我们还有什么帮助呢?请继续看下面的内容!
class Stack{ public: // size表示栈初始的内存大小 Stack(int size = 1000) :msize(size), mtop(0) { cout << "Stack(int)" << endl; mpstack = new int[size]; } // 栈的析构函数 ~Stack() { cout << "~Stack()" << endl; delete[]mpstack; mpstack = nullptr; } // 栈的拷贝构造函数 Stack(const Stack &src) :msize(src.msize), mtop(src.mtop) { cout << "Stack(const Stack&)" << endl; mpstack = new int[src.msize]; memcpy(mpstack, src.mpstack, sizeof(int)*mtop); } // 栈的赋值重载函数 Stack& operator=(const Stack &src) { cout << "operator=" << endl; if (this == &src) return *this; delete[]mpstack; msize = src.msize; mtop = src.mtop; mpstack = new int[src.msize]; memcpy(mpstack, src.mpstack, sizeof(int)*mtop); return *this; } // 返回栈的长度 int getSize()const { return msize; }private: int *mpstack; int mtop; int msize;};Stack GetStack(Stack &stack){ // 这里构造新的局部对象tmp Stack tmp(stack.getSize()); /* 因为tmp是函数的局部对象,不能出函数作用域, 所以这里tmp需要拷贝构造生成在main函数栈帧上 的临时对象,因此这里会调用拷贝构造函数,完成 后进行tmp局部对象的析构操作 */ return tmp;}int main(){ Stack s; /* GetStack返回的临时对象给s赋值,该语句结束,临时对象 析构,所以此处调用operator=赋值重载函数,然后调用 析构函数 */ s = GetStack(s); return 0;}
上面的代码运行结果如下所示:
Stack(int) =》 对应Stack s; Stack(int) =》 对应 Stack tmp(stack.getSize()); Stack(const Stack&) =》 对应return tmp; ~Stack() =》 对应tmp的析构 operator= =》 s = GetStack(s); ~Stack() =》 对应s = GetStack(s);语句完成,临时对象的析构 ~Stack() =》 对应main函数中s局部对象的析构上面的这段代码是我们编写C++类经常会遇到的一类问题,Stack对象由于成员变量是一个指针int *mpstack,构造时指向了堆内存,因此这样的对象做默认的浅拷贝和赋值操作是有问题的,导致两个对象的成员指针指向同一个资源,析构时同一个资源被delete两次,代码运行崩溃,因此我们需要给Stack提供自定义的拷贝构造函数和operator=赋值重载函数,如上面的代码所示。
上面的代码虽然解决了对象的浅拷贝问题,但是效率却非常的低下,主要在这两句代码上:
// 栈的拷贝构造函数Stack(const Stack &src) :msize(src.msize), mtop(src.mtop){ cout << "Stack(const Stack&)" << endl; mpstack = new int[src.msize]; memcpy(mpstack, src.mpstack, sizeof(int)*mtop);}
上面代码中,src引用的是tmp对象,this指针指向的是main函数栈帧上的临时对象,它的实现是根据tmp临时对象的内存大小给临时对象底层开辟内存,然后把tmp的数据再通过memcpy拷贝过来,关键是tmp马上就析构了!!!
上面为什么不能把tmp持有的内存资源直接给临时对象呢?非得给临时对象重新开辟内存拷贝一份数据,然后tmp的资源又没有什么用处,而且马上就要析构,这样只能造成代码运行效率低下。
// 栈的赋值重载函数Stack& operator=(const Stack &src){ cout << "operator=" << endl; if (this == &src) return *this; delete[]mpstack; msize = src.msize; mtop = src.mtop; mpstack = new int[src.msize]; memcpy(mpstack, src.mpstack, sizeof(int)*mtop); return *this;}
同样的问题,临时量对象给s赋值完成后,马上就析构了,为什么不能把临时对象的资源直接给s呢?如果这样做的话,效率就很高了,省了内存的开辟和大量数据的拷贝时间了。
上面提到的两个问题,在C++11中的解决方式是提供带右值引用参数的拷贝构造函数和operator=赋值重载函数。
右值引用的拷贝构造和operator=赋值函数
给上面的Stack类添加带右值引用参数的拷贝构造函数和operator=赋值重载函数,新添加代码如下:// 带右值引用参数的拷贝构造函数Stack(Stack &&src) :msize(src.msize), mtop(src.mtop){ cout << "Stack(Stack&&)" << endl; /*此处没有重新开辟内存拷贝数据,把src的资源直接给当前对象,再把src置空*/ mpstack = src.mpstack; src.mpstack = nullptr;}// 带右值引用参数的赋值重载函数Stack& operator=(Stack &&src){ cout << "operator=(Stack&&)" << endl; if(this == &src) return *this; delete[]mpstack; msize = src.msize; mtop = src.mtop; /*此处没有重新开辟内存拷贝数据,把src的资源直接给当前对象,再把src置空*/ mpstack = src.mpstack; src.mpstack = nullptr; return *this;}
重新运行所有代码,打印如下:
Stack(int) Stack(int) Stack(Stack&&) =》对应return tmp; 自动调用带右值引用参数版本的拷贝构造 ~Stack() operator=(Stack&&) =》 s = GetStack(s); 自动调用带右值引用参数的赋值重载函数 ~Stack() ~Stack()从上面的打印可以清晰的看到,上面两处的拷贝构造函数和赋值重载函数的调用,自动使用了带右值引用参数的版本,效率大大提升,因为没有涉及任何的内存开辟和数据拷贝。因为临时对象马上就要析构了,直接把临时对象持有的资源拿过来就行了。
所以,临时量都会自动匹配右值引用版本的成员方法,旨在提高内存资源使用效率。带右值引用参数的拷贝构造和赋值重载函数,又叫移动构造函数和移动赋值函数,这里的移动指的是把临时量的资源移动给了当前对象,临时对象就不持有资源,为nullptr了,实际上没有进行任何的数据移动,没发生任何的内存开辟和数据拷贝。
2018年校招中遇见过这样一道笔试题,如下:
#include#include using namespace std;class A{ public: A(int data=10):ptr(new int(data)) { } ~A() { delete ptr; ptr = nullptr; } A(const A &src) { cout << "A(const A&)" << endl; ptr = new int(*src.ptr); } A(A &&src) { cout << "A(A&&)" << endl; ptr = src.ptr; src.ptr = nullptr; }private: int *ptr;};int main(){ vector vec; vec.reserve(2); A a; vec.push_back(a); // 调用哪个构造函数? vec.push_back(A(20)); // 调用哪个构造函数? return 0;}
很明显,vec.push_back(a)调用的是左值引用参数的拷贝构造函数。vec.push_back(A(20))实参传入的是临时量对象,调用的是右值引用参数的拷贝构造函数,效率较高。上面程序打印如下:
A(const A&) A(A&&)以上面笔试题类A的代码举例如下:
vector getVector(){ vector vec; vec.reserve(3); vec.push_back(A(20)); vec.push_back(A(30)); vec.push_back(A(40)); cout << "————————" << endl; /* 这里返回vec时,会调用vector容器的带右值引用参数的拷贝构造函数, 类似vector(vector &&src),直接把这里vec的资源移动给main函数 中的v,效率很高,也就是说函数在返回容器的过程中,没有做任何的内存和 数据开销 */ return vec;}int main(){ vector v = getVector(); return 0;}
代码打印如下:
A(A&&) A(A&&) A(A&&) ————————可以看到,vector< A > v = getVector()没有做任何的容器数据拷贝,调用带右值引用参数的成员方法,大大提高了对象的使用效率。
先看下面的一段代码解释:
int main(){ int a = 10; int &b = a; //int &&c = a; // 错误,无法将左值a绑定到右值引用c //int &&d = b; // 错误,无法将左值b绑定到右值引用d int &&e = 20; // 正确,20是一个右值(没地址没名字),可以绑定到右值引用e上 //int &&f = e; // 错误,无法将左值e绑定到右值引用f,因为e有名字,有地址,本身也是左值 int &g = e; // 正确,e本身有名字,有地址,是一个左值,可以被g引用 return 0;}
从上面这段代码大家可以对左值和右值有更清楚的了解,尤其是右值引用变量e本身是一个左值,这个需要做正确的理解。
引用折叠的概念主要用在函数模板类型参数的推导中,如下面的函数模板:
templatevoid func(T&& val){ cout << "01 val:" << val << endl; T tmp = val; tmp++; cout << "02 val:" << val << " tmp:" << tmp << endl;}int main(){ int a = 10; int &b = a; int &&c = 10; cout << "func(10):" << endl; func(10);// 10是右值,引用类型是int&&,T&&推导过程是int&&+&&折叠成int&&,所以T是int,下同 cout << "func(a):" << endl; func(a);// a是左值,不可能用右值引用来引用,所以func推导T为int&,那么T&&->int&+&&折叠成int& cout << "func(std::move(a)):" << endl; func(std::move(a)); // std::move(a)是把a转成右值类型,右值引用类型是int&&,所以func推导T为int cout << "func(b):" << endl; func(b);// b是左值,不可能用右值引用来引用,所以func推导T为int&,那么T&&->int&+&&折叠成int& cout << "func(c):" << endl; func(c);// c是左值,不可能用右值引用来引用,所以func推导T为int&,那么T&&->int&+&&折叠成int& return 0;}
代码运行打印如下:
func(10): //T tmp = val; T是int 01 val:10 02 val:10 tmp:11 func(a): //T tmp = val; T是int& 01 val:10 02 val:11 tmp:11 func(std::move(a)): //T tmp = val; T是int 01 val:11 02 val:11 tmp:12 func(b): //T tmp = val; T是int& 01 val:11 02 val:12 tmp:12 func©: //T tmp = val; T是int& 01 val:10 02 val:11 tmp:11
从上面的代码可以看出,func这个函数模板的T类型有时候推导出来是int类型,有时候是int&类型,这个通过上面的代码测试,希望大家能够掌握。再说引用折叠,就是int && + &&折叠成int&&,除此之外,都折叠成int&,如int& + &&折叠成int&,知道这个就可以,具体的应用再看下面对move和forward函数的讲解。
看如下代码示例:
class A{ public: A(int data=10):ptr(new int(data)) { } ~A() { delete ptr; ptr = nullptr; } A(const A &src) { cout << "A(const A&)" << endl; ptr = new int(*src.ptr); } A(A &&src) { cout << "A(A&&)" << endl; ptr = src.ptr; src.ptr = nullptr; }private: int *ptr;};int main(){ vector vec; vec.reserve(10); for (int i = 0; i < 10; ++i) { A a(i); /* 这里a是一个左值,因此vec.push_back(a)会调用左值的 拷贝构造函数,用a拷贝构造vector底层数组中的对象 */ vec.push_back(a); } return 0;}
代码运行打印如下:
A(const A&) A(const A&) A(const A&) A(const A&) A(const A&) A(const A&) A(const A&) A(const A&) A(const A&) A(const A&)调用了10次左值引用的拷贝构造函数,看上面的代码,A a(i)在for循环中其实算是局部对象,在vec.push_back(a)完成后,a对象也就该析构了,所以在vec.push_back(a)时,应该把对象a的资源直接移动给vector容器底层的对象,也就是调用右值引用参数的拷贝构造函数,怎么做到呢?这时候就用到了带移动语义的std::move函数,main函数代码修改如下:
int main(){ vector vec; vec.reserve(10); for (int i = 0; i < 10; ++i) { A a(i); /* 由于a马上就会销毁,因此这里应该用右值引用参数 的拷贝构造函数,效率会更高 */ vec.push_back(std::move(a)); } return 0;}
代码运行打印如下:
A(A&&) A(A&&) A(A&&) A(A&&) A(A&&) A(A&&) A(A&&) A(A&&) A(A&&) A(A&&)此时在vec.push_back(std::move(a))这段代码中会调用到a对象的右值引用参数的拷贝构造函数。可以看move函数的源码,其实move就是返回传入的实参的右值引用类型,做了一个类型强转,move代码:
template_NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept { // forward _Arg as movable return (static_cast <_Ty>&&>(_Arg)); }
先给一个代码示例,实现一个简单的vector,来描述forward的应用场景,示例代码如下:
// 容器里面元素的类型class A{ public: A(){ } // 带左值引用参数的赋值函数 A& operator=(const A &src) { cout << "operator=" << endl; return *this; } // 带右值引用参数的赋值函数 A& operator=(A &&src) { cout << "operator=(A&&)" << endl; return *this; }};// 容器的类型templateclass Vector{ public: // 引用左值的push_back函数 void push_back(const _Ty &val) { addBack(val); } // 引用右值的push_back函数 void push_back(_Ty &&val) { // 这里传递val时,要用move转换成右值引用类型, // 因为val本身是左值,有名字有地址,见前面引用折叠部分的说明 addBack(std::move(val)); }private: enum { VEC_SIZE = 10 }; _Ty mvec[VEC_SIZE]; int mcur; template void addBack(_Ty &&val) { /* 这里val本身永远是左值,所以不可能调用 容器内部对象的右值引用参数的operator=赋值函数 */ mvec[mcur++] = val; }};int main(){ Vector vec; A a; vec.push_back(a); // 调用A的左值引用的赋值函数 vec.push_back(A()); // 理应调用A的右值引用参数的赋值函数,却调用了左值引用的赋值函数 return 0;}
代码运行打印如下:
operator= operator= vec.push_back(A())这句代码传入的是临时对象,最终却没有调用A对象的右值引用参数的赋值函数operator=,主要原因就是在Vector中addBack函数里面,val永远被当作左值了,无法保持它接收的实参的引用类型,是左引用还是右引用,此时std::forward就要起作用了,它称作“类型完美转发”,也就是说可以保持实参数据的左引用或者右引用类型,上面的addBack函数修改如下:templatevoid addBack(_Ty &&val){ /* 这里使用std::forward,可以获取val引用的实参的引用类型, 是左引用,还是右引用,原理就是根据“引用折叠规则” int&+&&->int& int&&+&&->int&& */ mvec[mcur++] = std::forward<_Ty>(val);}
修改完addBack函数,重新运行上面代码,打印如下:
operator=operator=(A&&)
完美!vec.push_back(A())这句代码,最终调用了A对象的右值引用参数的赋值函数operator=,符合预期。因为在addBack中使用了std::forward类型完美转发机制,它的源码实现如下:
template_NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept { // forward an lvalue as either an lvalue or an rvalue return (static_cast<_Ty&&>(_Arg)); }template _NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept { // forward an rvalue as an rvalue static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call"); return (static_cast<_Ty&&>(_Arg)); }
上面是C++库里面提供的两个forward重载函数,分别接收左值和右值引用类型,进行一个类型强转,(static_cast<_Ty&&>(_Arg)), 如果实参类型是int& + && -> int&就保持了实参的左值引用类型,如果实参类型是int&& + && -> int&&就保持了实参的右值引用类型。
【总结】:std::move是获取实参的右值引用类型;std::forward是在代码实现过程中,保持实参的原有的引用类型(左引用或者右引用类型)。
作者:大秦坑王
来源:CSDN 原文: 版权声明:本文为博主原创文章,转载请附上博文链接!