天天看點

深入了解移動語義

一直以來,C++中基于值語義的拷貝和指派嚴重影響了程式性能。尤其是對于資源密集型對象,如果進行大量的拷貝,勢必會對程式性能造成很大的影響。為了盡可能的減小因為對象拷貝對程式的影響,開發人員使出了萬般招式:盡可能的使用指針、引用。而編譯器也沒閑着,通過使用RVO、NRVO以及複制省略技術,來減小拷貝次數來提升代碼的運作效率。

但是,對于開發人員來說,使用指針和引用不能概括所有的場景,也就是說仍然存在拷貝指派等行為;對于編譯器來說,而對于RVO、NRVO等編譯器行為的優化需要滿足特定的條件(具體可以參考文章​​編譯器之傳回值優化​​)。為了解決上述問題,自C++11起,引入了移動語義,更進一步對程式性能進行優化 。

C++11新标準重新定義了lvalue和rvalue,并允許函數依照這兩種不同的類型進行重載。通過對于右值(rvalue)的重新定義,語言實作了移動語義(move semantic)和完美轉發(perfect forwarding),通過這種方法,C++實作了在保留原有的文法并不改動已存在的代碼的基礎上提升代碼性能的目的。

本文的主要内容如下圖所示:

深入了解移動語義

值語義

值語義(value sematics)指目标對象由源對象拷貝生成,且生成後與源對象完全無關,彼此獨立存在,改變互不影響,就像int類型互相拷貝一樣。C++的内置類型(bool/int/double/char)都是值語義,标準庫裡的complex<> 、pair<>、vector<>、map<>、string等等類型也都是值語意,拷貝之後就與原對象脫離關系。

C++中基于值語義的拷貝構造和指派拷貝,會招緻對資源密集型對象不必要拷貝,大量的拷貝很可能成為程式的性能瓶頸。

首先,我們看一段例子:

BigObj fun(BigObj obj) {
  BigObj o;
  // do sth
  return o;
}

int main() {
  fun(BigObj());
  return 0;
}      

在上述代碼中,我們定義了一個函數fun()其參數是一個BigObj對象,當調用fun()函數時候,會通過調用BigObj的拷貝構造函數,将obj變量傳遞給fun()的參數。

編譯器知道何時調用拷貝構造函數或者指派運算符進行值傳遞。如果涉及到底層資源,比如記憶體、socket等,開發人在定義類的時候,需要實作自己的拷貝構造和指派運算符以實作​

​深拷貝​

​。然而拷貝的代價很大,當我們使用STL容器的時候,都會涉及到大量的拷貝操作,而這些會浪費CPU和記憶體等資源。

正如上述代碼中所示的那樣,當我們将一個臨時變量(BigObj(),也稱為右值)傳遞給一個函數的時候,就會導緻拷貝操作,那麼我們該如何避免此種拷貝行為呢?這就是我們本文的主題:​

​移動語義​

​。

左值、右值

關于左值、右值,我們在之前的文章中已經有過詳細的分享,有興趣的同學可以移步​​【Modern C++】深入了解左值、右值​​,在本節,我們簡單介紹下左值和右值的概念,以友善了解下面的内容。

左值(lvalue,left value),顧名思義就是指派符号左邊的值。準确來說,左值是表達式結束(不一定是指派表達式)後依然存在的對象。

可以将左值看作是一個關聯了名稱的記憶體位置,允許程式的其他部分來通路它。在這裡,我們将 "名稱" 解釋為任何可用于通路記憶體位置的表達式。是以,如果 arr 是一個數組,那麼 arr[1] 和 *(arr+1) 都将被視為相同記憶體位置的“名稱”。

左值具有以下特征:

  • 可通過取位址運算符擷取其位址
  • 可修改的左值可用作内建指派和内建符合指派運算符的左操作數
  • 可以用來初始化左值引用(後面有講)

C++11将右值分為純右值和将亡值兩種。純右值就是C++98标準中右值的概念,如非引用傳回的函數傳回的臨時變量值;一些運算表達式,如1+2産生的臨時變量;不跟對象關聯的字面量值,如2,'c',true,"hello";這些值都不能夠被取位址。而将亡值則是C++11新增的和右值引用相關的表達式,這樣的表達式通常是将要移動的對象、T&&函數傳回值、std::move()函數的傳回值等。

