1. 程式人生 > >Effective Modern C++:08調整

Effective Modern C++:08調整

41:針對可複製的形參,在移動成本低且一定會被賦值的前提下,考慮將其按值傳遞

class Widget {
public:
    void addName(const std::string& newName) 
    { names.push_back(newName); } 
    void addName(std::string&& newName) 
    { names.push_back(std::move(newName)); } 
    …  
private:
    std::vector<std::string> names;
};

          上面的addName函式,針對左值實施複製,右值實施移動。但是它實際上是在兩個函式中做同一件事情,是冗餘程式碼。可以考慮使用萬能引用的函式模板:

template<typename T> 
void addName(T&& newName) {
    names.push_back(std::forward<T>(newName)); 
}

          雖然這消除了冗餘程式碼,但是萬能引用會導致其他方面的複雜性,作為模板,addName的實現通常必須位於標頭檔案中,而且它可能在物件程式碼中產生好幾個函式。

 

         像addName這樣的函式,是否存在一種方法,針對左值實施複製,針對右值實施移動,而且無論在原始碼中,還是目的碼中只有一個函式,還能避免萬能引用的怪癖?這種方法是存在的,就是按值傳遞:

void addName(std::string newName) 
{ names.push_back(std::move(newName)); } 

          這可能顛覆了你的認知,畢竟按值傳遞會帶來效率上的問題。在C++98中,這確實會引發效率問題,無論呼叫方傳入的是什麼,形參newName都會經由複製建構函式建立,但是到了C++11中,newName僅在傳入左值時才會被複制構造,而傳入右值時,會被移動構造(如果編譯器有優化措施的話,實際上可能是直接在引數newName上構造實參,類似於返回值優化那樣。去掉優化措施,則使用移動構造,GCC 5.4.0 20160609測試):

Widget w;
…
std::string name("Bart");
w.addName(name); // call addName with lvalue

w.addName(name + "Jenne"); // call addName with rvalue

         

         雖然按值傳遞看起來乾淨利落,但是還有有一些成本需要考慮。回顧addName的三個版本及其呼叫方式,可以總結如下:

         過載版本,對於左值是一次複製,對於右值是一次移動;使用萬能引用版本,左值是一次複製,右值是一次移動;按值傳遞,對於左值是一次複製加一次移動,對於右值是兩次移動。

         所以,本條款的標題:針對可複製的形參,在移動成本低且一定會被賦值的前提下,考慮將其按值傳遞。使用這樣的措辭是有理由的,具體如下:

         按值傳遞的成本總會更高一些,某些情況下,還會產生另外的成本;僅對於可複製的形參才考慮按值傳遞,不符合這個條件的形參是move-only的,因而也就不需要針對左值和右值分別提供過載版本;按值傳遞僅在形參移動成本低廉的前提下才值得考慮,只有這樣,一次移動帶來的額外成本才是可以接受的,如果這個前提不成立,那麼執行不必要的移動和執行不必要的複製就沒有區別了;而且針對一定會複製的形參才考慮按值傳遞,比如:

void addName(std::string newName) {
    if ((newName.length() >= minLen) &&
        (newName.length() <= maxLen))
    {
        names.push_back(std::move(newName));
    }
}

          該函式只有在滿足一定條件時才複製,在條件不滿足的情況下,還需要付出構造和析構newName的成本。

 

有時候,即使你面對的函式確實是針對可複製型別實施無條件複製,並且移動成本也低廉,但是還是存在不適合採用按值傳遞的一些情況。原因在於,函式可以經由兩種方式來實施複製:構造以及賦值。如果是賦值操作,則情況要複雜的多,比如下面的函式:

class Password {
public:
    explicit Password(std::string pwd)
    : text(std::move(pwd)) {}
    void changeTo(std::string newPwd)
    { text = std::move(newPwd); }
    …
private:
    std::string text; // text of password
};

