阅读导览:
- 通过左值、右值的基础概念来引出左值引用和右值引用
- 知道左值引用和右值引用后,先了解他们为什么能实现(底层原理)
- 熟悉了解左值引用的优点和缺陷并给出疑问,进而引出右值引用出现的意义以及如何解决左值引用的疑问
- 最后从多个方面再次了解右值引用
基础概念
右值、左值
问题:下列几个分别是左值还是右值
int a=10; //1
const int b=10; //2
a+b; //3
'x'; //4
"hello C"; //5
10; //6
int *p1=&a; //7
const int*p2=&a; //8
int* const p3=&a; //9
func(a,b); //10
左值:1、2、7、8、9
右值:3、4、5、6、10
如何区分左值和右值
能否取地址 | 能否给其赋值 | 常见类型 | |
---|---|---|---|
左值 | 可以取地址 | 可以给其赋值( 非const) | 变量名、指针 |
右值 | 不可以取地址 | 不可给其赋值 | 字面常量、表达式返回值、函数返回值 |
还有一个记法:右值要么就是字面常量、要么就是在表达式求值过程中创建的临时变量。而左值具有持久的状态,他们生命周期不会只限于某一行。
左值引用、右值引用
对左值的引用就是左值引用
对右值的引用就是右值引用
右值引用需要用&&来表示
int& pa=a; //左值引用
int& pb=b; //左值引用
int&& p1=a+b; //右值引用
int&& p2='x'; //右值引用
const int& pc='x'; //左值引用
const int&&p3='x'; //右值引用
int*& pd=&a; //左值引用
引用引用你的
左值引用是引用左值的,右值引用是引用右值的,但是他们也可以引用对方,只要遵循以下几点就行:
- 如果想让左值引用去引用右值,那么左值必须是const引用,如:const int& p=‘x’;
- 如果想让右值引用去引用左值,那么左值必须被move一下,如:int&& p=move(x);(move后面会说明)
浅探底层
我们采用对引用的变量进行++操作,来探寻发生了什么(以下均在MCVS(VS2022)下观察所得)
代码:
int main()
{
int a = 1;
int b = 2;
int& c = a;
c++;
cout << c << " ";
int&& d = a + b;
d++;
cout << d << endl;
return 0;
}
命令行结果:
2 5
这个结果表明两个++都产生了实际的作用
反汇编显示
这是左值引用的反汇编:
int& c = a;
008C4F1D lea eax,[a]
008C4F20 mov dword ptr [c],eax
c++;
008C4F23 mov eax,dword ptr [c]
008C4F26 mov ecx,dword ptr [eax]
008C4F28 add ecx,1
008C4F2B mov edx,dword ptr [c]
008C4F2E mov dword ptr [edx],ecx
这是右值引用的反汇编:
int&& d = a + b;
008C4F61 mov eax,dword ptr [a]
008C4F64 add eax,dword ptr [b]
008C4F67 mov dword ptr [ebp-3Ch],eax
008C4F6A lea ecx,[ebp-3Ch]
008C4F6D mov dword ptr [d],ecx
d++;
008C4F70 mov eax,dword ptr [d]
008C4F73 mov ecx,dword ptr [eax]
008C4F75 add ecx,1
008C4F78 mov edx,dword ptr [d]
008C4F7B mov dword ptr [edx],ecx
上图可见:对于他们的++操作,汇编代码一致,所以主要的不同则是体现在变量初始化阶段。
左值引用:这是一套最基本的指针操作(因为引用的底层就是指针),取a的地址赋给c。
右值引用:先记住其底层也是指针。前2行的操作不谈(基本的加法操作),从第3行开始,
- 将a+b的结果(右值,此时位于寄存器中)放入某一块地址(ebp-3Ch)中
- 将该地址放入寄存器中,并将该地址给变量d
总结:两者的底层都是指针,唯一不同的就是他们所指的位置。左值引用所指的位置是原先左值的地址。右值引用所指的位置是新开辟出来的一块空间(该空间存储的就是右值)。
左值引用的优缺点
我们所说的左值引用,也就是初学C++时发现的比指针还好用的引用
,而引用的出现时间远早于右值引用。下面统一称为左值引用
左值引用的优点
左值引用的出现解决了一项很重要的问题:避免了传参时发生拷贝构造,进而降低了传参的开销。
//void test(vector<string>& v)
void test(vector<string> v)
{
//...
}
int main()
{
vector<string> v;
v.push_back("a");
v.push_back("b");
//...期间省略10000000000次push_back(),当然不可能push这么多,会爆的
test(v);
return 0;
}
上图中test()没有采用传引用传值,这就会导致一个“小问题”:
调用test()时,会取调用vector的拷贝构造,将所有的值再push一变,这样的开销…哇…可想而知
如果采用引用接收传参,就不会发生拷贝构造
左值引用的缺陷
既然左值引用这么优秀了,那么为什么还有右值引用呢?不妨看一下下述场景
vector<string> test()
{
vector<string> v;
v.push_back("a");
v.push_back("b");
//...期间同样省略10000000000次push_back()
return v;
}
int main()
{
vector<string> v = test();
return 0;
}
上述场景会发生什么呢?哇…同样不敢想象…
或许有人就要问:如果我给返回类型加上&呢vector<string>& test(vector<string> v)
。哇…那更不可以了!这会触发编程之忌:未定义行为。因为v在出了test()后就会调用析构销毁,这个引用指向的是一块非法区域(当然你的编译器不一定会报错,如果你没有对返回值进行访问时)
没办法啦,于是C++11就掏出了右值引用
来救场啦
右值引用
在正式谈右值引用前,我们先来解决一下前面的问题,再信心满满的去学习接下来的内容!
如何解决:
我们只需要重载一下拷贝构造函数就可以解决问题(这里会先用一下后面的知识,看不懂没关系)
template <class T, ...>//这里只是省略了几个模板参数,并不是特殊的语法
class vector
{
public:
vector(vector<T, ...>&& v)//重载了拷贝构造的右值引用版本
://...
{
std::swap(v);
}
};
vector<string> test()
{
vector<string> v;
v.push_back("a");
v.push_back("b");
//...期间同样省略10000000000次push_back()
return move(v); //这里手动move了,不手动move编译器也会帮你move
}
int main()
{
vector<string> ret = test();
return 0;
}
- 重载了拷贝构造的右值引用版本,得以让返回值可以通过右值引用版本的拷贝构造来返回(其内容和原拷贝构造不一样)。可以称右值引用版的构造、赋值重载为移动语义。
- 手动move了要return的v
先浅说一下原理:
对于将要返回的v,我们知道,出了这个函数作用域后,v就要自动调用析构函数来析构资源,所以v的生命周期也就剩这么…大概1行,所以后面就有了2个选择:
- 老样子,先将我的内容深拷贝一份,然后析构我自己,用我深拷贝的内容去构造ret。
- 不拷贝我的内容,而是创建一个空vector,交换一下资源(这就是所谓的copy and swap技术),然后虽然这个v还是会去调用析构函数,但是此时析构的内容是一个空vector,所以不影响原本的内容,然后我们再用他原本的资源去构造ret。这样就节省了一次拷贝构造。
当然,我们所写的右值引用版本的拷贝构造当然用的是方法2,而return时move(v)就是为了让这个v的右值版本去return(move不修改v的原本属性)
如果只是想大致的理解右值引用,读到这里就足够用了,后面的内容是进一步拓展。
移动语义
移动语义说白了,就是去调用移动拷贝、移动赋值重载去完成对象的返回操作。
很明显,移动语义的作用就是:在容器重新分配内存时去移动它们的元素而不是复制它们,进而显著提高这些操作的性能。
移动语义的几点注意事项:
- 最好将之声明为noexpect,因为移动语义的操作实际上是交换了资源,但是如果有多个资源(多个成员变量)等待被交换,万一其中一个抛出了异常,那么这个资源交换实际上并没有完成,就会导致一系列问题。而将其声明为noexpect就可以解决这一问题。
- 应确保移动后源对象必须是可析构的。也就是说,此时的源对象中是空的(因为我们实现了copy and swap),而他的析构函数应不依赖于源对象中的任何数据(例如源对象中含有某个指针,析构函数中有调用该指针的操作)。
所以现在,放轻松…C++总共会生成8大默认函数,他们是:
- 构造函数
- 析构函数
- 拷贝构造函数
- 赋值重载函数
- 取地址符
- const取地址符
- 移动构造函数
- 移动赋值重载
对于这两位新成员,似乎有点特殊,我们先来看结论:
- 如果你没有写移动构造函数,同时析构、拷贝构造、赋值重载一个都没有实现,那么编译器就会生成一个默认的移动构造函数。而这个自动生成的移动构造函数,对于内置类型进行浅拷贝,对于自定义类型会去调用他的移动构造函数(如果它没有移动构造函数,就会去调用他的拷贝构造函数)
- 移动赋值函数同上
是不是看起来晦涩难懂,其实刨根问底发现原理其实很简单。首先,抛出一个疑问:什么时候均不会实现析构、拷贝构造、赋值重载函数?
- 对于一些无需深拷贝的类,实现他们的析构、拷贝构造、赋值重载函数无疑画蛇添足,因为他们资源的创建相比在堆上创建的资源,实在是太小、太简单了,所以这个时候移动语义就起不到什么优化的作用,用编译器默认生成的浅拷贝就够了(反正他们又能有多大呢?也不用怕内存泄漏和非法访问)。同样,一旦你写了他们3个中的任何一个,这就代表(大概率代表,如果你听话的话)里面的资源可能并不“简单”,这个时候就急需移动语义来提高操作的性能(因为这种类的拷贝构造往往开销都不小)。同样,如果你显式提供了移动构造、移动赋值,那么编译器就不会生成默认的拷贝构造、赋值重载函数。
move
这个C++11新增的一个特性,用于支持移动语义(move semantics)。std::move
是一个函数模板,它接受一个左值引用并返回一个右值引用(rvalue reference)。
注意:move之后的数据如果当参数传递时,会丢失其右值属性(为了能进行资源交换,因为右值是const属性),所以如果有需要,可能还需要对实参进行二次move
如下:
void func(int&& t);
template <typename T>
void test(T&& t)
{
func(move(t));
}
int main()
{
int a=10;
test(move(a));
return 0;
}
对于许多现代编译器来说,他们在返回时,一般都会判断一下该对象是否属于将亡对象(也就是它的生命周期没几行了),对于将亡对象则隐式调用move完成类型转换。为什么编译器要隐式调用呢?这就和历史有关,由于C++的代码存量大,许多的代码在当初书写时并没有右值引用的提出,所以C++11为了向前兼容,就会根据情况判断是否隐式调用move。当然,你完全可以显式调用move,这没有任何问题。
编译器做了哪些优化
按照我们前面所说,v返回的时候先调用移动拷贝,然后再调用构造来构造ret,但是编译器为了更高的效率,还会在此基础上进一步优化。我们看如下代码:
string& string::operator=(const string& str)//赋值重载
{
cout << "operator= string&" << endl;
if (this != &str)
{
string tmp(str);
swap(tmp);
}
return *this;
}
string(const string& str)//拷贝构造
:_str(nullptr)
{
cout << "string&" << endl;
string tmp(str._str);
swap(tmp);
}
string(string&& str)//移动构造
:_str(nullptr)
{
cout << "string&&" << endl;
swap(str);
}
string(const char* str = "")//构造函数
{
cout << "const char* " << endl;
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
mystring::string test()
{
mystring::string str = "hello world";
return move(str); //这里显式调用了move
}
int main()
{
mystring::string ret = test();
return 0;
}
大体思路:
我们用一个test()函数返回一个内部的临时对象,我们将这个对象手动move了,来看看总共会调用哪些函数。
命令行输出:
const char*
string&&
结论:
从中可以看出,编译器确实优化了很多,首先const char*
是构造函数调用的,再之后手动move时调用了string&&
,但是给ret赋值时,没有调用赋值重载函数,所以可见:编译器将移动构造和拷贝构造->优化成移动构造。!!如果我这里不写move,那么优化的更狠,连string&&都不会打印。
容器对右值引用做出的努力
vector (vector&& x);
vector (vector&& x, const allocator_type& alloc);
vector& operator= (vector&& x);
list (list&& x);
list (list&& x, const allocator_type& alloc);
list& operator= (list&& x);
map (map&& x);
map (map&& x, const allocator_type& alloc);
map& operator= (map&& x);
//...
可见,所有的容器都实现了右值引用版本的移动构造和移动赋值
他们的作用和前面说的一样,通过移动语义来提高效率。
完美转发
这是右值引用的一大重要作用,我们先看下面代码:
template <typename T>
void func(const T&);
void func(T&);
void func(T&&);
void func(const T&&);
如果我想用传统的模板实例化,那么我的一个func()函数就必须实现上述几个版本,否则我的调用可能找不到匹配的模板。此时右值引用又站了出来
template <typename T>
void func(T&&);
很多情况下,我们只需要写上面这一个模板(这就是万能引用
),编译器就可以自动推演出所有的需求。有的地方也称之为引用折叠
,因为它可以将&&折叠成&(即推演生成func(T&)),所以形象的称之为折叠。
但是模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,但是传进来的参数后续使用中都退化成了左值,但我们希望能够在传递过程中保持它的左值或者右值的属性, 所以就可以用到:
完美转发
->在传参的过程中保留对象原生类型属性。
std::forward<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 <typename T>
void Perfect(T&& t)
{
func(std::forward<T>(t));//他知道该去调用哪个func
}
标签:11,右值,int,左值,C++,vector,引用,&&
From: https://blog.csdn.net/m0_75062065/article/details/137190787