深入分析C++物件模型之移動建構函式

iShare_爱分享發表於2024-04-18

接下來我將持續更新“深度解讀《深度探索C++物件模型》”系列,敬請期待,歡迎關注!也可以關注公眾號:iShare愛分享,自動獲得推文和全部的文章列表。

C++11新標準中最重要的特性之一就是引入了支援物件移動的能力,為了支援移動的操作,新標準引入了一種新的引用型別——右值引用,右值引用一個重要的性質就是隻能繫結到一個將要銷燬的物件。對物件執行移動操作後要確保源物件處於可析構的狀態,源物件隨時可能被銷燬,所以程式在之後不要再去使用源物件的值,同時也要保證源物件析構之後不會對移入物件產生副作用。移動語義的加持使得移動一個如容器之類的大物件的成本可以像複製一個指標一樣低廉了,於是出現了各種各樣的傳言:如編譯器會使用移動操作來替代複製操作以獲得效率上的提升,甚至說將符合C++98標準的以前的老程式碼用符合C++11新標準的編譯器重新編譯一次,一行程式碼未改即可獲得執行速度上質的提升。對於種種傳聞,事實上是否如此?接下來讓我們撥開層層迷霧,來一探究竟,看完這篇文章,你的心中就會有答案。

為了支援物件的移動,新標準新增了移動建構函式和移動賦值運算子,移動建構函式和移動賦值運算子的情形類似,所以放在一起討論。對於傳聞中如果程式中沒有定義移動建構函式,那麼編譯器就會幫助程式生成一個移動建構函式這一說法是否可靠?我們以實際的程式碼來分析一下,由於移動建構函式需要一個右值引用作為第一個引數,測試程式碼中可以使用標準庫裡的move函式來產生一個右值引用,move函式其實就是一個型別轉換,它可以把一個左值轉換成右值引用。看看下面的程式碼是否編譯器會合成出來移動建構函式:

#include <utility>

class Object {
    int a;
};

int main() {
    Object d;
    Object d1 = std::move(d);
    
    return 0;
}

把它編譯成彙編程式碼看一下:

main:						# @main
    push    rbp
    mov     rbp, rsp
    mov     dword ptr [rbp - 4], 0
    mov     eax, dword ptr [rbp - 8]
    mov     dword ptr [rbp - 16], eax
    xor     eax, eax
    pop     rbp
    ret

實際上編譯器並沒有生成一個移動建構函式,甚至任何建構函式都沒有生成。因為沒有必要,在這種情況下,編譯器可以做一些最佳化,執行按物件的成員逐個複製過去就可以了,不需要生成一個函式來做這個事情。上面彙編程式碼的第5、第6行就是將物件d(存放在棧空間[rbp - 8]中)的內容先複製到eax暫存器,然後再從暫存器eax複製到物件d1(存放在棧空間[rbp - 16]中)。

那麼在什麼情況下才會合成出來移動建構函式呢?

編譯器合成移動建構函式的條件

編譯器只有在以下的這些情況下才會合成出來移動建構函式:

  1. 類中沒有定義複製建構函式、複製賦值運算子、解構函式;且:
  2. 類的定義中有一個類型別的成員,這個類成員定義了移動建構函式;或者:
  3. 繼承的父類中定義了移動建構函式;或者:
  4. 類中定義了或者從父類中繼承了一個以上的虛擬函式;或者:
  5. 類的繼承鏈上有一個父類是virtual base class。

在上面C++程式碼的Object類中增加一個std::string型別的成員,std::string是標準庫中提供的操作字串的類,類中有定義了移動建構函式。Object類定義如下:

class Object {
    std::string s;
    int a;
};

把它編譯成彙編程式碼,可以看到這下彙編程式碼變得很多,不光生成了Object類的移動建構函式,還有預設建構函式和解構函式。main函式的彙編程式碼如下:

