C++11:右值引用、移動語意與完美轉發
在C++11之前我們很少聽說左值、右值這個叫法,自從C++11支援了右值引用之後,大多數人會像我一樣疑惑:啥是右值?
準確的來說:
- 左值:擁有可辨識的記憶體地址的識別符號便是一個左值。
- 右值:非左值。
- 左值引用:左值識別符號的一個別名,簡稱引用
- 右值引用:右值識別符號的一個別名
舉例:
int a = 5; //a為左值,5為右值
int* pA = &a; //pA為左值,&a為右值
int& refA = a; //refA是一個左值引用,C++11之前簡稱引用,a為右值
int&& rVal = 5; //rVal是一個右值引用。
上面的例子還可看出:左值有時可作為右值使用,而右值則永遠無法作為左值使用。
右值引用還有一種通俗的定義:臨時的物件便是一個右值。
右值引用有何作用呢? 我們先假設有一個類:
class Animal{
int* m_dataArr;
int m_dataLength;
public:
Animal(){
m_dataLength = 10;
m_dataArr = new int[m_dataLength ];
//init
...
}
//拷貝建構函式
Animal(const Animal& obj) {
m_dataLength = obj.m_dataLength;
m_dataArr = new int[m_dataLength];
//copy
for(int i=0; i<m_dataLength; i++){
...
}
}
//賦值操作符
Animal& operator=(const Animal& obj){
if(this != &obj){
//像Animal(const Animal& obj)函式一樣進行拷貝操作
...
}
return *this;
}
~Animal(){
delete [] m_dataArr;
}
}
作用一:移動語意
又整一新詞兒,啥叫“移動語意”?
拷貝建構函式大家應該都很熟悉——這個constructor負責把一個物件裡的資料拷貝到自己物件中,克隆一個自己。我們可以把這個行為稱作拷貝語意。典型場景——實參拷貝到形參。
void SomeFunc(Animal x){ ... }
Animal CreateAnimal(){ ... }
Animal cat;
SomeFunc(cat); //此處會呼叫拷貝建構函式將cat裡的資料拷貝到x中。
cat....
假如SomeFunc(cat);之後,不再引用cat了,我們經常這樣寫:
SomeFunc(CreateAnimal()); //新建立的物件會被拷貝給x然後被銷燬——極為浪費。
在此種情況下,拷貝顯得極為浪費——剛產生出的物件,被拷貝一份之後立即被銷燬——為何不直接使用剛剛創建出的物件裡的資料而避免不必要的拷貝?
此時移動語意就很容易理解了:一個constructor負責把一個物件裡的資料移動到自己物件中。這裡有個前提:被掏空的物件必須是一個 臨時物件,他被掏空之後不會再被引用到——這意味著掏空他後可以立即銷燬。這個負責掏空別人的constructor便是移動建構函式 與 移動賦值操作符。此時的Animal類變成這樣的了:
class Animal{
int* m_dataArr;
int m_dataLength;
public:
Animal(){
m_dataLength = 10;
m_dataArr = new int[m_dataLength ];
//init
...
}
//拷貝建構函式
Animal(const Animal& obj){
m_dataLength = obj.m_dataLength;
m_dataArr = new int[m_dataLength];
//copy
for(int i=0; i<m_dataLength; i++){
...
}
}
//移動建構函式
Animal(Animal&& obj){
m_dataLength = obj.m_dataLength;
m_dataArr = obj.m_dataArr; //將obj內的陣列指標直接拿來用
obj.m_dataArr= nullptr; //將obj內的陣列指標,防止稍後obj析構時銷燬m_data。
}
Animal& operator=(const Animal& obj){
if(this != &obj){
delete m_dataArr;
//像Animal(const Animal& obj)函式一樣進行拷貝操作
...
}
return *this;
}
Animal& operator=(Animal&& obj){
assert(this != &obj);
delete m_dataArr;
//像Animal(Animal&& obj)一樣進行移動
...
}
~Animal(){
delete[] m_dataArr;
}
}
我們暫時忽略賦值操作符與移動操作符的細節,只討論拷貝構造與移動構造。
接下來我們為SomeFunc增加一個過載,變成這樣:
//void SomeFunc(Animal x){ ... } //普通版本,不能與下面兩個版本共存,會導致呼叫時的不確定
void SomeFunc(Animal& x){ ... } //左值引用版本
void SomeFuncR(Animal&& x){ ... } //右值引用版本
Animal cat;
SomeFunc(cat); //cat是一個左值,呼叫void SomeFunc(Animal& x)版本
SomeFunc(CreateAnimal()); //CreateAnimal()返回一個右值,呼叫void SomeFunc(Animal&& x)版本,執行移動構造
SomeFunc(std::move(cat)); //呼叫void SomeFunc(Animal&& x)版本,執行移動構造,cat會被掏空,但不會被立即析構,cat的析構要等到它的生存期結束。
一般我們寫C++函式傳遞引數時,一般使用左值引用。但是當實參是常量是就無法再使用左值引用版本的函數了,右值應用此時可以補上。
作用二:完美轉發(Perfect Forwarding)
移動語意較容易理解,完美轉發就沒那麼直觀了,我們先通過程式碼看下什麼是“轉發”與“不完美轉發”。
template <typename T>
void TempFunc(T t){
//TempFunc模板函式會把t傳遞給SomeFunc,這個過程便稱為實參轉發(Argument Forwarding)
SomeFunc(t);
}
在移動語意部分,我們知道,SomeFunc(cat)會匹配左值引用版本的SomeFunc,而SomeFunc(CreateAnimal())匹配右值引用版本的SomeFunc。現在我們在SomeFunc外面包了一層殼:TempFunc,考慮如下呼叫:
TempFunc(cat);
TempFunc(CreateAnimal());
TempFunc的內部會分別匹配哪個版本的SomeFunc呢?答案是:上兩行程式碼都會匹配左值引用版本的SomeFunc。
Holy shit!
為啥會這樣?
因為所有的形參都是左值。
如何才能讓TempFunc(CreateAnimal())匹配右值引用版本的SomeFunc,實現完美轉發呢?
這麼幹:
template <typename T>
void TempFunc(T&& t){//此處的T&&稱為萬能引用
SomeFunc(std::forward<T>(t));
}
這樣定義模板函式,即可實現完美轉發,當呼叫TempFunc(cat)時,會匹配左值引用版本的SomeFunc;當呼叫TempFunc(CreateAnimal())時,匹配右值引用版本的SomeFunc。
關於為何上述程式碼能夠實現完美轉發以及std::move與std::forward的內部實現,請移步另一篇部落格。