深入理解移動語義

高效能架構探索發表於2022-03-29

本文始發於公眾號【高效能架構探索】,原文連結:深入理解移動語義

一直以來,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()的時候,最好要知道底層的基本實現原理,否則往往會得到我們意想不到的結果。

結語

終於寫完了。

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

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

作者:高效能架構探索
本文首發於公眾號【高效能架構探索】
個人技術部落格: 高效能架構探索

相關文章