首页 > 编程语言 >[C++11]右值引用

[C++11]右值引用

时间:2024-03-31 16:00:56浏览次数:26  
标签:11 右值 int 左值 C++ vector 引用 &&

阅读导览:

  1. 通过左值、右值的基础概念来引出左值引用和右值引用
  2. 知道左值引用和右值引用后,先了解他们为什么能实现(底层原理)
  3. 熟悉了解左值引用的优点和缺陷并给出疑问,进而引出右值引用出现的意义以及如何解决左值引用的疑问
  4. 最后从多个方面再次了解右值引用

在这里插入图片描述

基础概念


右值、左值

问题:下列几个分别是左值还是右值

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;		//左值引用

引用引用你的

左值引用是引用左值的,右值引用是引用右值的,但是他们也可以引用对方,只要遵循以下几点就行:

  1. 如果想让左值引用去引用右值,那么左值必须是const引用,如:const int& p=‘x’;
  2. 如果想让右值引用去引用左值,那么左值必须被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行开始,

  1. 将a+b的结果(右值,此时位于寄存器中)放入某一块地址(ebp-3Ch)中
  2. 将该地址放入寄存器中,并将该地址给变量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;
}
  1. 重载了拷贝构造的右值引用版本,得以让返回值可以通过右值引用版本的拷贝构造来返回(其内容和原拷贝构造不一样)。可以称右值引用版的构造、赋值重载为移动语义。
  2. 手动move了要return的v

先浅说一下原理:

对于将要返回的v,我们知道,出了这个函数作用域后,v就要自动调用析构函数来析构资源,所以v的生命周期也就剩这么…大概1行,所以后面就有了2个选择:

  1. 老样子,先将我的内容深拷贝一份,然后析构我自己,用我深拷贝的内容去构造ret。
  2. 不拷贝我的内容,而是创建一个空vector,交换一下资源(这就是所谓的copy and swap技术),然后虽然这个v还是会去调用析构函数,但是此时析构的内容是一个空vector,所以不影响原本的内容,然后我们再用他原本的资源去构造ret。这样就节省了一次拷贝构造。

当然,我们所写的右值引用版本的拷贝构造当然用的是方法2,而return时move(v)就是为了让这个v的右值版本去return(move不修改v的原本属性)

如果只是想大致的理解右值引用,读到这里就足够用了,后面的内容是进一步拓展。

移动语义

移动语义说白了,就是去调用移动拷贝、移动赋值重载去完成对象的返回操作。

很明显,移动语义的作用就是:在容器重新分配内存时去移动它们的元素而不是复制它们,进而显著提高这些操作的性能。

移动语义的几点注意事项:

  1. 最好将之声明为noexpect,因为移动语义的操作实际上是交换了资源,但是如果有多个资源(多个成员变量)等待被交换,万一其中一个抛出了异常,那么这个资源交换实际上并没有完成,就会导致一系列问题。而将其声明为noexpect就可以解决这一问题。
  2. 应确保移动后源对象必须是可析构的。也就是说,此时的源对象中是空的(因为我们实现了copy and swap),而他的析构函数应不依赖于源对象中的任何数据(例如源对象中含有某个指针,析构函数中有调用该指针的操作)。

所以现在,放轻松…C++总共会生成8大默认函数,他们是:

  1. 构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 赋值重载函数
  5. 取地址符
  6. const取地址符
  7. 移动构造函数
  8. 移动赋值重载

对于这两位新成员,似乎有点特殊,我们先来看结论:

  • 如果你没有写移动构造函数,同时析构、拷贝构造、赋值重载一个都没有实现,那么编译器就会生成一个默认的移动构造函数。而这个自动生成的移动构造函数,对于内置类型进行浅拷贝,对于自定义类型会去调用他的移动构造函数(如果它没有移动构造函数,就会去调用他的拷贝构造函数)
  • 移动赋值函数同上