左值引用、右值引用

在明确了左值和右值的概念之後,我們将在本節簡單介紹下左值引用和右值引用。

按照概念,對左值的引用稱為左值引用,而對右值的引用稱為右值引用。既然有了左值引用和右值引用,那麼在C++11之前,我們通常所說的引用又是什麼呢?其實就是左值引用,比如:

int a = 1;
int &b = a;      

在C++11之前,我們通過會說b是對a的一個引用(當然,在C++11及以後也可以這麼說,大家潛移默化的認識就是引用==左值引用),但是在C++11中,更為精确的說法是b是一個左值引用。

在C++11中,為了區分左值引用,右值引用用&&來表示,如下:

int &&a = 1; // a是一個左值引用
int b = 1;
int &&c = b; // 錯誤,右值引用不能綁定左值      

跟左值引用一樣,右值引用不會發生拷貝,并且右值引用等号右邊必須是右值,如果是左值則會編譯出錯,當然這裡也可以進行強制轉換,這将在後面提到。

在這裡,有一個大家都經常容易犯的一個錯誤,就是綁定右值的右值引用,其變量本身是個左值。為了便于了解,代碼如下:

int fun(int &a) {
  std::cout << "in fun(int &)" << std::endl;
}

int fun(int &&a) {
  std::cout << "in fun(int &)" << std::endl;
}

int main() {
  int a = 1;
  int &&b = 1;
  
  fun(b);
  
  return 0;
}      

代碼輸出如下:

in fun(int &)      

左值引用和右值引用的規則如下:

  • 左值引用,使用T&,隻能綁定左值
  • 右值引用,使用T&&,隻能綁定右值
  • 常量左值,使用const T&,既可以綁定左值,又可以綁定右值,但是不能對其進行修改
  • 具名右值引用,編譯器會認為是個左值
  • 編譯器的優化需要滿足特定條件,不能過度依賴

好了,截止到目前,相信你對左值引用和右值引用的概念有了初步的認識,那麼,現在我們介紹下為什麼要有右值引用呢?我們看下述代碼:

BigObj fun() {
  return BigObj();
}
BigObj obj = fun(); // C++11以前
BigObj &&obj = fun(); // C++11      

上述代碼中,在C++11之前,我們隻能通過編譯器優化(N)RVO的方式來提升性能,如果不滿足編譯器的優化條件,則隻能通過拷貝等方式進行操作。自C++11引入右值引用後,對于不滿足(N)RVO條件,也可以通過避免拷貝延長臨時變量的生命周期,進而達到優化的目的。

但是僅僅使用右值引用還不足以完全達到優化目的,畢竟右值引用隻能綁定右值。那麼,對于左值,我們又該如何優化呢?是否可以通過左值轉成右值,然後進行優化呢?等等

為了解決上述問題,标準引入了移動語義。通移動語義,可以在必要的時候避免拷貝;标準提供了move()函數,可以将左值轉換成右值。接下來,就開始我們本文的重點-移動語義。

移動語義

移動語義是Howard Hinnant在2002年向C++标準委員會提議的,引用其在移動語義提案上的一句話:

移動語義不是試圖取代複制語義,也不是以任何方式破壞它。相反,該提議旨在增強複制語義

對于剛剛接觸移動語義的開發人員來說,很難了解為什麼有了值語義還需要有移動語義。我們可以想象一下,有一輛汽車,在内置發動機的情況下運作平穩,有一天,在這輛車上安裝了一個額外的V8發動機。當有足夠燃料的時候,V8發動機就能進行加速。是以,汽車是值語義,而V8引擎則是移動語義。在車上安裝引擎不需要一輛新車,它仍然是同一輛車,就像移動語義不會放棄值語義一樣。是以,如果可以,使用移動語義,否則使用值語義,換句話說就是,如果燃料充足,則使用V8引擎,否則使用原始預設引擎。

