C++ 11 右值引用以及std::move
新型別:
int和int&是什麼?都是型別。int是整數型別,int&則是整數引用型別。同樣int&&也是一個型別。兩個引號&&是C++ 11提出的一個新的引用型別。記住,這是一個新的型別。默唸10次吧。如果你記住這個新型別,那麼很多疑問都能迎刃而解。並且對《Effective Modern C++》說到的void f(Widget&& w),就很容易明白w是新型別的一個值,肯定是一個左值而不是右值,自然就不用去翻第二頁了。
出現了新型別,就像定義一個新類一樣,自然有兩件事接著要做:如何初始化、函式匹配(根據引數型別匹配函式)。先看後者。void fun(int &a) { cout<<"int &a "<<a<<endl; } void fun(int &&a) { cout<<"int &&a "<<a<<endl; } int main() { int b = 3; fun(b); return 0; }
main函式中的fun(a)會匹配第一個fun函式。因為第二個fun的引數是int右值引用,不能匹配一個左值。值得注意的是,雖然第二個fun函式的a的型別是右值引用型別,但它卻是一個左值,因為它是某一個型別變數嘛。
那要怎麼做才能使得b匹配第二個fun函式呢?強制型別轉換,把b強制轉換成右值引用型別,也就是使用static_cast<int&&>(b)。此時,自然就會匹配第二個fun函數了。
在C++ 11中,static_cast<T&&>有一個高大上的替代物std::move。其實,高大上的std::move做的事情和前面說的差不多,強制型別轉換使得匹配特定的函式而已。
class Test { public: Test() : p(nullptr) {} ~Test() { delete [] p; } Test(Test &t) : p(t.p)//注意這個拷貝建構函式的引數沒有const { t.p = nullptr;//不然會在解構函式中,delete兩次p } private: char *p; }; int main() { Test a; Test b(a); return 0; }
注意這個拷貝建構函式的引數沒有const。
讀者們,你們會覺得上面那個Test在拷貝建構函式不高效嗎?幾乎是沒有任何效率上的負擔啊。類似,也能寫一個高效的賦值函式。
但是,一般來說我們的拷貝建構函式的引數都是有const的。有const意味著不能修改引數t,上面的程式碼也可以看到:將t.p賦值nullptr是必須的。因為t.p不能修改,所以不得不進行深複製,不然將出現經典的淺複製問題。不用說,有const的拷貝建構函式更適合一些,畢竟我們需要從一個const物件中複製一份。移動構造:
效能的救贖:
在C++ 11之前,我們只能眼睜睜看著重量級的類只能呼叫有const的拷貝建構函式,複製一個重量級物件。在C++ 11裡面加入了一個新型別右值引用,那能不能用這個右值引用型別作為建構函式的引數呢?當然可以啦。畢竟類的建構函式引數沒有什麼特別的要求。習慣上,我們會稱這樣的建構函式為移動(move)建構函式,對應的賦值操作則稱為移動(move)賦值函式。他們的程式碼也很簡單,如下:
class Test
{
public:
Test() : p(nullptr)
{
cout<<"constructor"<<endl;
}
~Test()
{
cout<<"destructor"<<endl;
delete [] p;
}
Test(const Test& t) : p(nullptr), str(t.str)
{
cout<<"copy constructor"<<endl;
if(t.p != nullptr)
{
p = new char[strlen(t.p)+1];
memcpy(p, t.p, strlen(t.p)+1);
}
}
Test& operator = (const Test& t)
{
cout<<"operator = "<<endl;
if( this != &t )
{
char *tmp =nullptr;
if( t.p != nullptr)
{
tmp = new char[strlen(t.p)+1];
memcpy(tmp, t.p, strlen(t.p)+1);
}
delete [] p;
p = tmp;
str = t.str;
}
return *this;
}
Test(Test && t)noexcept : p(t.p), str(std::move(t.str))//如何移動由string類完成
{
cout<<"move copy constructor"<<endl;
t.p = nullptr;//記得,不然會對同一段記憶體重複delete
}
Test& operator = (Test &&t)noexcept
{
cout<<"move operator ="<<endl;
if( this != &t)
{
p = t.p;
t.p = nullptr;
str = std::move(t.str);//如何移動由string類完成
}
return *this;
}
private:
char *p;
std::string str;
};
協助完成移動構造:
有了move建構函式和move賦值函式,下一步是協助完成移動構造/移動賦值,包括程式設計師和編譯器。如果不協助的話,可能呼叫的是copy建構函式而不是move建構函式。從前文也可以看到,協助完成移動構造/移動賦值,其實也就是使得在函式呼叫時能匹配引數為右值引用的函式。碼農能做的就是強制將一個不需要了的物件呼叫std::move。如下面程式碼:int main()
{
Test a;
Test b = std::move(a);//呼叫move建構函式
Test c = a;//呼叫copy建構函式
return 0;
}
雖然上面的程式碼在構造b的時候呼叫了移動構造,但明顯上面程式碼一點都不正常,為什麼不直接構造b呢?完全用不著move構造啊。此時可能有讀者會想到這樣一個用途:我們可以為一個臨時物件加上std::move啊,比如operator + 的返回值。實際上這是畫蛇添足的。因為編譯器會為這個臨時物件當作右值(準確說應該是:將亡值),當然也就自動能使用移動構造了。
難道移動構造是屠龍之技?不是的。移動構造的一大優點是可以高效地在函式中返回一個重量級的類,函式返回值會在後面說到。除了在函式返回值用到外,在函式內部也可以使用到的。std::vector<std::string> g_ids;//全域性變數
void addIds(std::string id)
{
g_ids.push_back(std::move(id));
}
int main()
{
addIds("1234");//在新增到g_ids過程中,會呼叫一次copy建構函式,一次move建構函式
std::string my_id = "123456789";
addIds(my_id);//會呼叫一次copy建構函式,一次move建構函式
for(auto &e : g_ids)
cout<<e<<endl;
return 0;
}
有讀者可能會問,為什麼addIds的引數不是const std::string &的形式,這樣在對my_id呼叫的時候就不用為引數id呼叫一次copy建構函式。但別忘了,此時id被push進g_ids時就要必須要呼叫一次copy構造函數了。
前面用紅色標出,對一個不需要的了物件呼叫std::move強制型別轉換。為什麼說是不需要了的呢?因為一個物件被std::move並且作為move建構函式的引數後,該物件所佔用的一些資源可能被移走了,留下一個沒有用的空殼。注意,雖然是空殼,但在移動的時候,也要保證這個空殼物件能正確析構。
或許讀者還是覺得移動語義是屠龍之技,那麼讀者們想一下:vector容器在擴容的時候吧。有了移動語義,vector裡面的物件從舊地址搬到新地址,毫不費勁。右值引用情況下的返回值問題:
有了右值引用,讀者可能會寫出下面的程式碼:
Test&& fun()
{
Test t;
...
return std::move(t);
}
int main()
{
Test && tt = fun();//和下者,哪個才是正確的呢?
Test tt = fun();//和上者,哪個才是正確的呢?
return 0;
}
無疑,在main函式中,還需要考慮一下tt物件是一個Test型別還是Test&&型別。其實,大錯早就在fun函式中鑄成了。
返回的只是一個引用,真身呢?真身已經在fun函式中被摧毀了。Meyers早在《Effective C++》裡面就告誡過:不要在函式中返回一個引用。前文也已經說了,右值引用也是一個引用(型別)! 那返回什麼好呢? 當然是真身啦! 如同下面程式碼:
Test fun()
{
Test t;
...
return t;
}
int main()
{
Test tt = fun();
return 0;
}
當函式返回一個物件時,編譯器會將這個物件看作的一個右值(準確來說是將亡值)。所以無需在fun函式中,將return t寫成return std::move(t);
當然,實際上t變數的真身還是在fun函式中被摧毀了,但真身裡面有價值的東西都被移走了。對!就像比克大魔王那樣,臨死前把自己的孩子留下來! 在C++裡面,當然不能生成一個孩子,但是可以通過移動建構函式生成一個臨時物件,把有價值的東西移走。因為不是移動到main函式的tt變數中,只是移動到了臨時物件。所以接下來臨時物件還要進行一次移動,把有價值的東西移動到main函式的tt變數中。這個移動過程無疑是一個很好的金蟬脫殼的經典教程。讀者可以執行一下程式碼,可以看到整個移動過程。記住,用g++編譯的時候要加入-fno-elide-constructors選項,禁止編譯器使用RVO優化。因為這裡的RVO優化比移動構造更省力。所以如果不禁用,會優先使用RVO,而非移動建構函式。
初始化:
因為右值引用也是一個引用型別,所以只能初始化而不能賦值。既然這樣,那隻需討論什麼型別的值才能用於初始化一個右值引用。一般來說,右值引用只能引用右值、字面值、將亡值。所以問題轉化為:什麼是右值?網上介紹的一個方法是:要能不能將取地址符號&應用於某個識別符號,如果能就說明它是一個左值,否則為右值。這個方法好像是行得通的。不過,我覺得沒有必要分得那麼清楚,又不是在考試。在平常寫程式碼時,沒有誰會寫類似a+++++a這樣的考試程式碼。我個人覺得,記住最常見的那幾種就差不多了。比如,字面量(1,‘c'這類),臨時(匿名)物件(即將亡值),經過std::move()轉換的物件,函式返回值。其他的右值,還是留給編譯器和Scott Meyers吧。如果真的要細究,可以參考stackoverflow上的一個提問《What are rvalues, lvalues, xvalues, glvalues, and prvalues?》
還有一個問題需要說明,const的左值引用(const T&)是一個萬能引用,既可以引用左值,也能引用右值。這個是很特殊,特殊得很自然。如果Test類沒有定義move建構函式,但使用者又使用Test a = std::move(b)構造變數a。那麼最終會呼叫Test類的copy建構函式。一個類的copy建構函式如果使用者不定義,編譯器會在必要情況下自動合成一個。所以上面的a變數肯定能構造。
謹慎的編譯器:
前一段貌似隱隱約約說到編譯器不會自動合成一個move建構函式。是的。如果使用者定義了copy建構函式,解構函式,operator =中的任何一個,編譯器都不會自動為這個類合成一個move構成函式以及move 賦值函式,即使需要用到。具體的規則可以點這裡。我個人覺得是因為,當定義了那四個函式中的任何一個,都可以認為這個類不是nontrival的了。
想一下,在什麼情況下我們是需要解構函式和copy建構函式的。當這個類裡面有一些資源(變數)需要我們手動管理的時候。既然有資源要管理,那麼讀者你覺得編譯器預設生成的move建構函式的內部實現應該是怎麼樣的呢?對類裡面的所有成員都呼叫std::move進行移動?還是呼叫copy建構函式複製一份呢?這種吃力但又不見得討好的事情,編譯器選擇不幹。畢竟還有前面說到的const T& 可以引用一個右值。沒有move建構函式,copy建構函式頂上即可。
作為類的設計者,你當然知道那些資源(變數)到底是move還是copy。如果是move的話,那麼直接用=default告訴編譯器:別擔心,直接用對所有變數move就行了。如下:class Test
{
public:
Test() p(new int) {}
~Test()=default;
Test(const Test&)=delete;
Test& operator = (const Test&)=delete;
Test(Test &&)=default;//告訴編譯器
Test& operator = (Test &&)=default;//告訴編譯器
private:
std::unique_ptr<int> p;
}