理解std::move和std::forward
本文翻譯自《effective modern C++》,由於水平有限,故無法保證翻譯完全正確,歡迎指出錯誤。謝謝!
根據std::move和std::forward不能做什麼來熟悉它們是一個好辦法。std::move沒有move任何東西,std::forward沒有轉發任何東西。在執行期,它們沒有做任何事情。它們沒有產生需要執行的程式碼,一byte都沒有。
std::move和std::forward只不過就是執行cast的兩個函式(實際上是函式模板)。std::move無條件地把它的引數轉換成一個右值,而std::forward只在特定條件滿足的情況下執行這個轉換。就是這樣了,我的解釋又引申出一系列的新問題,但是,基本上來說,上面說的就是全部內容了。
為了讓內容更加形象,這裡給出C++11中std::move實現的一個例子。它沒有完全遵循標準的細節,但是很接近了。
template<typename T> //在名稱空間std中 typename remove_reference<T>::type&& move(T&& param) { using ReturnType = //別名宣告 typename remove_reference<T>::type&&; //看Item 9 return static_cast<ReturnType>(param); }
我已經幫你把程式碼的兩個部分高亮(move和static_cast)顯示了。一個是函式的名字,因為返回值型別挺複雜的,我不想讓你在這複雜的地方浪費時間。另一個地方是包括了這個函式的本質(cast)。就像你看到的那樣,std::move需要一個物件的引用(準確地說是一個universal引用,看Item 24),並且返回同一個物件的引用。
函式返回值型別的“&&”部分暗示了std::move返回一個右值引用,但是,就像Item 28解釋的那樣,如果型別T恰好是左值引用,T&&將成為一個左值引用。為了防止這樣的事情發生,type trait(看Item 9)std::remove_reference被用在T上了,因此能保證把“&&”加在不是引用的型別上。這樣能保證讓std::move確切地返回一個右值引用,並且這是很重要的,因為由函式返回的右值引用是一個右值。因此,std::move所做的所有事情就是轉換它的引數為一個右值。
說句題外話,在C++14中std::move能被實現得更簡便一些。多虧了函式返回值型別推導(看Item 3)以及標準庫的別名模板std::remove_reference_t(看Item 9),std::move能被寫成這樣:
template<typename T>
decltype(auto) move(T&& param)
{
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}
看上去更簡單了,不是嗎?
因為std::move值只轉換它的引數為右值,這裡有一些更好的名字,比如說rvalue_cast。儘管如此,我們仍然使用std::move作為它的名字,所以記住std::move做了什麼和沒做什麼很重要。它做的是轉換,沒有做move。
當然了,右值是move的候選人,所以把std::move應用在物件上能告訴編譯器,這個物件是有資格被move的。這也就是為什麼std::move有這樣的名字:能讓指定的物件更容易被move。
事實上,右值是move的唯一候選人。假設你寫了一個代表註釋的類。這個類的建構函式有一個std::string的引數,並且它拷貝引數到一個數據成員中。根據Item 41中的資訊,你宣告一個傳值的引數:
class Annotation {
public:
explicit Annotation(std::string text); // 要被拷貝的引數
// 根據Item 41,宣告為傳值的
...
};
但是Annotation的建構函式只需要讀取text的值。它不需要修改它。為了符合歷史傳統(把const用在任何可以使用的地方),你修改了你的宣告,因此text成為了const的:
class Annotation {
public:
explicit Annotation(const std::string text)
...
};
為了在拷貝text到資料成員的時候不把時間浪費在拷貝操作上,你保持Item 41的建議並且把std::move用在text上,因此產生了一個右值:
class Annotation {
public:
explicit Annotation(const std::string text)
:value(std::move(text)) // “move” text到value中去;這段程式碼
{...} //做的事情不像看上去那樣
...
private:
std::string value;
};
程式碼能夠編譯。程式碼能夠連結。程式碼能夠執行。程式碼把資料成員value的值設為text的內容。這段程式碼同完美的程式碼(你所要的版本)之間的唯一不同之處就是text不是被move到value中去的,它是拷貝過去的。當熱,text通過std::move轉換成了一個右值,但是text被宣告為一個const std::string,所以在轉換之前,text是一個左值const std::string,然後轉換的結果就是一個右值const std::string,但是一直到最後,const屬性保留下來了。
考慮一下const對於編譯器決定呼叫哪個std::string建構函式有什麼影響。這裡有兩種可能:
class string { // std::string實際上是
public: // std::basic_string<char>的一個typedef
...
string(const string& rhs); // 拷貝建構函式
string(string& rhs); // move建構函式
...
};
在Annotation的建構函式的成員初始化列表中,std::move(text)的結果是一個const std::string的右值。這個右值不能傳給std::string的move建構函式,因為move建構函式只接受非const std::string的右值引用。但是,這個右值能被傳給拷貝建構函式,因為一個lvalue-reference-to-const(引用const的左值)能被繫結到一個const右值上去。因此即使text已經被轉化成了一個右值,成員初始化列表還是呼叫了std::string中的拷貝建構函式。這樣的行為本質上是為了維持const的正確性。一般把一個值move出去就相當於改動了這個物件,所以C++不允許const物件被傳給一個能改變其自身的函式(比如move建構函式)。
我們從這個例子中得到兩個教訓。第一,如果你想要讓一個物件能被move,就不要把這個物件宣告為const。在const物件上的move請求會被預設地轉換成拷貝操作。第二,std::move事實上沒有move任何東西,它甚至不能保證它轉換出來的物件能有資格被move。你唯一能知道的事情就是,把std::move用在一個物件之後,它變成了一個右值。
std::forward的情況和std::move相類似,但是std::move是無條件地把它的引數轉換成右值的,而std::forward只在確定條件下才這麼做。std::forward是一個有條件的轉換。為了理解它什麼時候轉換,什麼時候不轉換,回憶一下std::forward是怎麼使用的。最常見的情況就是,一個帶universal引用的引數被傳給另外一個引數:
void process(const Widget& lvalArg); // 引數為左值
void process(Widget&& rvalArg); // 引數為右值
template<typename T> // 把引數傳給process
void logAndProcess(T&& param) // 的模板
{
auto now =
std::chrono::system_clock::now(); // 取得正確的時間
makeLogEntry("Calling 'process'", now);
process(std::forward<T>(param));
}
考慮一下兩個logAndProcess呼叫,一個使用左值,另外一個使用右值:
Widget w;
logAndProcess(w); // 用左值呼叫
logAndProcess(std::move(w)); // 用右值呼叫
在logAndProcess內部,引數param被傳給process函式。process過載了左值和右值兩個版本。當我們用左值呼叫logAndProcess的時候,我們自然是希望這個左值作為一個左值被轉發給process,然後當我們使用右值呼叫logAndProcess時,我們希望右值版本的process被呼叫。
但是param就和所有的函式引數一樣,是一個左值。因此在logAndProcess內部總是呼叫左值版本的process。為了防止這樣的事情發生,我們需要一種機制來讓param在它被一個右值初始化(傳給logAndProcess的引數)的時候轉換成右值。這正好就是std::forward做的事情。這也就是為什麼std::forward是一個條件轉換:它只把用右值初始化的引數轉換成右值。
你可能會奇怪std::forward怎麼知道他的引數是不是用右值初始化的。舉個例子吧,在上面的程式碼中,std::forward怎麼會知道param是被左值還是右值初始化的呢?簡單來說就是這個資訊被包含在logAndProcess的模板引數T中了。這個引數被傳給了std::forward,這樣就讓std::forward得知了這個資訊。它具體怎麼工作的細節請參考Item 28。
考慮到std::move和std::forward都被歸結為轉換,不同之處就是std::move總是執行轉換,但是std::forward只在有些情況下執行轉換,你可能會問我們是不是可以去掉std::move並且在所有的地方都只使用std::forward。從技術的角度來看,回答是可以:std::forward能做到所有的事情。std::move不是必須的。當然,這兩個函式函式都不是“必須的”,因為我們能在使用的地方寫cast,但是我希望我們能同意它們是必須的函式,好吧,真是令人心煩的事。
std::move的優點是方便,減少相似的錯誤,並且更加清晰。考慮一個類,對於這個類我們想要記錄它的move建構函式被呼叫了多少次。一個能在move構造的時候自增的static計數器就是我們需要的東西了。假設這個類中唯一的非static資料是一個std::string,這裡給出通常的辦法(也就是使用std::move)來實現move建構函式:
class Widget {
public:
Widget(Widget&& rhs)
: s(std::move(rhs.s))
{ ++moveCtorCalls;}
}
...
private:
static std::size_t moveCtorCalls;
std::string s;
};
為了用std::forward來實現相同的行為,程式碼看起來像是這樣的:
class Widget {
public:
Widget(Wdiget&& rhs) //不常見,以及不受歡迎的實現
: s(std::forward<std::string>(rhs.s))
//譯註:為什麼是std::string請看Item 1,用右值傳入std::string&& str的話
//推導的結果T就是std::string,用左值傳入,則推導的結果T會是std::string&
//然後這個T就需要拿來用作forward的模板型別引數了。
//詳細的解釋可以參考Item28
{ ++moveCtorCalls; }
};
首先注意std::move只需要一個函式引數(rhs.s),而std::forward卻需要一個函式引數(rhs.s)以及一個模板型別引數(std::string)。然後注意一下我們傳給std::forward的型別應該是一個非引用型別,因為我們約定好傳入右值的時候要這麼編碼(傳入一個非引用型別,看Item 28)。也就是說,這意味著std::move需要輸入的東西比std::forward更少,還有,它去掉了我們傳入的引數是右值時的麻煩(記住型別引數的編碼)。它也消除了我們傳入錯誤型別(比如,std::string&,這會導致資料成員用拷貝建構函式來替換move建構函式)的可能。
更加重要的是,使用std::move表示無條件轉換到一個右值,然後使用std::forward表示只有引用的是右值時才轉換到右值。這是兩種非常不同的行為。第一個常常執行move操作,但是第二個只是傳遞(轉發)一個物件給另外一個函式並且保留它原始的左值屬性或右值屬性。因為這些行為如此地不同,所以我們使用兩個函式(以及函式名)來區分它們是很好的主意。
你要記住的事
- std::move執行到右值的無條件轉換。就其本身而言,它沒有move任何東西。
- std::forward只有在它的引數繫結到一個右值上的時候,它才轉換它的引數到一個右值。
- std::move和std::forward在執行期都沒有做任何事情。