好了,截止到現在,我們對移動語義有一個感官上的認識,它屬于一種優化,或者說屬于錦上添花。再次引用Howard Hinnant在移動語義提案上的一句話:

移動語義主要是性能優化:将昂貴的對象從記憶體中的一個位址移動到另外一個位址的能力,同時竊取源資源以便以最小的代價建構目标

在C++11之前,當進行值傳遞時,編譯器會隐式調用拷貝構造函數;自C++11起,通過右值引用來避免由于拷貝調用而導緻的性能損失。

右值引用的主要用途是建立​

​移動構造函數和移動指派運算符​

​​。移動構造函數和拷貝構造函數一樣,将對象的執行個體作為其參數,并從原始對象建立一個新的執行個體。但是,​

​移動構造函數可以避免記憶體重新配置設定​

​​,這是因為移動構造函數的參數是一個右值引用,也可以說是一個​

​臨時對象​

​,而臨時對象在調用之後就被銷毀不再被使用,是以,在移動構造函數中對參數進行移動而不是拷貝。換句話說,右值引用和移動語義允許我們在使用臨時對象時避免不必要的拷貝。

移動語義通過移動構造函數和移動指派操作符實作,其與拷貝構造函數類似,差別如下:

  • 參數的符号必須為右值引用符号,即為&&
  • 參數不可以是常量,因為函數内需要修改參數的值
  • 參數的成員轉移後需要修改(如改為nullptr),避免臨時對象的析構函數将資源釋放掉

為了友善我們了解,下面代碼包含了完整的移動構造和移動運算符,如下:

class BigObj {
public:
    explicit BigObj(size_t length)
        : length_(length), data_(new int[length]) {
    }

    // Destructor.
    ~BigObj() {
       if (data_ != NULL) {
         delete[] data_;
        length_ = 0;
       }
    }

    // 拷貝構造函數
    BigObj(const BigObj& other)
       : length_(other.length_), data(new int[other.length_]) {
         std::copy(other.mData, other.mData + mLength, mData);
    }

    // 指派運算符
    BigObj& operator=(const BigObj& other) {
         if (this != &other;) {
         delete[] data_;  
         length_ = other.length_;
        data_ = new int[length_];
        std::copy(other.data_, other.data_ + length_, data_);
         }
         return *this;
    }

    // 移動構造函數
    BigObj(BigObj&& other) : data_(nullptr), length_(0) {
        data_ = other.data_;
        length_ = other.length_;

        other.data_ = nullptr;
        other.length_ = 0;
    }

    // 移動指派運算符
    BigObj& operator=(BigObj&& other) {  
      if (this != &other;) {
          delete[] data_;

          data_ = other.data_;
          length_ = other.length_;

          other.data_ = NULL;
          other.length_ = 0;
       }
       return *this;
    }

private:
    size_t length_;
    int* data_;
};

int main() {
   std::vector<BigObj> v;
   v.push_back(BigObj(25));
   v.push_back(BigObj(75));

   v.insert(v.begin() + 1, BigObj(50));
   return 0;
}      

移動構造

移動構造函數的定義如下:

BigObj(BigObj&& other) : data_(nullptr), length_(0) {
        data_ = other.data_;
        length_ = other.length_;

        other.data_ = nullptr;
        other.length_ = 0;
    }      

從上述代碼可以看出,它不配置設定任何新資源,也不會複制其它資源:other中的記憶體被​

​移動​

​到新成員後,other中原有的内容則消失了。換句話說,它​

​竊取​

​了other的資源,然後将other設定為其預設構造的狀态。在移動構造函數中,最最關鍵的一點是,它沒有額外的資源配置設定,僅僅是将其它對象的資源進行了移動,占為己用。

在此,我們假設data_很大,包含了數百萬個元素。如果使用原來拷貝構造函數的話,就需要将該數百萬元素挨個進行複制,性能可想而知。而如果使用該移動構造函數,因為不涉及到新資源的建立,不僅可以節省很多資源,而且性能也有很大的提升。

移動指派運算符

代碼如下:

BigObj& operator=(BigObj&& other) {  
      if (this != &other;) {
          delete[] data_;

          data_ = other.data_;
          length_ = other.length_;

          other.data_ = NULL;
          other.length_ = 0;
       }
       return *this;
    }      

