Effective Modern C++ 條款25 對右值引用使用std::move,對通用引用使用std::forward

對右值引用使用std::move,對通用引用使用std::forward

右值引用只能綁定有移動機會的對象。如果你有個右值引用參數,那麼你要知道它綁定的對象可能要被移動:

class Widget {
    Widget(Widget&& rhs);  // rhs要綁定一個有移動機會的對象
    ...
};

情況既然是這樣,你將想要把這樣的對象,傳遞給那些以對象右值性質爲優勢的其它函數。因此,我們需要把綁定到右值對象的參數轉化爲右值。就如條款23所說,std::move不僅是這樣做的,它就是以這個爲目的創建出來的:

class Widget {
public:
    Widget(Widget&& rhs)    // rhs是個右值引用
    : name(std::move(rhs.name)),
      p(std::move(p))
      { ... }
    ...

private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

另一方便(條款24),通用引用有可能綁定一個有移動機會的對象,當初始值爲右值時,通用引用應該被轉換爲右值。條款23說明這完全是std::forward做的事情:

class Widget {
public:
    template<typename T>
    void setName(T&& newName)    // newName是個通用引用
    { name = std::forward<T>(newName); }

    ...
};

簡而言之,當把右值引用轉發給其他函數時,右值引用應該無條件轉換爲右值(藉助std::move),因爲右值引用總是綁定右值。而當把通用引用轉發給其他函數時,通用引用應該有條件地轉換爲右值(藉助std::forward),因爲通用引用只是有時候會綁定右值。

條款23說明對右值引用使用std::forward會表現出正確的行爲,但是源代碼會是冗長的、易錯的、不符合語言習慣的,因此你應該避免對右值引用使用std::forward。更糟的想法是對通用引用使用std::move,因爲它可以對不希望被改變的左值使用(例如,局部變量):

class Widget {
public:
    template<typename T>
    void setName(T&& newName)  // newName是個通用引用
    { name = std::move(newName); }  // 可以編譯,不過太糟了!太糟了!
    ...

private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName();    // 工廠函數

Widget w;

auto n = getWidgetName();   // n 是個局部變量

w.setName(n);    // 把n移動到w

...             // 到了這裏n的值是未知的

在這裏,局部變量n被傳遞給setName,調用者的行爲是可以被原諒的,畢竟這裏是個只讀操作。但是因爲在setName裏面使用了std::move,它無條件地把引用參數轉換爲右值,那麼n的值將會被移動到w.name,當n從setName返回時,它的值是未知的。這種行爲會讓調用者絕望——很可能暴怒。

你可能覺得setName的參數不應該聲明爲通用引用,這樣的引用不能是const的(看條款24),而setName是肯定不會修改參數的。你可能會指出如果setName簡單地對const左值和右值重載,所有的問題都可以避免。就像這樣:

class Widget {
public:
    void setName(const std::string& newName)  // 由const左值設置
    { name = newName; }

    void setName(std::string&& newName)  // 由右值設置
    { name = std::move(newName); } 

