文章預先釋出於:pokpok.ink
名詞解釋
-
移動語義:用不那麼昂貴的操作代替昂貴的複製操作,也使得只支援移動變得可能,比如 unique_ptr,將資料的所有權移交給別人而不是多者同時引用。
-
完美轉發:目標函式會收到轉發函式完全相同類似的實參。
-
右值引用:是這兩個機制的底層語言機制,形式是 Type&&,能夠引用到“不再使用”的資料,直接用於物件的構造
要注意的是,形參一定是左值,即使型別是右值引用:
void f(Widget&& w) {
/* w 在作用域內就是一個左值。 */
}
實現移動語意和完美轉發的重要工具就是std::move
和 std::forward
,std::move
和 std::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);
}
-
其中
typename remove_reference<T>::type&&
作用就是為了解決是當入引數是reference to lvalue
的時候,返回型別ReturnType
會因為引用摺疊
被推導為T&
,remove_reference<T>::type
就是為了去除推匯出的模版引數 T 的引用,從到強制得到T&&
。 -
如果沒有
remove_reference<T>
,而是用T&&
來代替函式返回值以及static_cast<>
,就會有下面的推導規則:- 如果入參是
lvalue
,那麼T
就會被推導成為T&
,引數param
的型別就變成了T& &&
,再通過引用摺疊
的規則,推導最終結果為T&
,而根據表示式的 value category 規則,如果一個函式的返回值型別是左值引用,那麼返回值的型別為左值,那麼std::move(v)
就不能實現需要的功能,做到強制右值轉換。 - 如果入參是
rvalue
,那麼T
會被直接推導成T&
,引數param
的型別也就變成了T&&
,函式返回的型別(type)也是T&&
,返回的值類別也是右值。
- 如果入參是
-
此外,在 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::vector
的 emplace_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_if
的condition
內希望入參的型別為string
,無論左值和右值,這樣就完成了一個萬能引用的正確過載。
引用摺疊
在c++中,引用的引用是非法的,但是編譯器可以推匯出引用的引用的引用再進行摺疊,通過這種機制實現移動語義和完美轉發。
模板引數T
的推導規則有一點就是,如果傳參是個左值,T
的推導型別就是T&
,如果傳參是個右值,那麼T
推導結果就是T
(不變)。引用的摺疊規則也很簡單,當編譯器出現引用的引用後,結果會變成單個引用,在兩個引用中,任意一個的推導結果為左值引用,結果就是左值引用,否則就是右值引用。
返回值優化(RVO)
編譯器如果要在一個按值返回的函式省略區域性物件的複製和移動,需要滿足兩個條件:
- 區域性物件的型別和返回值型別相同
- 返回的就是區域性物件本身
如果在return
的時候對區域性變數做std::move()
,那麼就會使得區域性變數的型別和返回值型別不匹配,原本可以只構造一次的操作,變成了需要構造一次加移動一次,限制了編譯器的發揮。
另外,如果不滿足上面的條件二,按值返回的區域性物件是不確定的,編譯器也會將返回值當作右值處理,所以對於按值返回區域性變數這種情況,並不需要實施std::move()
。