main:							# @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 96
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 48]
    call    Object::Object() [base object constructor]
    lea     rdi, [rbp - 88]
    lea     rsi, [rbp - 48]
    call    Object::Object(Object&&) [base object constructor]
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 88]
    call    Object::~Object() [base object destructor]
    lea     rdi, [rbp - 48]
    call    Object::~Object() [base object destructor]
    mov     eax, dword ptr [rbp - 4]
    add     rsp, 96
    pop     rbp
    ret

上面彙編程式碼的第7行呼叫了Object類的預設建構函式,因為string類裡也定義了預設建構函式,所以這裡需要去呼叫它,具體分析可見另外一篇的分析文章。第10行實際上就是呼叫Object類的移動建構函式了,在Object類的移動建構函式里會去呼叫string類的移動建構函式。所以可以推測出來,只有需要呼叫類型別成員的移動建構函式的時候編譯器才會合成一個移動建構函式出來,在合成的移動建構函式中去呼叫它,上面的第3種情況也類似,第4和第5種情形是因為編譯器需要重設虛表指標,所以也會生成一個移動建構函式來完成,這些情形跟合成複製建構函式的機制是類似的,具體的分析可以見《編譯器背後的行為之複製建構函式》這篇文章,這裡就不再一一贅述了。

編譯器抑制合成移動建構函式的情形

雖然說合成移動建構函式的時機和合成複製建構函式的類似,但是合成移動建構函式的條件要比合成複製建構函式要苛刻得多,在以下的情形中,移動建構函式的合成將受到抑制,編譯器不會合成一個移動建構函式出來。

  • 類中只要定義了複製建構函式、複製賦值運算子和解構函式的其中一個,編譯器就不會合成移動建構函式

有這麼一個指導原則,叫做Rule of Three,大意是:主要你定義了複製建構函式、複製賦值運算子、解構函式中的一個,你就必須要全部定義它們。原因就是既然你需要自己實現複製的操作,說明這裡需要管理資源,比如記憶體的申請和釋放,在複製建構函式里需要管理資源,意味著在複製賦值運算子函式里也需要,反之亦然,同時也需要在解構函式中釋放資源。由此可以得出的推論就是如果你定義了這其中的一個函式,說明有資源需要特別處理,那麼編譯器合成出來的移動建構函式可能就不是你想要的效果,甚至破壞程式的邏輯,引起潛在的bug,所以編譯器就不會合成出來移動建構函式。

按照上面的推論,如果定義了解構函式,那麼編譯器就不應該生成複製建構函式和複製賦值運算子了,但是C++98標準中卻留下了一個“bug“:在定義了解構函式之後,編譯器還是會在有需要的時候合成出複製建構函式和複製賦值運算子,C++11標準為了相容C++98,同樣地也允許合成出來,但是對於移動建構函式和移動賦值運算子,C++11標準中明確規定了:只要定義了解構函式,編譯器便不再合成出移動建構函式和移動賦值運算子。

如果你的程式碼中沒有定義上面的三種函式,你的類中的成員也是可以移動的,編譯器在這時也為程式合成出了移動建構函式或者移動賦值運算子,如果這一切正符合你的本意,那麼這種情況下建議你,最好在你的程式碼中把移動建構函式或移動賦值運算子用=default顯示地宣告出來。原因在於,假如有一個類,類中有一個容器,容器存放了大量的資料,類中沒有定義複製建構函式和解構函式等,編譯器也合成了移動建構函式,使得物件的移動非常高效。但是突然有天來個需求,需要在物件的構造和析構時記錄下來,於是你增加了建構函式和解構函式以滿足需求,但是加入程式碼重新編譯之後發現程式執行的效率變差了,甚至有可能差了幾個數量級,根源在於你定義了解構函式之後,編譯器便不再合成移動建構函式了,而是用複製操作替換了移動的操作,所以顯示地宣告它們是一種好的習慣,儘管我們不需要實現這個函式的程式碼,所以使用=default讓編譯器來自動生成。

  • 如果類的定義中有一個類型別的成員或者繼承自一個父類,這個類成員或者父類裡的移動建構函式或者移動賦值運算子被定義為刪除的(=delete)或者是不可訪問的(定義為private),那麼此類的移動建構函式或者移動賦值運算子被定義為刪除的。