是不是看起来晦涩难懂,其实刨根问底发现原理其实很简单。首先,抛出一个疑问:什么时候均不会实现析构、拷贝构造、赋值重载函数?

  • 对于一些无需深拷贝的类,实现他们的析构、拷贝构造、赋值重载函数无疑画蛇添足,因为他们资源的创建相比在堆上创建的资源,实在是太小、太简单了,所以这个时候移动语义就起不到什么优化的作用,用编译器默认生成的浅拷贝就够了(反正他们又能有多大呢?也不用怕内存泄漏和非法访问)。同样,一旦你写了他们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

相关文章

  • C++类(class)中的this指针与静态成员
    1.this指针作用:指向成员函数所作用的对象2.静态成员定义方式:在定义成员时加static关键字。访问方式:不用通过对象就可以访问(类似全局变量/全局函数)目的:设置静态成员这种机制的目的是将和某些类紧密相关的全局变量和函数写到类里面,看上去像一个整体,易于维护和理解。①......
  • 20211110lyxDER编码
    一、任务详情参考附件中图书p120中7.1的实验指导,完成DER编码。Name实例中,countryName改为"CN",organizationName="你的学号"commonName="你的姓名拼音"。用echo-n-e"编码">你的学号.der中,用OpenSSLasn1parse分析编码的正确性。提交编码过程文档(推荐markdown格式)。......
  • C++Lambda表达式
    Lambda表达式0、介绍c++11新引入了lambda表达式,一般用于定义匿名函数,使得代码更加灵活简洁。lambda表达式与普通函数类似,也有参数列表、返回值类型和函数体,只是它的定义方式更加简介,并且可以在函数内部定义。通常,lambda用于封装传递给算法或异步方法的几行代码。1、Lambd......
  • 老鹰捉小鸡 c++编程参考程序(一本通51.
    #include<bits/stdc++.h>usingnamespacestd;intmain(){   inti,j,a[6],n;//定义整形变量   for(i=1;i<6;i++)//循环      a[i]=i;   i=1;   cout<<i<<":"<<"";//输出i   for(j=1;j<6;j++)//再循环      cout<......
  • C++String类
    前言大家好,我是jiantaoyab,本篇文章将给大家介绍String类的常用法,和模拟实现String类。String介绍在cplusplus中,对String有着下面的介绍。Thestandardstringclassprovidessupportforsuchobjectswithaninterfacesimilartothatofastandardcontainerofb......
  • C++原子操作与内存序 1
    问题#include<iostream>#include<thread>intmain(){ intsum=0; autof=[&sum](){ for(inti=0;i<10000;i++) sum+=1; }; std::threadt1(f); std::threadt2(f); t1.join(); t2.join(); std::cout<<"thesum......
  • 【c++】类和对象(六)深入了解隐式类型转换
    ......
  • C++: 虚函数,一些可能被忽视的细节
    C++:虚函数,一些可能被忽视的细节引言:关于C++虚函数,对某些细节的理解不深入,可能导致我们的程序无法按预期结果运行,或是表明我们对其基本原理理解不够透彻。本文详细解答以下几个问题:实现多态,忘记写virtual会怎么样?虚函数的默认参数可以重载吗?纯虚函数真的不能有实现吗?析构函数可......
  • C++ 中的 volatile 和 atomic
    C++中的volatile和atomic0.TL;DRstd::atomic用于多线程并发场景,有两个典型使用场景:原子操作:对atomic变量的操作(读/写/自增/自减)仿佛受互斥量保护。一般通过特殊的机器指令实现,比使用互斥量更高效限制编译器/硬件对赋值操作的重新排序volatile和多线程并发没有......
  • C++—vector的介绍及使用 && vector的模拟实现
    提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档目录文章目录前言一、vector的介绍及使用1.1vector的介绍1.2vector的使用1.2.1vector的定义1.2.2vectoriterator的使用1.2.3vector空间增长问题1.2.4 vector增删查改1.2.5 vector迭代器......