右值引用,移動語義,完美轉發

  • 2022 年 4 月 19 日
  • 筆記

文章預先發佈於:pokpok.ink

名詞解釋

  • 移動語義:用不那麼昂貴的操作代替昂貴的複製操作,也使得只支援移動變得可能,比如 unique_ptr,將數據的所有權移交給別人而不是多者同時引用。

  • 完美轉發:目標函數會收到轉發函數完全相同類似的實參。

  • 右值引用:是這兩個機制的底層語言機制,形式是 Type&&,能夠引用到「不再使用」的數據,直接用於對象的構造

要注意的是,形參一定是左值,即使類型是右值引用:

void f(Widget&& w) {
    /* w 在作用域內就是一個左值。 */
}

實現移動語意和完美轉發的重要工具就是std::movestd::forwardstd::movestd::forward 其實都是強制類型轉換函數,std::move 無條件將實參轉換為右值,而 std::forward 根據實參的類型將參數類型轉化為左值或者右值到目標函數。

移動語義

std::move(v) 相當於 static_cast<T&&>(v),強制將類型轉化為需要類型的右值,move 的具體實現為:

template<typename T>
typename remove_reference<T>::type&&
move(T&& param) {
    using ReturnType = typename remove_reference<T>::type&&;
    return static_cast<ReturnType>(param);
}
  1. 其中 typename remove_reference<T>::type&& 作用就是為了解決是當入參數是 reference to lvalue 的時候,返回類型ReturnType會因為引用摺疊被推導為 T&remove_reference<T>::type 就是為了去除推導出的模版參數 T 的引用,從到強製得到 T&&

  2. 如果沒有remove_reference<T>,而是用 T&& 來代替函數返回值以及 static_cast<>,就會有下面的推導規則:

    • 如果入參是 lvalue,那麼 T 就會被推導成為 T&,參數 param 的類型就變成了 T& &&,再通過引用摺疊的規則,推導最終結果為 T&,而根據表達式的 value category 規則,如果一個函數的返回值類型是左值引用,那麼返回值的類型為左值,那麼 std::move(v) 就不能實現需要的功能,做到強制右值轉換。
    • 如果入參是 rvalue,那麼 T 會被直接推導成 T&,參數 param 的類型也就變成了 T&&,函數返回的類型(type)也是 T&&,返回的值類別也是右值。
  3. 此外,在 c++14 能直接將 typename remove_reference<T>::type&& 替換為 remove_reference_t<T>&&

完美轉發

std::forward<T>(v) 的使用場景用於函數需要轉發不同左值或者右值的場景,假設有兩個重載函數:

void process(const Widget& lvalArg);
void process(Widget&& rvalArg);

有一個函數 LogAndProcess 會根據 param 左值或者右值的不同來區分調用不同函數簽名的 process 函數:

template<typename T>
void LogAndProcess(T&& param) {
    // DoSomething
    logging(param);

    process(std::forward<T>(param));
}

這樣使用 LogAndProcess 的時候就能區分:

Widget w;
LogAndProcess(w); // call process(const Widget&);
LogAndProcess(std::move(w)); // call process(Widget&&);

這裡就有 emplace_back 一種常見的用錯的情況,在程式碼中也經常看見,如果要將某個不用的對象a放到vector中:

class A {
	A(A&& a) {}
};

A a;
std::vector<A> vec;
vec.push_back(a);

如果使用 push_back 就會造成一次拷貝,常見的錯誤做法是將其替換為 vector::emplace_back()

vec.emplace_back(a);

但是 emplace_back 的實現有 std::forward 根據實參數做轉發,實參 a 實際上是個 lvaue,轉發到構造函數時得到的也是左值的 a,就相當於調用賦值拷貝構造:

vec[back()] = a;

解決方法其實只需要調用 std::move 做一次右值轉換即可,就能完成數據的移動。

vec.emplace_back(std::move(a)); 

萬能引用和右值引用

萬能引用和右值引用最大的區別在於萬能引用會涉及模板的推導。但並不是說函數參數中有模板參數就是萬能引用,例如 std::vector::push_back() 的函數簽名是 push_back(T&& x), 但是 T 的類型在 std::vector<T> 聲明的時候就已經確定了,在調用push_back 的時候不會涉及類型推導,而 std::vectoremplace_back 是確實存在推導的。另外即使類型是 T&&,但是如果有 const 修飾得到 const T&&,那麼也不是一個合格的萬能引用。

