1. 程式人生 > >返回值和右值引用的傳遞問題

返回值和右值引用的傳遞問題

  最近突然發現了這個問題,挺有意思的,記錄下來備忘。

  以下程式碼在gcc 4.8.1下編譯測試。

測試類

  測試類結構如下:

class Test2
{
public:
	Test2() {}
	Test2(const char* str);
	Test2(const Test2& o);
	Test2(Test2&& o);
	virtual ~Test2();
	Test2& operator=(const Test2& o);
	Test2& operator=(Test2&& o);
	void swap(Test2& o);
	const char* cstr() const { return _blocks ? _blocks : ""; }
protected:
	char* _blocks;	// 儲存字串的緩衝區
};

  可以看到,這個類中包含了C++11標準中規定的若干元素:

  • 預設建構函式(可預設);
  • 引數建構函式(可預設);
  • 解構函式;
  • copy建構函式;
  • move建構函式(轉移建構函式);
  • copy賦值運算子;
  • move賦值運算子(轉移賦值運算);
  • 物件交換函式;
  其中,關鍵的幾個函式實現如下:
/**
 * 引數構造器
 * @param [in] str 字串值
 */
Test2::Test2(const char* str) :
	_blocks(NULL)
{
	if (str)
		_blocks = ::strdup(str);
}

/**
 * 拷貝建構函式
 * @param [in] o 同類型的另一個物件引用
 */
Test2::Test2(const Test2& o) :
	_blocks(NULL)
{
	if (o._blocks)
		_blocks = ::strdup(o._blocks);
}

/**
 * Move建構函式
 * @param [in] o 同類型的另一個物件右值引用
 */
Test2::Test2(Test2&& o) :
	_blocks(NULL)
{
	swap(o);
}

/**
 * 解構函式
 */
Test2::~Test2()
{
	if (_blocks)
		::free(_blocks);
	_blocks = NULL;
}

/**
 * 賦值運算子過載
 * @param [in] o 同類型的另一個物件引用
 * @return 當前型別的另一個引用
 */
Test2& Test2::operator=(const Test2& o)
{
	if (this != &o)
		Test2(o, int()).swap(*this);
	return *this;
}

/**
 * 右值引用賦值運算子過載
 * @param [in] o 同類型的另一個物件右值引用
 * @return 當前型別的另一個引用
 */
Test2& Test2::operator=(Test2&& o)
{
	if (this != &o)
	{
		swap(o);
		o.~Test2();
	}
	return *this;
}

/**
 * 交換兩個物件
 * @param [in] o 同類型的另一個物件
 */
void Test2::swap(Test2& o)
{
	std::swap(_blocks, o._blocks);
}
  突然想了解一下具有move建構函式和move賦值運算的類,在物件傳遞時會發生什麼情況,所以寫了下面的幾個函式進行測試。

第一個函式,返回函式內部產生的區域性變數:

/**
 * 測試返回內部具備變數
 * @return 返回臨時生成的物件
 */
Test2 return_object()
{
	Test2 res = "test";
	return res;
}
通過如下程式碼測試
Test2 t1 = return_object();
t1 = return_object();
結論:
  1. 第一行程式碼中,只在呼叫函式內部執行了一次引數建構函式(構造區域性物件),沒有發生copy建構函式(或者move建構函式)的呼叫
    ,即可以認為在函式內部例項化的區域性物件就是返回值變數t1,這應該是編譯器優化的結果;
  2. 第二行程式碼執行時,變數t1已經被初始化,所以賦值運算是必然會發生的,此時除過在呼叫函式內部執行了一次引數建構函式(構造區域性物件)外,還執行了一次move賦值運算,可見編譯器認為函式的返回值是右值。由於有了move賦值運算子,所以沒有呼叫copy賦值運算子,相當於將函式內部的區域性物件(右值)轉移到了t1變數(左值)中,完成了右到左的轉化(減少了一次構造和析構);

第二個函式,返回函式內部產生的區域性變數的引用:

