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

北極烏布發表於2022-04-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()

相關文章