移動指派運算符的寫法類似于拷貝指派運算符,所不同點在于:移動指派預算法會破壞被操作的對象(上述代碼中的參數other)。

移動指派運算符的操作步驟如下:

  1. 釋放目前擁有的資源
  2. 竊取他人資源
  3. 将他人資源設定為預設狀态
  4. 傳回*this

在定義移動指派運算符的時候,需要進行判斷,即被移動的對象是否跟目标對象一緻,如果一緻,則會出問題,如下代碼:

data = std::move(data);      

在上述代碼中,源和目标是同一個對象,這可能會導緻一個嚴重的問題:它最終可能會釋放它試圖移動的資源。為了避免此問題,我們需要通過判斷來進行,比如可以如下操作:

if (this == &other) {
  return *this
}      

生成時機

衆所周知,在C++中有四個特殊的成員函數:預設構造函數、析構函數,拷貝構造函數,拷貝指派運算符。之是以稱之為特殊的成員函數,這是因為如何開發人員沒有定義這四個成員函數,那麼編譯器則在滿足某些特定條件(僅在需要的時候才生成,比如某個代碼使用它們但是它們沒有在類中明确聲明)下,自動生成。這些由編譯器生成的特殊成員函數是public且inline。

自C++11起,引入了另外兩隻特殊的成員函數:移動構造函數和移動指派運算符。如果開發人員沒有顯示定義移動構造函數和移動指派運算符,那麼編譯器也會生成預設。與其他四個特殊成員函數不同,編譯器生成預設的移動構造函數和移動指派運算符需要,滿足以下條件:

  • 如果一個類定義了自己的拷貝構造函數,拷貝指派運算符或者析構函數(這三者之一,表示程式員要自己處理對象的複制或釋放問題),編譯器就不會為它生成預設的移動構造函數或者移動指派運算符,這樣做的目的是防止編譯器生成的預設移動構造函數或者移動指派運算符不是開發人員想要的
  • 如果類中沒有提供移動構造函數和移動指派運算符,且編譯器不會生成預設的,那麼我們在代碼中通過std::move()調用的移動構造或者移動指派的行為将被轉換為調用拷貝構造或者指派運算符
  • 隻有一個類沒有顯示定義拷貝構造函數、指派運算符以及析構函數,且類的每個非靜态成員都可以移動時,編譯器才會生成預設的移動構造函數或者移動指派運算符
  • 如果顯式聲明了移動構造函數或移動指派運算符,則拷貝構造函數和拷貝指派運算符将被隐式删除(是以程開發人員必須在需要時實作拷貝構造函數和拷貝指派運算符)

與拷貝操作一樣,如果開發人員定義了移動操作,那麼編譯器就不會生成預設的移動操作,但是編譯器生成移動操作的行為和生成拷貝操作的行為有些許不同,如下:

  • 兩個拷貝操作是獨立的:聲明一個不會限制編譯器生成另一個。是以如果你聲明一個拷貝構造函數,但是沒有聲明拷貝指派運算符,如果寫的代碼用到了拷貝指派,編譯器會幫助你生成拷貝指派運算符。同樣的,如果你聲明拷貝指派運算符但是沒有拷貝構造函數,代碼用到拷貝構造函數時編譯器就會生成它。上述規則在C++98和C++11中都成立。
  • 兩個移動操作不是互相獨立的。如果你聲明了其中一個,編譯器就不再生成另一個。如果你給類聲明了,比如,一個移動構造函數,就表明對于移動操作應怎樣實作,與編譯器應生成的預設逐成員移動有些差別。如果逐成員移動構造有些問題,那麼逐成員移動指派同樣也可能有問題。是以聲明移動構造函數阻止編譯器生成移動指派運算符,聲明移動指派運算符同樣阻止編譯器生成移動構造函數。

類型轉換-move()函數

在前面的文章中,我們提到,如果需要調用移動構造函數和移動指派運算符,就需要用到右值。那麼,對于一個左值,又如何使用移動語義呢?自C++11起,标準庫提供了一個函數move()用于将左值轉換成右值。