對於萬能引用,如果是入參是右值引用,模版參數 T 的推導結果還是 T,而不是 T&&,這不是右值引用的特殊性,而是左值引用的特殊性,
模板函數的函數參數列表中包含 forwarding reference 且相應的實參是一個 lvalue 的情況時,模版類型會被推導為左值引用。 forwarding reference 是 C++ 標準中的詞,翻譯叫轉發引用;《modern effective c++》的作者 Scott Meyers 將這種引用稱之為萬能引用(universal reference)。

右值引用的重載

有了右值引用後,就能通過 std::move 將左值轉換為右值,完成目標對象的移動構造,省去大對象的拷貝,但是如果傳遞的參數是個左值,調用者不希望入參被移動,數據被移走,那就需要提供一個左值引用的版本,因為右值引用無法綁定左值。假設大對象是一個string,就會寫出下面這種函數簽名:

void f(const std::string& s);
void f(string&& s);

一個參數沒問題,我們學會了左值和右值的區別並給出了不同的函數重載,但是如果參數是兩個 string,情況就變得複雜的,針對不同的情況,就需要提供四種函數簽名和實現:

void f(const std::string& s1, const std::string& s2);
void f(const std::string& s1, string&& s s2);
void f(string&& s s1, const std::string& s2);
void f(string&& s s1, string&& s s2);

如果參數進一步增加,編碼就會越來越複雜,遇到這種情況就可以使用萬能引用處理,在函數體內對string做std::forward()即可:

template<typename T1, typename T2>
void f(T1&& s1, T2&& s2);

萬能引用的重載

萬能引用存在一個問題,過於貪婪而導致調用的函數不一定是想要的那個,假設 f() 除了要處理左值和右值的 string 外,還有可能需要處理一個整形,例如int,就會有下面這種方式的重載。

// 萬能引用版本的 f(),處理左值右值
template<typename T>
void f(T&& s) {
    std::cout << "f(T&&)" << std::endl;
}

// 整數類型版本的 f(),處理整形
void f(int i)  {
    std::cout << "f(int)" << std::endl;
}

如果用不同的整型去調用f(),就會發生問題:

int i1;
f(i1); // output: f(int)

size_t i2;
f(i2); // output: f(T&&)
  • 如果參數是一個 int,那麼一切正常,調用f(int)的版本,因為c++規定,如果一個常規函數和一個模板函數具備相同的匹配性,優先使用常規函數。
  • 但是如果入參是個 size_t,那麼就出現問題了,size_t 的類型和 int 並不相等,需要做一些轉換才能變成int,那麼就會調用到萬能引用的版本。

如何限制萬能引用呢?

1、標籤分派:根據萬能引用推導的類型,f(T&&) 新增一個形參變成f(T&&, std::true_type)f(T&&, std::false_type),調用f(args, std::is_integral<T>()) 就能正確分發到不同的 f() 上。
2、模板禁用:std::enable_if 能強制讓編譯器使得某種模板不存在一樣,稱之為禁用,底層機制是 SFINAE

std::_enable_if 的正確使用方法為:

template<typename T,
        typename = typename std::enable_if<condition>::type>
void f(T param) {

}

應用到前面的例子上,希望整型只調用f(int)而 string 會調用 f(T&&),就會有:

void f(int i) {
    std::cout << "f(int)" << std::endl;
}

template<typename T,
         typename = typename std::enable_if<
            std::is_same<
                typename std::remove_reference<T>::type, 
                std::string>::value
            >::type
        >
void f(T&& s) {
    std::cout << "f(T&&)" << std::endl;
}

模板的內容看上去比較長,其實只是在std::enable_ifcondition內希望入參的類型為string,無論左值和右值,這樣就完成了一個萬能引用的正確重載。

引用摺疊

在c++中,引用的引用是非法的,但是編譯器可以推導出引用的引用的引用再進行摺疊,通過這種機制實現移動語義和完美轉發。

模板參數T的推導規則有一點就是,如果傳參是個左值,T的推導類型就是T&,如果傳參是個右值,那麼T推導結果就是T(不變)。引用的摺疊規則也很簡單,當編譯器出現引用的引用後,結果會變成單個引用,在兩個引用中,任意一個的推導結果為左值引用,結果就是左值引用,否則就是右值引用。

返回值優化(RVO)

編譯器如果要在一個按值返回的函數省略局部對象的複製和移動,需要滿足兩個條件:

  1. 局部對象的類型和返回值類型相同
  2. 返回的就是局部對象本身

如果在return的時候對局部變數做std::move(),那麼就會使得局部變數的類型和返回值類型不匹配,原本可以只構造一次的操作,變成了需要構造一次加移動一次,限制了編譯器的發揮。

另外,如果不滿足上面的條件二,按值返回的局部對象是不確定的,編譯器也會將返回值當作右值處理,所以對於按值返回局部變數這種情況,並不需要實施std::move()