C++之右值引用和移動構造函數

提綱
1、右值引用
2、移動構造函數
3、總結



1、右值引用

什麼是右值引用呢?要搞明白右值引用,必須先搞清楚什麼是右值和左值,其次必須搞清楚什麼是值引用。


1.1 左值和右值

左值一般都是帶有內存地址的變量,而右值一般是立即數或者運算過程中的臨時對象,這種對象不會有地址值。舉例說明如下

int main(void) {	
  int i = 10;	
  int j = 11;	
  int sum = i + j;
}

上面這一段代碼中10,11,(i+j),這三個是屬於右值,因爲它本身沒有內存地址,除非把它們放入到棧中或者堆中。
而i,j,sum這三個是屬於左值,因爲它們是線程棧上地址的標識符。

能用取址符號 & 取出地址的皆爲左值,剩下的都是右值。而且,匿名變量一律屬於右值。

std::move() 能把左值強制轉換爲右值。

移動構造實現一節的例程我們把語句 Integer b(temp); 改爲 Integer b(std::move(temp)); 後,運行結果如下。


1.2 值類型

值類型 作爲方法參數或者返回值時會生成自身的副本,如果 值類型 很大,那一來一回生成若干個深複製的 臨時對象 將會產生巨大的性能開銷。


1.3 左值引用和右值引用

知道了左右值概念,接下來理解左右值引用就很簡單了,既然是引用,必然是多個變量指向同一個地址,用代碼舉例如下:

int main(void) {	
  int i = 10;	
  int& k = i;		//左值引用	
  int&& m = 10;	//右值引用
}

上面代碼中的k是一個引用,但是它是一個指向左值的引用,上面的代碼中的m是一個引用,但是它指向的是10這個常量的引用,常量10沒有明確的內存地址,不能給常量10賦值,所以它是右值,指向它的引用就是右值引用。

所以,簡單來講,右值引用就是指的是指向右值的一個引用。

2、移動構造函數:右值引用如何減少對象的創建

移動構造函數是c++11的新特性,移動構造函數傳入的參數是一個右值 用&&標出。

首先講講拷貝構造函數:拷貝構造函數是先將傳入的參數對象進行一次深拷貝,再傳給新對象。這就會有一次拷貝對象的開銷,拷貝的內存越大越耗費時間,並且進行了深拷貝,就需要給對象分配地址空間。而移動構造函數就是爲了解決這個拷貝開銷而產生的。

右值引用的好處是可以減少創建對象,從而節省內存空間,加快程序的執行性能。那麼它是如何減少對象創建的呢?

減少臨時對象的創建,無非就是在運算過程中複用一些對象,不需要每次都走賦值構造函數來進行深複製,圖示如下:

整體的思路就如上圖所示,下面我們舉例說明。

#include <iostream>
#include <vector>
using namespace std;
class StringBuidler
{
public:
    char *str;
    int length;

public:
    StringBuidler() {}
    StringBuidler(int len, char c)
    {
        this->str = new char[len];
        this->str[0] = c;
        this->length = len;
    }
    StringBuidler(const StringBuidler &s)
    {
        printf("StringBuidler:深複製 \n");
        this->length = s.length;
        this->str = new char[s.length];
        for (size_t i = 0; i < length; i++)
        {
            this->str[i] = s.str[i];
        }
    }
    StringBuidler operator+(const StringBuidler &p)
    {
        StringBuidler tmp;
        tmp.length = this->length + p.length;
        tmp.str = new char[tmp.length];
        int index = 0;
        for (size_t i = 0; i < this->length; i++)
        {
            tmp.str[index++] = this->str[i];
        }
        for (size_t i = 0; i < p.length; i++)
        {
            tmp.str[index++] = p.str[i];
        }
        return tmp;
    }
};
int main()
{
    StringBuidler s1(10, 'a');
    StringBuidler s2(5, 'b');
    StringBuidler s3 = s1 + s2;
    printf("s3.length=%d, s1.length=%d, s2.length=%d \n", s3.length, s1.length, s2.length);
}

從這個例子中可以看到,s1+s2 操作中出現了一次 深copy,具體代碼出現在 return 處,tmp對象返回後賦值給s3的過程中,發生了tmp到s3的深度複製。
因爲是深複製,所以會再次生成一個 new char[] ,如果 new char[] 很大,那將會是不必要的性能開銷,能不能像我畫的圖一樣,將 s3 中的 str 指針直接指向 tmp 所持有的 heap 上的 char[] 數組來達到複用目的呢? 肯定是可以的。

這裏需要用 右值引用 + 移動構造函數 讓 s3.str 指向 tmp.str,從而避免複製構造函數,在 StringBuilder 類中加一個方法如下:

StringBuidler(StringBuidler &&s)
{
    this->str = s.str;
    this->length = s.length;
    s.str = nullptr;
}

有了這個移動構造函數後,StringBuilder的加號運算符方法在執行到return語句的時候,深複製的賦值構造函數就沒有了,這個移動構造函數會在 return 處被調用,編譯器會判斷如果是右值的話,自動走移動構造函數,沒有這個函數就會走賦值構造函數。

上面的程序中,s3=s1+s2這一條語句會調用Stringbuilder的加法運算法函數,在函數中的return語句處需要返回的是一個tmp局部變量,因此它此時就是一個tmp臨時變量,因爲在函數結束後它就消亡了,對應的其動態內存也會被析構掉,所以系統在執行return函數之前,需要將tmp對象複製到s3對象中,此處自然就有兩種解決方法:1、調用複製構造函數進行復制;2、使用移動構造函數把即將消亡tmp對象的內存的所有權進行轉移,手動延長tmp對象佔用內存的生命週期。

顯然,前者需要深拷貝操作依次複製全部數據,而後者只需要“變更所有權”即可。

上面的運行結果中第一次析構就是return tmp; 這個臨時對象在轉移完內存所用權之後就析構了。

此處更需要說明的是:遇到這種情況時,編譯器會很智能幫你選擇類內合適的構造函數去執行,如果沒有移動構造函數,它只能默認的選擇複製構造函數,而同時存在移動構造函數和複製構造函數則自然會優先選擇移動構造函數。



3、總結

你有一本書,(對應一個對象A)
你不想看,(這個對象A不需要再使用了)
但我很想看,(需要新建一個一樣的對象B)
那麼我有哪些方法可以讓我能看這本書?
有兩種做法,(兩種做法其實對應的就是拷貝構造函數和移動構造函數)
一種是你直接把書交給我,(對應移動構造函數,資源發生了轉移)
另一種是我去買一些稿紙來,(買一些稿紙意味着重新申請一塊資源)
然後照着你這本書一字一句抄到稿紙上。(把原來的對象A拷貝到對象B,這時存在兩個內容一樣的對象,但對象A用不到了就浪費了)

這個例子用於體現拷貝構造函數和移動構造函數的不同點非常契合。



參考資料
1、https://baijiahao.baidu.com/s?id=1739395293413891144&wfr=spider&for=pc
2、https://www.jianshu.com/p/66e511a11209
3、https://blog.csdn.net/weixin_44788542/article/details/126284429



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章