Effective Modern C++ 條款29 假設移動操作是不存在的、不廉價的、不能用的

假設移動操作是不存在的、不廉價的、不能用的

有人爭論,移動語義是C++11最重要的特性。“現在移動容器就像拷貝幾個指針一樣廉價!”你可能會聽到過這個,“拷貝臨時對象現在很高效,避免拷貝臨時對象的代碼相當於過早優化(premature optimization)!”這樣的觀點很容易理解。移動語義的確是個很重要的特性。它不只是允許編譯器用相對廉價的移動來代替昂貴的拷貝操作,它實際上是要求編譯器這樣做(當滿足條件時)。在你C++98的舊代碼基礎上,用適應C++11的編譯器和C++11標準庫重新編譯,然後——釋放洪荒之力——你的程序就變快了。

移動語義真的可以辦到那事情,而那授予了這特性傳奇般的光環。但是呢,傳奇,一般都是被誇出來的。本條款的目的就是爲了讓你認清現實。

讓我們從觀察那些不支持移動的類型開始吧。因爲C++11支持移動操作,而移動比拷貝快,所以C++98標準庫被大改過,這些標準庫的實現利用了移動操作的優勢,但是你的舊代碼沒有爲C++11而改過啊。對於在你應用裏(或在你使用的庫裏)的一些類型,並沒有爲C++11進行過改動,所以編譯器支持的移動操作對你這些類型可能一點幫助都沒有。是的,C++11願意爲缺乏它們的類生成移動操作,但那隻會發生在沒有聲明拷貝操作、移動操作、析構函數的類中(看條款17)。如果成員變量或基類禁止移動(例如,刪除移動操作——看條款11),也會以致編譯器生成移動操作。對於不是顯式支持移動操作和沒有資格讓編譯器生成移動操作的類型,沒有理由期望C++11的性能比C++98好。

就算一些類型顯式支持移動操作,它們也沒有你想象中那樣有益。例如,C++所有的容器都支持移動操作,但認爲移動所有容器都是廉價的觀點是錯誤的。這是因爲,對於一些容器,移動它們的內容真心不廉價;而對於另外的容器,它們提供的廉價移動操作在元素不滿足條件時會發出警告。

看下std::array,C++11的一個新容器。std::array本質上是個擁有STL接口的內置數組,它與其它標準容器不同,其它容器都把它的內容存儲在堆上。這種容器類型(不同於std::array的容器)的對象,在概念上,只持有一個指針(作爲成員變量),指向存儲容器內容的堆內存。(實際情況更復雜,但爲了這裏的分析,區別不是很重要。)這個指針的存在使得用常量時間移動一個容器的內容成爲可能:把指向容器內容的指針從源容器拷貝到目的容器,然後把源指針設置爲空:

std::vector<Widget> vw1;

// 把數據放到vw1
...
// 把vw1移動到vw2,只需常量時間
//  只有vw1和vw2的指針被修改
auto vw2 = std::move(vw1);

這裏寫圖片描述


std::array缺少這樣的指針,因爲std::array存儲的數據直接存儲在std::array對象中:

std::array<Widget, 10000> aw1;

// 把數據放到aw1
...
//把aw1移到到aw2。需要線性時間。
// aw1中所有的元素都被移動到aw2
auto aw2 = std::move(aw1);

這裏寫圖片描述

請注意,aw1中所有的元素都被移動到aw2。假設Widget類型的移動操作比拷貝操作快,那麼移動一個元素爲Widget類型的std::array將比拷貝它要快,所以std::array肯定支持移動操作。移動和拷貝一個std::array都需要線性時間的計算複雜度,因爲容器中的每個元素都需要被移動或拷貝,這和我們聽到的“現在移動一個容器就像拷貝幾個指針一樣廉價”的宣言相差很遠啊。

另一方面,std::string提供常量時間的移動和線性時間的拷貝。聽起來,移動比拷貝快,但實際上不是這樣的。許多string的實現都使用了small string optimization(SSO),通過SSO,“small”string(例如,那些容量不超過15字符的string)會被存儲到std::string對象內的一個緩衝區中;不需要使用堆分配的策略。移動一個基於SSO實現的small string不比拷貝它快,因爲一般的移動操作拷貝單個指針的把戲在這裏不適用。

SSO存在的動機是:有大量證據表明在大多數應用中普遍使用短字符串。使用內部緩衝區存儲string的內容可以消除動態分配內存的需求,而這通常贏得效率。但是,這個實現移動不比拷貝快,也可以反過來說,對於這種string,拷貝不比移動慢。

儘管一些類型支持快速的移動操作,但是一些看似一定會使用移動的場合最終使用了拷貝。條款14解釋過標準庫一些容器操作提供異常安全保證,然後爲了確保C++98舊代碼依賴的保證不會因程序提升到C++11而被打破,只有當移動操作不拋異常時,纔會把內部的拷貝當作替換成移動操作。結果就是:即使一個類提供移動操作,這個移動操作相對拷貝操作高效很多,即使在代碼的某個位置,移動操作是合適的(例如,源對象是個右值),編譯器可能仍然會使用拷貝操作,因爲它對應的移動操作沒有聲明爲noexcept


因此在下面幾種情況下,C++11的移動語義對你沒好處:

  • 沒有移動操作。需要被移動的對象拒絕提供移動操作,結果是移動請求會變成拷貝請求。
  • 移動的速度不快。需要被移動的對象有移動操作,但是不比拷貝操作快。
  • 不能使用移動操作。在一些進行移動操作的上下文中,要求移動操作不能發出異常,但移動操作沒有被聲明爲noexcept

還有一種情況,移動語義不會提升性能,在這裏也值得被提起:

  • 源對象是個左值。只有右值纔有可能作爲移動操作的源對象,除去極少數例外。

不過,本條款的標題是假設移動操作是不存在的、不廉價的、不能用的。這指的是在通用代碼的通常情況下,例如,當寫模板的時候,因爲你不知道該模板爲哪些類型工作。在這種情況下,你必須像C++98那樣(在移動語義出現之前)保守地拷貝對象。這也適用於“不穩固”的代碼中,即被使用的類的特性會相對頻繁改動的代碼。

但是,你經常會知道代碼使用的類型,然後你可以依賴它們不會改動的特性(例如,它們是否會支持不昂貴的移動操作)。當在這種情況下,你不需要做這個假設,你可以簡單地查詢你使用的類的移動細節。如果那些類提供廉價的移動操作,然後你使用的對象又在可以調用移動操作的語境,你可以安全地依賴移動語義,用開銷更小的移動操作替換掉拷貝操作。


總結

需要記住的2點:

  • 假設移動操作是不存在的、不廉價的、不能用的。
  • 在知道類型或支持移動語義的代碼中,不需要這個假設。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章