std::string initPwd("Supercalifragilisticexpialidocious");
Password p(initPwd);
std::string newPassword ="Beware the Jabberwock";
p.changeTo(newPassword);

 傳遞給changeTo的是個左值(newPassword),當構造引數newPwd時,會呼叫複製建構函式,該函式會有申請記憶體的動作,之後,newPwd移動賦值給text,這會造成text原來持有的記憶體被釋放。因此,changeTo函式就有了申請記憶體和釋放記憶體的額外成本。但是實際上,在上面的呼叫中,新密碼要比舊密碼短,實際上沒有必要申請和釋放任何記憶體。如果採用過載的方法的話,就不存在這樣的成本:

void changeTo(const std::string& newPwd) // the overload for lvalues
{ 
    text = newPwd; // can reuse text's memory if text.capacity() >= newPwd.size()
}

 在上面的場景中,記憶體申請和釋放帶來的成本很可能會超過移動操作的成本。有趣的是,如果舊密碼比新密碼更短,則賦值過程中不可避免的要涉及記憶體分配和釋放的成本,這種情況下,按值傳遞又與按引用傳遞沒有太大的區別了。因此,按值傳遞的成本還取決於參與賦值的實參值,這一分析結果適用於可能在動態分配的記憶體中持有值得任何形參型別,比如std::vector和std::string。

這種潛在的成本增加僅存在於左值的情況,因為只有複製時才會設計記憶體的分配和釋放,對於右值而言,使用移動就行了。

所以,當通過賦值實施形參複製時,按值傳參的成本分析會是複雜的。通常情況下,應該總是採用過載或萬能引用而非按值傳遞,除非已經有確鑿的證據表明按值傳遞能夠為所需的形參型別生成可接受效率的程式碼。

 

還有一個與效率無關,不同於按引用傳遞,按值傳遞比較容易遭遇切片問題。因此,欲實現函式以利用可複製右值型別的移動語義,就需要過載或使用萬能引用兩者之一,但這兩者都有一定的缺點,對於可複製的,移動成本低廉的型別,並且傳入的函式總是對其實施複製這種特殊情況,在切片問題也無需擔心的情況下,按值傳遞可以提供一個易於實現的替代方法,他和按引用傳遞的競爭對手效率相近,但是避免了它們的不足。

 

 

42:考慮置入而非插入

std::vector<std::string> vs; 
vs.push_back("xyzzy"); 

          std::vector的push_back針對左值和右值給出了不同的過載版本:

template <class T, class Allocator = allocator<T>>
class vector {
public:
    …
    void push_back(const T& x); // insert lvalue
    void push_back(T&& x); // insert rvalue
};

          上面的呼叫語句中,從字串字面量“xyzzy”建立了一個std::string型別的臨時物件,該物件沒有名字,稱其為temp,它是個右值;temp被傳遞給push_back的右值過載版本,它被繫結到右值引用形參x,然後,會在記憶體中為std::vecotor構造一個x的副本。這一次的構造,結果就是在std::vector內建立了一個新的物件;在push_back返回的那一時刻,temp被析構,所以,就需要呼叫一次std::string的解構函式。

         實際上,還是有方法能使得上面的程式碼效率更加高效。那就是使用emplace_back,它使用傳入的實參在std::vector內直接構造一個std::string,不會涉及任何臨時物件的構造和析構。emplace_back使用了完美轉發,所以只要沒有遇到完美轉發的限制,就可以通過emplace_back傳遞任意型別,任意數量的實參。

         emplace_back可用於支援push_back的標準容器,類似的,所有支援push_front的標準容器也支援emplace_front;還有,任何支援insert的都支援emplace。

         置入函式優於插入函式的原因,是置入函式更加靈活的結構。插入函式接收的是帶插入物件,而置入函式接收的則是待插入物件的建構函式實參。這一區別就讓置入函式可以避免臨時物件的建立和析構,但插入函式就無法避免。即使插入函式不會建立臨時物件的情況下,也可以使用置入函式。那種情況下,插入函式和置入函式本質上做的是同一件事,比如:

