1. 程式人生 > >C++11:右值引用、移動語意與完美轉發

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的內部實現,請移步另一篇部落格。