您的位置:首頁 > 軟件教程 > 教程 > 深入分析C++對象模型之移動構造函數

深入分析C++對象模型之移動構造函數

來源:好特整理 | 時間:2024-04-18 15:31:48 | 閱讀:131 |  標簽: 對象 C c++   | 分享到:

C++11新標準中最重要的特性之一就是引入了支持對象移動的能力,移動語義的加持使得移動一個如容器之類的大對象的成本可以像復制一個指針一樣低廉了,于是出現了各種各樣的傳言:如編譯器會使用移動操作來替代拷貝操作以獲得效率上的提升,甚至說將符合C++98標準的以前的老代碼用符合C++11新標準的編譯器重新

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

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

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

#include 

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

實際上編譯器并沒有生成一個移動構造函數,甚至任何構造函數都沒有生成。因為沒有必要,在這種情況下,編譯器可以做一些優(yōu)化,執(zhí)行按對象的成員逐個復制過去就可以了,不需要生成一個函數來做這個事情。上面匯編代碼的第5、第6行就是將對象d(存放在?臻g[rbp - 8]中)的內容先拷貝到eax寄存器,然后再從寄存器eax拷貝到對象d1(存放在?臻g[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標準中明確規(guī)定了:只要定義了析構函數,編譯器便不再合成出移動構造函數和移動賦值運算符。

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

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

如下面的例子:

#include 
#include 

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調用,它將匹配適合這個成員的操作,即如果成員是可移動則執(zhí)行移動操作,如果不可移動的則執(zhí)行拷貝操作。所以實際上將會調用此成員的拷貝構造函數。

另一種情形,如std::array容器,它是C++11標準新提供的容器類型,功能相當于內建的數組,它不同于別的容器類型將數據存儲在堆中,然后使用指針指向數據,移動容器只需賦值指針,然后將源指針置空即可。array容器的數據是存放在對象上,即使數組里存放的元素類型能提供移動操作,那也得需要一個個地將每個元素執(zhí)行一遍移動操作,這個時間是一個線性時間復雜度。

  • 移動的效率不高

std::string類往往采用了小型字符串優(yōu)化(small string optimization, SSO)的實現手法,SSO是將小型字符串(比如長度小于15個字符)直接存儲在string對象內的緩沖區(qū)中,超過這個長度的則存放在堆上。之所以采用SSO優(yōu)化手法,就是因為在實際應用場景中大多數使用的字符串長度都比較短,這樣可避免頻繁地申請和釋放內存帶來的開銷。在使用了SSO的情況下,移動一個string對象并不比較拷貝來得更快,實際上這種情況移動操作執(zhí)行的是拷貝動作。

  • 移動操作未被調用

即使類中提供的移動操作比拷貝操作的效率明顯要高得多,但是也有可能未能調用到移動操作,依然使用的是拷貝操作,導致實際效果效率不高的問題。比如標準庫中的vector容器,它提供了一個push_back的接口,調用此接口向容器中加入一個元素,這時有可能容器的容量滿了,需要申請一塊更大的內存,然后把原先內存位置的元素搬過去再銷毀掉。vector容器的實現者需要保證這個過程的前后狀態(tài)要保持不變,在移動元素時,如果元素的類型提供了移動功能,那么vector容器就會使用它,但是要求這個移動操作必須是noexcept的,假如移動操作不能保證是noexcept的,vector容器就不會使用它。

試想一下,假如在移動到一半的時候,這時拋出了異常,移動操作隨即停止,這時一半的元素在新空間中,一半的元素在舊的空間中,vector無法恢復到原先的狀態(tài)?截惒僮鲃t不會存在這個問題,假如在拷貝過程中出現問題,那么只需要將新空間的元素和新申請的內存釋放掉,vector的狀態(tài)還是保持不變。

所以如果你的類型中的移動構造函數未加上noexcept聲明,即使類型中的移動操作比對應的拷貝操作的效率要高效得多,編譯器仍會強制去調用拷貝操作而非移動操作。因此建議當你定義自己版本的移動構造函數或移動賦值運算符的時候,要確保不會拋出異常,并在聲明中明確加上noexcept聲明。

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

小編推薦閱讀

好特網發(fā)布此文僅為傳遞信息,不代表好特網認同期限觀點或證實其描述。

相關視頻攻略

更多

掃二維碼進入好特網手機版本!

掃二維碼進入好特網微信公眾號!

本站所有軟件,都由網友上傳,如有侵犯你的版權,請發(fā)郵件[email protected]

湘ICP備2022002427號-10 湘公網安備:43070202000427號© 2013~2025 haote.com 好特網