std::string queenOfDisco("Donna Summer");
vs.push_back(queenOfDisco); // copy-construct queenOfDisco at end of vs
vs.emplace_back(queenOfDisco); // ditto

          這麼一來,置入函式就能夠做大插入函式所能做到的一切,有時候他們還比後者更加高效,至少理論上不會比後者效率更低。既然如此,為何不總是使用置入函式呢?

        

理論和顯示還有有差距的,有時候,存在插入函式執行的更快的情況,這些情況難以具體描述,因為它取決於傳遞的實參型別,使用的容器型別,請求插入或置入的容器位置,所持有型別建構函式異常安全性等,還有對於禁止出現重複值的容器而言,容器中是否已經存在要新增的值。所以,這裡使用一般的效能調優建議:確定置入或插入哪個執行的更快,需對兩者實施基準測試。

         有些指導性原則可以幫助你決定是使用插入函式還是置入函式,如果下列情況都成立,那麼置入函式將幾乎肯定要比插入更高效:

         A、要新增的值是以構造而非賦值方式加入容器,比如之前的例子,就是從值”xyzzy”構造出std::string型別物件,並加入到std::vector末尾,該位置之前沒有值,所以新值必須以構造方式加入std::vector。但是如果新std::string加入到容器的位置,已經被物件佔用了,則變成另外一種情況:

std::vector<std::string> vs;
vs.emplace(vs.begin(), "xyzzy"); // add "xyzzy" to beginning of vs

          上面的程式碼,很少有實現會將待新增的std::string在vs[0]佔用的記憶體中實施構造。這裡一般會採用移動賦值的方式來讓該值就位。但既然是移動賦值,就會需要一個移動源,這就需要建立一個臨時物件作為移動源,那置入相對於插入的主要優點就在於既不會建立也不會析構臨時物件,所以,置入的優勢也就趨於消失了。

         向容器中新增值究竟是通過構造還是賦值,這取決於實現。但是基本的指導原則是,基於節點的容器幾乎總是使用構造來新增新值,而大多數標準容器都是基於節點的,例外情況是std::vector、std::deque和std::string。在不是基於節點的容器中,可以可靠的說emplace_back是使用構造來將新值就位的,而這一點對於std::deque的emplace_front也成立。

         B、傳遞的實參型別與容器持有物型別不同。置入相對於插入的優勢通常基於這樣一個事實,當傳遞的實參型別並非容器持有之物的型別時,其介面不要求建立和析構臨時物件。當型別為T的物件被新增到container<T>中時,沒有理由期望置入的執行速度會比插入塊,因為並不需要為了滿足插入的介面去建立臨時物件。

         C、容器不太可能由於出現重複情況而拒絕待新增新值。與檢測某值是否已經在容器中,置入的實現通常會使用該新值建立一個節點,以便將該節點的值與容器的現有節點進行比較,如果待新增的值尚不再容器中,則將節點加入到容器中,但是如果該值已經存在,則置入就會終止,節點也就會被析構,這意味著構造和析構的成本被浪費了。這樣的節點會更經常的為置入函式,而非插入函式建立。

 

         在決定是否使用置入函式時,還有其他兩個問題值得考慮。第一個是和資源管理有關,比如下面的程式碼:

std::list<std::shared_ptr<Widget>> ptrs;
void killWidget(Widget* pWidget);

ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));
ptrs.push_back({ new Widget, killWidget });

          由於需要指定刪除器,所以不能使用std::make_shared。上面兩個push_back的呼叫本質上是相同的。不管哪一個,都會建立一個std::shared_ptr的臨時物件。但是如果使用emplace_back,雖然可以避免建立臨時物件,但是在本例中,臨時物件帶來的收益要遠超成本。

考慮下面的情況:上面的push_back呼叫會構造一個std::shared_ptr<Widget>型別的臨時物件,用以持有new Widget返回的裸指標,稱該臨時物件為temp;push_back使用按引用的方式接受temp,在為連結串列節點分配記憶體以持有temp的副本的過程中,丟擲了記憶體不足的異常,該異常傳播到了push_back之外,temp被析構,自然它也就會釋放Widget,也就是呼叫killWidget析構Widget物件。