    ...
};

這是可以工作的,不過它有缺點。第一,它有更多的源代碼要寫和維護(用了兩個函數代替一個模板函數),第二,它效率更低。例如,這樣使用setName:

w.setName("Adela Novak");

在接受通用引用的setName版本中,字符串“Adela Novak”將會傳遞給setName,然後在w裏面表達爲std::string的賦值操作。因此w裏的name成員變量直接被字符串賦值,沒有產生std::string臨時對象。而在重載的setName版本中,會創建一個臨時對象(用const char*創建string),然後setName的參數綁定到這個臨時對象,再把這個臨時對象移動到w的成員變量中。因此,調用一次setName需要執行一次std::string的構造函數(爲了創建臨時對象),和一次std::string的移到賦值操作符(爲了把newName移到到w.name),還有一次std::string的析構函數(爲了銷燬臨時string對象)。這種執行順序幾乎肯定會比單獨使用接受const char*指針的std::string移動構造函數昂貴。額外的開銷可能會根據實現的不同而不同,而這筆開銷是否值得又要根據應用和庫的不同而不同,但事實是,使用一個接受通用引用的模板代替這兩個重載函數可能會在某些情況下減少運行時開銷。如果我們的例子中的Widget的成員變量可以任意類型(而不是被人熟知的std::string),那麼性能的差距可能會進一步拉大,因爲不是所有類型的移動操作都像**std::string那麼便宜(看條款29)。

但是,使用兩個重載函數的最嚴重的問題,不是冗長易錯的源代碼,也不是代碼的運行時效率,而是這種設計的可擴展性差。Widget::setName只接受一個參數,所以只需重載兩個函數,但如果函數有更多的參數,每個參數都可以是左值或右值,那麼重載函數的數量會成幾何增加:n個參數需要2^n個重載。然而這根本不值得,一些函數——實際上是模板函數——可以接受無限個參數,每個都可以是左值和右值。典型的代表是std::make_shared,還有在C++14中的std::make_unique。它們的聲明如下:

template<class T, class... Args>
shared_ptr<T> make_shared(Args&&... args);   // C++11標準庫

template<class T, class... Args>
unique_ptr<T> make_unique(Args&&... args);  // C++14標準庫

對於這種函數,用左值和右值重載根本不可能:只有通用引用纔是正確的方式。而在這些函數裏面,我向你保證,當把通用引用轉發給其他函數時,都使用了std::forward,這也是你應該做的。

最後呢,在某些情況中,你想要在單獨的函數中多次使用被通用引用或者右值引用綁定的對象,那麼你應該確保這個對象不會被移動,除非你完成了工作。在那種情況,你只應在最後一次使用那個引用時,才用std::move(對右值引用)或std::forward(對通用引用)。例如:

template<typename T>
void setSignText(T&& text)   // text是個通用引用
{ 
    sign.setText(text);      // 使用text,但不修改它

    auto now = std::chrono::system_clock::now();  // 獲取當前時間

    signHistory.add(now, std::forward<T>(text));  // 有條件地把text轉換爲右值
}

在這裏,我們要確保text的值不會被sign.setText改變,因爲我們在調用signHistory.add時還想要用這個值。因此,在最後一次使用這個通用引用時纔對它使用std::forward

對於std::move,想法是一樣的(即最後一次使用右值引用時纔對它使用std::move),但在你要注意再極少數情況下,你需要用std::move_if_noexcept來代替std::move。想知道什麼時候和爲什麼的話,去看條款14。


如果你有個函數是通過值返回,然後你函數內返回的是被右值引用或通用引用綁定的對象,那麼你應該對你返回的對象使用std::movestd::forward。想知道爲什麼,考慮一個把兩個矩陣相加的operator+函數,而左邊的矩陣參數被指定爲右值(因此可以用這個參數來存儲相加的結果):

Matrix     // 通過值返回
operator+(Matrix&& lhs, const Matrix& rhs)
{
    lhs += rhs;
    return std::move(lhs);      // 把lhs移動到返回值
}

在返回語句中,通過把lhs轉換爲一個右值(藉助std::move),lhs會被移動到函數的返回區。如果省略了std::move的調用,

Matrix
operator+(Matrix&& lhs, const Matrix& rhs)
{
    lhs += rhs;
    return lhs;            // 把lhs拷貝到返回值
}

事實上,lhs是個左值 ,它會強迫編譯器把它的值拷貝到返回區。假如Matrix類型支持移動構造,它比拷貝構造效率更高,那麼在返回語句中使用std::move會產生更高效的代碼。

如果Matrix類型不支持移動構造,把它轉換爲右值也是無傷害的,因爲此事右值會簡單地作爲參數來調用拷貝構造函數(看條款23)。如果Matrix後來被修改爲支持移動,那麼在下次編譯operator+會自動地提高效率。這種情況下,返回值爲右值引用,對該右值引用使用std::move不會損失任何東西(而且可能得到一些好處)。

這種情況,對於通用引用和std::forward是相似的。考慮一個模板函數reduceAndCopy,它的參數是一個可能沒減少過的Fraction對象,然後函數的行爲是把它減少,然後返回減少後的Fraction對象。如果最開始的對象是個右值,它的值應該被移到到返回值中(因此避免進行拷貝的開銷),但如果最開始的對象是個左值,那麼應該進行拷貝。因此:

template<typename T>
Fraction       // 通過值返回
reduceAndCopy(T&& frac)     // 通用引用參數
{
    frac.reduce();
    return std::forward<T>(frac);  // 把右值移動到返回值,而拷貝左值
}

如果省略了std::forward的調用,那麼frac會無條件地被拷貝到reduceAndCopy的返回值中。

一些開發者得知了上面的信息後,嘗試在一些原本不該使用的場合進行拓展使用。“如果對放回值爲右值引用的參數使用std::move,執行的拷貝構造會變成移動構造”,他們振振有詞,“我也可以對返回的局部變量做通用的優化。”換句話說,他們認爲假如一個函數通過值語義返回一個局部變量,就像這樣,

Widget makeWidget()          // “拷貝”版本的makeWidget
{
    Widget w;      // 局部變量

    ...           // 配置w

    return w;     //  把w“拷貝”到返回值
}

開發者可以“優化”這份代碼,把“拷貝”變成移動:

Widget makeWidget()     // 移動版本的makeWidget
{
    Widget w;
    ...
    return std::move(w);  // 把w移動到返回值中(實際上沒有這樣做)
}

我的註釋已經提示你這樣的想法是錯誤的,但爲什麼是錯誤的呢?

這想法是錯誤的,因爲標準委員會早就想到想到這種優化了。長期被公認的是:在“拷貝”版本的makeWidget中,通過在分配給函數返回值的內存中直接構造w,從而避免拷貝局部變量w(意思是直接在返回區創建w,這樣返回時就不用把局部變量w拷貝到返回區了)。這稱爲return value optimization(RVO),這被標準庫明文規定了。

制定這樣的規定是件很麻煩的事情,因爲你只有在不影響程序行爲的情況下才想要允許這樣的拷貝省略(copy elision)。把標準庫那墨守成規(可以說是有毒的)的規則進行意譯,這個特殊的規則講的是在通過值返回的函數中,如果(1)一個局部變量的類型和返回值的類型相同,而且(2)這個局部變量是被返回的對象,那麼編譯器可能會省略局部變量的拷貝(或移動)。記住這點,再看一次“拷貝”版本的makeWidget:

Widget makeWidget()      // “拷貝”版本的makeWidget
{
    Widget w;
    ...
    return w;      // 把w“拷貝”到返回值
}

兩個條件都滿足,你要相信我,這份代碼在每個正規的C++編譯器面前,都會進行RVO優化來避免拷貝w。這意味着“拷貝”版本的makeWidget實際上沒有拷貝任何東西。

移動版本的makeWidget只是做了它名字意義上的事情(假定Widget提供移動構造):把w的內容移動到makeWidget的返回區。但爲什麼編譯器不使用RVO來消除移動,直接在分配給函數返回值的內存中構造w呢?答案很簡單:它們不行。條件(2)明確規定返回的是個局部對象,但移動版本的makeWidget的行爲與此不同。再看一次返回語句:

return std::move(w);

這裏返回的不是局部變量w,而是個對w的引用——std::move(w)的結果。返回一個對局部變量的引用不滿足RVO的條件,所以編譯器必須把w移動到函數的返回區。開發者想要對返回的局部變量使用std::move來幫助編譯器優化,實際上卻是限制了編譯器可選的優化選項!

不過RVO只是一種優化方式,編譯器有時候不會省略拷貝和移動操作,儘管優化被允許。你可能會過分猜疑,然後你擔心你的編譯器會嚴厲對待拷貝操作,因爲它們可以這樣。或者你有足夠的洞察力來辨認那些情況RVO難以實現,例如,當一個函數中有不同的控制流返回不同的局部變量時。(編譯器會在分配給返回值的內存中構建合適的局部變量,但是編譯器怎麼知道返回哪個局部變量合適呢?)如果是這樣的話,比起拷貝的開銷,你可能更樂意使用移動。那樣的話,你依然覺得對返回的局部變量使用std::move是合情理的,因爲你知道這樣絕對不用拷貝。

遺憾的是,在那種情況下,對局部變量使用std::move依然是個糟糕的想法。一部分標準RVO的規則講述:如果RVO條件滿足,但編譯器沒有省略拷貝操作,那麼返回的對象一定會被視爲右值。實際上,標準庫要求當RVO被許可時,要麼發生拷貝省略,要麼對返回的局部變量隱式使用std::move。因此“拷貝”版本的makeWidget,

Widget makeWidget()   // 如前
{
    Widget w;
    ...
    return w;
}

編譯器必須是要麼把拷貝省略,要麼把這個函數看作是這樣寫的:

Widget makeWidget()
{
    Widget w;
    ...
    return std::move(w);    // 把w視爲右值,因爲沒有省略拷貝.
}

這種情況和以值傳遞的函數參數很像,關於函數返回值,它們沒有資格省略拷貝,但是當它們返回時,編譯器一定會把它看作右值。結果是,如果你的源代碼是這樣的,

Widget makeWidget(Widget w)  // 以值傳遞的參數,類型和返回值一樣
{
    ...
    return w;
}

而編譯器會把代碼視爲這樣寫的:

Widget makeWidget(Widget w) 
{
    ...
    return std::move(w);      // 把w視爲右值
}

這意味着,如果你對返回的局部變量(局部變量的類型和返回值類型相同,函數是通過值返回)使用std::move,你並不能幫助你的編譯器(如果編譯器不能省略拷貝,就會把局部變量視爲右值),不過你可以阻礙它們(通過阻礙RVO)。存在對局部變量使用std::move的合適的場合(即當你把變量傳遞給一個函數,而且你知道不會再使用這個局部變量了),但在有資格進行RVO的return語句,或者返回以值傳遞的從參數時,std::move不適用。


總結

需要記住的3點:

  • 在最後一次使用右值引用或者通用引用時,對右值引用使用std::move,對通用引用使用std::forward
  • 在一個通過值返回的函數中,如果返回的是右值引用或通用引用,那麼對它們做同樣的事情(對右值引用使用std::move,對通用引用使用std::forward)。
  • 如果局部變量有資格進行返回值優化(RVO),不要對它們使用std::movestd::forward
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章