如下面的例子:

#include <utility>
#include <string>

class Base {
public:
    Base() = default;
    Base(Base&& rhs) = delete;
    int b;
};

class Object {
public:
    Base b;
    std::string s;
    int a;
};

int main() {
    Object d;
    Object d1 = std::move(d);	// 這行編譯不透過。
    
    return 0;
}

上面的例子中,編譯器不再會生成移動建構函式和複製建構函式,所以第20行的程式碼將編譯不透過,因為沒有複製建構函式或移動建構函式供呼叫。

  • 如果類的解構函式被定義為刪除的或不可訪問的,那麼此類的移動建構函式被定義為刪除的。

移動操作並未使效率更高的情況

在某些情況下,移動建構函式或移動賦值運算子被正確地合成出來或者由程式設計師定義出來了,但是程式卻並未如預期的提升執行效率,如以下的場景:

  • 沒有移動操作

假如類中有了移動建構函式(合成的或者使用者定義的),同時類中有一個類型別的成員,這個成員剛好存放著大量資料,而此成員的類定義中沒有定義移動建構函式,因此它只可以複製而不能移動。當對物件實施move操作時,實際上將會對物件的每個成員依次遞迴地實施move呼叫,它將匹配適合這個成員的操作,即如果成員是可移動則執行移動操作,如果不可移動的則執行複製操作。所以實際上將會呼叫此成員的複製建構函式。

另一種情形,如std::array容器,它是C++11標準新提供的容器型別,功能相當於內建的陣列,它不同於別的容器型別將資料儲存在堆中,然後使用指標指向資料,移動容器只需賦值指標,然後將源指標置空即可。array容器的資料是存放在物件上,即使陣列裡存放的元素型別能提供移動操作,那也得需要一個個地將每個元素執行一遍移動操作,這個時間是一個線性時間複雜度。

  • 移動的效率不高

std::string類往往採用了小型字串最佳化(small string optimization, SSO)的實現手法,SSO是將小型字串(比如長度小於15個字元)直接儲存在string物件內的緩衝區中,超過這個長度的則存放在堆上。之所以採用SSO最佳化手法,就是因為在實際應用場景中大多數使用的字串長度都比較短,這樣可避免頻繁地申請和釋放記憶體帶來的開銷。在使用了SSO的情況下,移動一個string物件並不比較複製來得更快,實際上這種情況移動操作執行的是複製動作。

  • 移動操作未被呼叫

即使類中提供的移動操作比複製操作的效率明顯要高得多,但是也有可能未能呼叫到移動操作,依然使用的是複製操作,導致實際效果效率不高的問題。比如標準庫中的vector容器,它提供了一個push_back的介面,呼叫此介面向容器中加入一個元素,這時有可能容器的容量滿了,需要申請一塊更大的記憶體,然後把原先記憶體位置的元素搬過去再銷燬掉。vector容器的實現者需要保證這個過程的前後狀態要保持不變,在移動元素時,如果元素的型別提供了移動功能,那麼vector容器就會使用它,但是要求這個移動操作必須是noexcept的,假如移動操作不能保證是noexcept的,vector容器就不會使用它。

試想一下,假如在移動到一半的時候,這時丟擲了異常,移動操作隨即停止,這時一半的元素在新空間中,一半的元素在舊的空間中,vector無法恢復到原先的狀態。複製操作則不會存在這個問題,假如在複製過程中出現問題,那麼只需要將新空間的元素和新申請的記憶體釋放掉,vector的狀態還是保持不變。

所以如果你的型別中的移動建構函式未加上noexcept宣告,即使型別中的移動操作比對應的複製操作的效率要高效得多,編譯器仍會強制去呼叫複製操作而非移動操作。因此建議當你定義自己版本的移動建構函式或移動賦值運算子的時候,要確保不會丟擲異常,並在宣告中明確加上noexcept宣告。

如果您感興趣這方面的內容,請在微信上搜尋公眾號iShare愛分享或者微訊號iTechShare並關注,以便在內容更新時直接向您推送。

相關文章