儘管發生了異常,但是沒有資源洩漏。但是如果使用了emplace_back:

ptrs.emplace_back(new Widget, killWidget);

          從new返回的裸指標會完美轉發,並執行到emplace_back內為連結串列節點分配記憶體的執行點時,然後記憶體分配失敗了,丟擲了記憶體不足的異常;異常傳播到了emplace_back之外,此時還沒有建立std::shared_ptr,因此造成了記憶體洩漏。

         這裡的問題不在於std::shared_ptr,使用std::unique_ptr也會有同樣的問題。從根本上來講,std::shared_ptr和std::unique_ptr這樣的資源管理類若要發生作用,前提是資源會立即傳遞給資源管理物件的建構函式。std::make_shared和std::make_unique這樣的函式就是把這一點自動化了。在呼叫持有資源管理物件的容器的插入函式時,函式的形參型別通常能保證在資源的獲取和對資源管理的物件實施構造之間不再有任何其他動作。但是在置入函式中,完美轉發會推遲資源管理物件的建立,直到它們能夠在容器的記憶體中構造為止。這就可能會導致資源洩露。因此,在處理持有資源管理物件的容器時,必須小心確保在選用了置入而非插入函式時,不會在提升了一點程式碼效率的同時,卻因異常安全的而導致問題。

         坦率的說,絕不應該把new這樣的表示式傳遞給emplace_back、push_back或者大多數其他函式。實際上應該把從new獲得的指標在獨立語句中轉交給資源管理物件,然後將該物件作為右值傳遞給其他函式,所以,使用了push_back的程式碼應該是:

std::shared_ptr<Widget> spw(new Widget, killWidget);
ptrs.push_back(std::move(spw));

std::shared_ptr<Widget> spw(new Widget, killWidget);
ptrs.emplace_back(std::move(spw));

  

         置入函式第二個值得考慮的場景是,它們與帶有explicit宣告飾詞的建構函式之間的互動,比如:

std::vector<std::regex> regexes;
regexes.emplace_back(nullptr); // add nullptr to container of regexes?

          上面向emplace_back傳入nullptr是個無心之舉,但是要查找出該bug卻需要費一番事。實際上,指標根本不是正則表示式,如果你用指標賦值給std::regex,則會報錯:

std::regex r = nullptr; // error! won't compile

          而且,如果你使用push_back而非emplace_back,編譯器同樣會報錯:

regexes.push_back(nullptr); // error! won't compile

          雖然從字串出發可以構造std::regex,但是實際上它對應的建構函式是explicit的,這也就是為什麼上面的語句無法通過編譯的原因,上面的呼叫,我們實際上都要求一次從指標到std::regex的隱式型別轉換,由於建構函式帶有explicit,因而編譯器決絕了。

         但是在使用emplace_back時,我們實際上是向std::regex傳遞的是個建構函式實參。它被編譯器視作下面的程式碼:

std::regex r(nullptr); // compiles

          考慮下面的程式碼:

std::regex r1 = nullptr; // error! won't compile
std::regex r2(nullptr); // compiles

          r1使用所謂的複製初始化,而r2使用的是直接初始化。複製初始化不允許呼叫帶有explicit宣告的建構函式,但是直接初始化是可以的。而對於push_back和emplace_back而言,或者更一般的插入和置入函式而言,置入函式使用的是直接初始化,所以他們能夠呼叫帶有explicit的建構函式,而插入函式使用的複製初始化,就不能呼叫帶有explicit宣告的建構函式。因此:

regexes.emplace_back(nullptr); // compiles. Direct init permits use of explicit std::regex ctor taking a pointer
regexes.push_back(nullptr); // error! copy init forbids use of that ctor

          因此這裡得到的教訓就是,在使用置入函式時,要特別小心保證傳遞了正確的實參,因為即使帶有explicit宣告飾詞的建構函式會被編譯器納入考慮範圍。