/**
 * 測試返回區域性變數的引用
 * @return 返回臨時生成的物件的引用
 */
Test2& return_reference()
{
	Test2 res = "test";
	return res;
}
  這個函式一看就是錯誤的,返回區域性變數的引用或指標都是不允許的,因為在函式返回前,區域性變數就會被析構,導致返回的引用是無效引用(已經遊離),為了測試的完整性,用如下程式碼測試:
Test2 t2 = return_reference();
t2 = return_reference();
結論:
  1. 第一行程式碼中,在呼叫函式內部執行了引數建構函式構造了局部物件,之後又執行了copy建構函式,其含義是將返回的區域性物件引用,通過copy建構函式來構造變數t2物件,但結果是變數t2不一定可以構造成功,即使構造成功了其值也不正確,顯然在呼叫copy建構函式的時候,區域性物件已經析構,copy的值無效;
  2. 第二行程式碼中,在呼叫函式內部執行了引數建構函式構造了局部物件,之後又執行了copy賦值函式,結果和第一行程式碼類似;

第三個函式,返回函式內部產生區域性變數的右值引用:

/**
 * 測試返回區域性變數的右值引用
 * @return 返回臨時生成的物件的右值引用
 */
Test2 return_right_reference()
{
	Test2 res = "test";
	return std::move(res);// move函式在這裡的作用是將res的引用型別轉換為右值引用型別
}
  以如下程式碼進行測試:
Test2 t3 = return_right_reference();
t3 = return_right_reference();
結論:
  1. 第一行程式碼中,除了呼叫引數建構函式構造區域性物件外,還呼叫了一次move建構函式,這是由於返回值變成了區域性物件的右值引用,和變數t3型別不同,所以又額外的呼叫了一次move建構函式對變數t3進行初始化;
  2. 第二行程式碼中,情況就比較複雜了。照例通過引數建構函式構造了局部物件,但返回的是其右值引用,所以又呼叫了一次move建構函式,通過該右值引用產生了一個臨時的Test2物件(右值物件),最後通過一個move賦值運算將臨時的Test2物件轉移給變數t3;

第四個函式,對第三個函式進行修改:

/**
 * 測試返回區域性變數的右值引用
 * @return 返回臨時生成的物件的右值引用
 */
Test2&& return_right_reference2()
{
	Test2 res = "test";
	return std::move(res);	// move函式在這裡的作用是將res的引用型別轉換為右值引用型別
}
結論: 這段程式碼執行的結果和“第二個函式”一樣,返回區域性變數的引用(不管是左值還是右值)都不會有正確結果。

總結:

  最後發現,最樸素的寫法反而是執行效率最高的寫法(“第一個函式”),這種寫法充分的利用了編譯器在構造物件時進行的優化以及move賦值運算帶來的優勢,避免了物件在傳遞過程中產生的臨時物件以及引發的構造和析構;這也體現了move賦值運算存在的必要性
  無論如何,都不能在函式內部返回臨時變數的指標或引用,無論該引用是左值引用還是右值引用。C++11也從來沒有認為變數的控制權被轉移後析構就不再發生了。所以要在函式內部產生一個物件並返回,正確的做法是:1)將物件建立在堆記憶體上並返回地址;2)返回區域性物件,並通過copy複製運算子在函式外複製該區域性物件的副本;3)返回區域性物件(是一個右值),並通過move複製運算子將返回的區域性物件轉移到另一個物件中;
  move函式不能亂用,C++在一些場合下,隱含著右值的概念(比如函式返回值就是右值),此時將值進行型別轉換都會導致額外的不必要開銷(例如將返回值必須是“右值”,如果將其轉為“右值引用”,編譯器仍要生成程式碼將其轉回“右值”的物件,等於做了一堆無用功)。

  上面這些結論在C++文件裡說的很明白,但以前也從沒有專門思考過,這次做一個測試,發現了一些沒有發現的問題,特別是move賦值運算在傳遞返回值時的作用和move函式在返回時的無效性。所以有些東西光看文件是不夠的,還得親手試一下。