首先,我們看下cppreference中對move語義的定義:

std::move is used to indicate that an object t may be "moved from", i.e. allowing the efficient transfer of resources from t to another object.

In particular, std::move produces an xvalue expression that identifies its argument t. It is exactly equivalent to a static_cast to an rvalue reference type.

從上述描述,我們可以了解為​

​std::move()​

​​并沒有​

​移動​

​​任何東西,它隻是進行類型轉換而已,​

​真正進行資源轉移的是開發人員實作的移動操作​

​。

該函數在STL中定義如下:

template<typename _Tp>
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }      

從上面定義可以看出,std::move()并不是什麼黑魔法,而隻是進行了簡單的類型轉換:

  • 如果傳遞的是左值,則推導為左值引用,然後由static_cast轉換為右值引用
  • 如果傳遞的是右值,則推導為右值引用,然後static_cast轉換為右值引用

使用move之後,就意味着兩點:

  • 原對象不再被使用,如果對其使用會造成不可預知的後果
  • 所有權轉移,資源的所有權被轉移給新的對象

使用

在某些情況下,編譯器會嘗試隐式移動,這意味着您不必使用​

​std::move()​

​。隻有當一個非常量的可移動對象被傳遞、傳回或指派,并且即将被自動銷毀時,才會發生這種情況。

自c++11起,開始支援右值引用。标準庫中很多容器都支援移動語義,以std::vector<>為例,vector::push_back()定義了兩個重載版本,一個像以前一樣将const T&用于左值參數,另一個将T&&類型的參數用于右值參數。如下代碼:

int main() {
  std::vector<BigObj> v;
  v.push_back(BigObj(10));
  v.push_back(BigObj(20));
  
  return 0;
}      

兩個push_back()調用都将解析為push_back(T&&),因為它們的參數是右值。push_back(T&&)使用BigObj的移動構造函數将資源從參數移動到vector的内部BigObj對象中。而在C++11之前,上述代碼則生成參數的拷貝,然後調用BigObj的拷貝構造函數。

如果參數是左值,則将調用push_back(T&):

int main() {
  std::vector<BigObj> v;
  BigObj obj(10);
  v.push_back(obj); // 此處調用push_back(T&)
  
  return 0;
}      

對于左值對象,如果我們想要避免拷貝操作,則可以使用标準庫提供的move()函數來實作(前提是類定義中實作了移動語義),代碼如下:

int main() {
  std::vector<BigObj> v;
  BigObj obj(10);
  v.push_back(std::move(obj)); // 此處調用push_back(T&&)
  
  return 0;
}      

我們再看一個常用的函數swap(),在使用移動構造之前,我們定義如下:

template<class T>
void swap(T &a, T &b) {
    T temp = a; // 調用拷貝構造函數
    a = b; // 調用operator=
    b = temp; // 調用operator=
}      

如果T是簡單類型,則上述轉換沒有問題。但如果T是含有指針的複合資料類型,則上述轉換中會調用一次複制構造函數,兩次指派運算符重載。

深入了解移動語義

而如果使用move()函數後,則代碼如下:

template<class T>
void swap(T &a, T &b) {
    T temp = std::move(a);
    a = std::move(b);
    b = std::move(temp);
}      

與傳統的swap實作相比,使用move()函數的swap()版本減少了拷貝等操作。如果T是可移動的,那麼整個操作将非常高效。如果它是不可移動的,那麼它和普通的swap函數一樣,調用拷貝和指派操作,不會出錯,且是安全可靠的。

深入了解移動語義

經驗之談

對int等基礎類型進行move()操作,不會改變其原值

對于所有的基礎類型-int、double、指針以及其它類型,它們本身不支援移動操作(也可以說本身沒有實作移動語義,畢竟不屬于我們通常了解的對象嘛),是以,對于這些基礎類型進行move()操作,最終還是會調用拷貝行為,代碼如下:

int main()
{
  int a = 1;
  int &&b = std::move(a);

  std::cout << "a = " << a << std::endl;
  std::cout << "b = " << b << std::endl;

  return 0;
}      

最終結果輸出如下:

a = 1
b = 1      

