欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

C++程序员应了解的那些事(45)右值引用

程序员文章站 2022-07-12 22:48:49
...

 

【左值引用和右值引用】

  ①C++传统的左值引用示例代码:

int main()
{
	int a = 10;
	int &b = a; // 定义一个左值引用变量
	b = 20; // 通过左值引用修改引用内存的值
	return 0;
}
上面这段代码的汇编指令如下:
int a = 10;
// 这条mov指令把10放到a的内存中
00354218  mov         dword ptr [a],0Ah  

int &b = a;
/* 下面的lea指令把a的地址放入eax寄存器
    mov指令把eax的内容放入b内存里面
*/
0035421F  lea         eax,[a]  
00354222  mov         dword ptr [b],eax 

b = 20;
/* 下面的mov指令把b内存的值放入eax寄存器(就是a的地址)
    mov指令再把20放入eax记录的地址的内存里面(就是把20赋值给a)
*/
00354225  mov         eax,dword ptr [b]  
00354228  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]           //取临时量的内存地址放入寄存器eax
010517D2  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-14h
00CA18B8  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=赋值重载函数,如上面的代码所示。

       上面的代码虽然解决了对象的浅拷贝问题,但是效率却非常的低下,主要在这两句代码上:

①return tmp;
       这句代码中,tmp是函数的局部对象,因此不能出函数作用域,所以这里由tmp拷贝构造生成main函数栈帧上的临时对象。仔细查看上面的拷贝构造函数的实现:

// 栈的拷贝构造函数
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的资源又没有什么用处,而且马上就要析构,这样只能造成代码运行效率低下!

②s = GetStack(s);
       这里先通过临时量对象给s赋值,然后再析构临时对象,看看上面的operator=赋值函数的代码实现,先释放s占用的内存,又根据临时量的大小给s重新分配内存,拷贝数据。

// 栈的赋值重载函数
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()        //临时对象tmp马上进行析构,mpstack此时已经被置为nullptr
operator=(Stack&&)  // s = GetStack(s); 自动调用带右值引用参数的赋值重载函数
~Stack()
~Stack()

       从上面的打印可以清晰的看到,上面两处的拷贝构造函数和赋值重载函数的调用,自动使用了带右值引用参数的版本,效率大大提升,因为没有涉及任何的内存开辟和数据拷贝。因为临时对象马上就要析构了,直接把临时对象持有的资源拿过来就行了

       所以,临时量都会自动匹配右值引用版本的成员方法,旨在提高内存资源使用效率。带右值引用参数的拷贝构造和赋值重载函数,又叫移动构造函数移动赋值函数,这里的移动指的是把临时量的资源移动给了当前对象,临时对象就不持有资源,为nullptr了,实际上没有进行任何的数据移动,没发生任何的内存开辟和数据拷贝。

相关标签: 程序员应知应会