move構造或者指派函數中,請将原對象恢複預設值

我們看如下代碼:

class BigObj {
public:
    explicit BigObj(size_t length)
        : length_(length), data_(new int[length]) {
    }

    // Destructor.
    ~BigObj() {
       if (data_ != NULL) {
         delete[] data_;
        length_ = 0;
       }
    }

    // 拷貝構造函數
    BigObj(const BigObj& other) = default;

    // 指派運算符
    BigObj& operator=(const BigObj& other) = default;

    // 移動構造函數
    BigObj(BigObj&& other) : data_(nullptr), length_(0) {
        data_ = other.data_;
        length_ = other.length_;
    }

private:
    size_t length_;
    int* data_;
};

int main() {
   BigObj obj(1000);
   BigObj o;
   {
    o = std::move(obj);
   }
   
   // use obj;
   return 0;
}      

在上述代碼中,調用移動構造函數後,沒有将原對象回複預設值,導緻目标對象和原對象的底層資源(data_)執行同一個記憶體塊,這樣就導緻退出main()函數的時候,原對象和目标對象均調用析構函數釋放同一個記憶體塊,進而導緻程式崩潰。

不要在函數中使用std::move()進行傳回

我們仍然以Obj進行舉例,代碼如下:

Obj fun() {
  Obj obj;
  return std::move(obj);
}

int main() {
  Obj o1 = fun();
  return 0;
}      

程式輸出:

in Obj()  0x7ffe600d79e0
in Obj(const Obj &&obj)
in ~Obj() 0x7ffe600d79e0      

如果把fun()函數中的std::move(obj)換成return obj,則輸出如下:

in Obj()  0x7ffcfefaa750      

通過上述示例的輸出,是不是有點超出我們的預期????。從輸出可以看出來,第二種方式(直接return obj)比第一種方式少了一次move構造和析構。這是因為編譯器做了NRVO優化。

是以,我們需要切記:如果編譯器能夠對某個函數做(N)RVO優化,就使用(N)RVO,而不是自作聰明使用std::move()。

知己知彼

STL中大部分已經實作移動語義,比如std::vector<>,std::map<>等,同時std::unique_ptr<>等不能被拷貝的類也支援移動語義。

我們看下如下代碼:

class BigObj
{
public:
    BigObj() {
        std::cout<<__PRETTY_FUNCTION__<<std::endl;
    }
    ~BigObj() {
        std::cout<<__PRETTY_FUNCTION__<<std::endl;
    }
    BigObj (const BigObj &b) {
        std::cout<<__PRETTY_FUNCTION__<<std::endl;
    }
    BigObj (BigObj &&b) {
        std::cout<<__PRETTY_FUNCTION__<<std::endl;
    }
};

int main() {
  std::array<BigObj, 2> v;
  auto v1 = std::move(v);

  return 0;
}      

上述代碼輸出如下:

BigObj::BigObj()
BigObj::BigObj()
BigObj::BigObj(BigObj&&)
BigObj::BigObj(BigObj&&)
BigObj::~BigObj()
BigObj::~BigObj()
BigObj::~BigObj()
BigObj::~BigObj()      

而如果把main()函數中的std::array<>換成std::vector<>後,如下:

int main() {
  std::vector<BigObj> v;
  v.resize(2);
  auto v1 = std::move(v);

  return 0;
}      

則輸出如下:

BigObj::BigObj()
BigObj::BigObj()
BigObj::~BigObj()
BigObj::~BigObj()      

從上述兩處輸出可以看出,std::vector<>對應的移動構造不會生成多餘的構造,且原本的element都移動到v1中;而相比std::array<>中對應的移動構造卻有很大的差別,基本上會對每個element都調用移動構造函數而不是對std::array<>本身。

是以,在使用std::move()的時候,最好要知道底層的基本實作原理,否則往往會得到我們意想不到的結果。

結語

終于寫完了。

這篇文章斷斷續續寫了三周,期間也查了大量的資料。但畢竟是基于自己的了解,畢竟有了解不到位的地方,可以留言或者加好友,直接溝通。

好了,今天的文章就到這,我們下期見!

作者:高性能架構探索

繼續閱讀