1. 程式人生 > >C++11嚐鮮:右值引用和轉發型引用

C++11嚐鮮:右值引用和轉發型引用

右值引用

為了解決移動語義及完美轉發問題,C++11標準引入了右值引用(rvalue reference)這一重要的新概念。右值引用採用T&&這一語法形式,比傳統的引用T&(如今被稱作左值引用 lvalue reference)多一個&。
如果把經由T&&這一語法形式所產生的引用型別都叫做右值引用,那麼這種廣義的右值引用又可分為以下三種類型:
  • 無名右值引用
  • 具名右值引用
  • 轉發型引用
無名右值引用和具名右值引用的引入主要是為了解決移動語義問題。
轉發型引用的引入主要是為了解決完美轉發問題。

無名右值引用

無名右值引用(unnamed rvalue reference)是指由右值引用相關操作所產生的引用型別。
無名右值引用主要通過返回右值引用的型別轉換操作產生, 其語法形式如下:
static_cast<T&&>(t)
標準規定該語法形式將把表示式 t 轉換為T型別的無名右值引用。
無名右值引用是右值,標準規定無名右值引用和傳統的右值一樣具有潛在的可移動性,即它所佔有的資源可以被移動(竊取)。

std::move()

由於無名右值引用是右值,藉助於型別轉換操作產生無名右值引用這一手段,左值表示式就可以被轉換成右值表示式。為了便於利用這一重要的轉換操作,標準庫為我們提供了封裝這一操作的函式,這就是std::move()。
假設左值表示式 t 的型別為T&,利用以下函式呼叫就可以把左值表示式 t 轉換為T型別的無名右值引用(右值,型別為T&&)。
std::move(t)

具名右值引用

如果某個變數或引數被宣告為T&&型別,並且T無需推導即可確定,那麼這個變數或引數就是一個具名右值引用(named rvalue reference)。
具名右值引用是左值,因為具名右值引用有名字,和傳統的左值引用一樣可以用操作符&取地址。
與廣義的右值引用相對應,狹義的右值引用僅限指具名右值引用。
傳統的左值引用可以繫結左值,在某些情況下也可繫結右值。與此不同的是,右值引用只能繫結右值。
右值引用和左值引用統稱為引用(reference),它們具有引用的共性,比如都必須在初始化時繫結值,都是左值等等。
struct X {};
X a;
X&& b = static_cast<X&&>(a);
X&& c = std::move(a);
//static_cast<X&&>(a) 和 std::move(a) 是無名右值引用,是右值
//b 和 c 是具名右值引用,是左值
X& d = a;
X& e = b;
const X& f = c;
const X& g = X();
X&& h = X();
//左值引用d和e只能繫結左值(包括傳統左值:變數a以及新型左值:右值引用b)
//const左值引用f和g可以繫結左值(右值引用c),也可以繫結右值(臨時物件X())
//右值引用b,c和h只能繫結右值(包括新型右值:無名右值引用std::move(a)以及傳統右值:臨時物件X())

左右值過載策略

有時我們需要在函式中區分引數的左右值屬性,根據引數左右值屬性的不同做出不同的處理。適當地採用左右值過載策略,藉助於左右值引用引數不同的繫結特性,我們可以利用函式過載來做到這一點。常見的左右值過載策略如下:
struct X {};
//左值版本
void f(const X& param1){/*處理左值引數param1*/}
//右值版本
void f(X&& param2){/*處理右值引數param2*/}

X a;
f(a);            //呼叫左值版本
f(X());          //呼叫右值版本
f(std::move(a)); //呼叫右值版本
即在函式過載中分別過載const左值引用和右值引用。
過載const左值引用的為左值版本,這是因為const左值引用引數能繫結左值,而右值引用引數不能繫結左值。
過載右值引用的為右值版本,這是因為雖然const左值引用引數和右值引用引數都能繫結右值,但標準規定右值引用引數的繫結優先度要高於const左值引用引數。

移動構造器和移動賦值運算子

在類的構造器和賦值運算子中運用上述左右值過載策略,就會產生兩個新的特殊成員函式:移動構造器(move constructor)和移動賦值運算子(move assignment operator)。
struct X
{
    X();                         //預設構造器
    X(const X& that);            //拷貝構造器
    X(X&& that);                 //移動構造器
    X& operator=(const X& that); //拷貝賦值運算子
    X& operator=(X&& that);      //移動賦值運算子
};

X a;                             //呼叫預設構造器
X b = a;                         //呼叫拷貝構造器
X c = std::move(b);              //呼叫移動構造器
b = a;                           //呼叫拷貝賦值運算子
c = std::move(b);                //呼叫移動賦值運算子

移動語義

無名右值引用和具名右值引用的引入主要是為了解決移動語義問題。
移動語義問題是指在某些特定情況下(比如用右值來賦值或構造物件時)如何採用廉價的移動語義替換昂貴的拷貝語義的問題。
移動語義(move semantics)是指某個物件接管另一個物件所擁有的外部資源的所有權。移動語義需要通過移動(竊取)其他物件所擁有的資源來完成。移動語義的具體實現(即一次that物件到this物件的移動(move))通常包含以下若干步驟:
  • 如果this物件自身也擁有資源,釋放該資源
  • 將this物件的指標或控制代碼指向that物件所擁有的資源
  • 將that物件原本指向該資源的指標或控制代碼設為空值
上述步驟可簡單概括為①釋放this(this非空時)②移動that
移動語義通常在移動構造器和移動賦值運算子中得以具體實現。兩者的區別在於移動構造物件時this物件為空因而①釋放this無須進行。

與移動語義相對,傳統的拷貝語義(copy semantics)是指某個物件拷貝(複製)另一個物件所擁有的外部資源並獲得新生資源的所有權。拷貝語義的具體實現(即一次that物件到this物件的拷貝(copy))通常包含以下若干步驟:
  • 如果this物件自身也擁有資源,釋放該資源
  • 拷貝(複製)that物件所擁有的資源
  • 將this物件的指標或控制代碼指向新生的資源
  • 如果that物件為臨時物件(右值),那麼拷貝完成之後that物件所擁有的資源將會因that物件被銷燬而即刻得以釋放
上述步驟可簡單概括為①釋放this(this非空時)②拷貝that③釋放that(that為右值時)
拷貝語義通常在拷貝構造器和拷貝賦值運算子中得以具體實現。兩者的區別在於拷貝構造物件時this物件為空因而①釋放this無須進行。

比較移動語義與拷貝語義的具體步驟可知,在賦值或構造物件時,
  • 如果源物件that為左值,由於兩者效果不同(移動that ≠ 拷貝that),此時移動語義不能用來替換拷貝語義。
  • 如果源物件that為右值,由於兩者效果相同(移動that = 拷貝that + 釋放that),此時廉價的移動語義(通過指標操作來移動資源)便可以用來替換昂貴的拷貝語義(生成,拷貝然後釋放資源)。
由此可知,只要在進行相關操作(比如賦值或構造)時,採取適當的左右值過載策略區分源物件的左右值屬性,根據其左右值屬性分別採用拷貝語義和移動語義,移動語義問題便可以得到解決。

下面用MemoryBlock這個自我管理記憶體塊的類來具體說明移動語義問題。
#include <iostream>

class MemoryBlock
{
public:

	// 構造器(初始化資源)
	explicit MemoryBlock(size_t length)
		: _length(length)
		, _data(new int[length])
	{
	}

	// 析構器(釋放資源)
	~MemoryBlock()
	{
		if (_data != nullptr)
		{
			delete[] _data;
		}
	}

	// 拷貝構造器(實現拷貝語義:拷貝that)
	MemoryBlock(const MemoryBlock& that)
		// 拷貝that物件所擁有的資源
		: _length(that._length)
		, _data(new int[that._length])
	{
		std::copy(that._data, that._data + _length, _data);
	}

	// 拷貝賦值運算子(實現拷貝語義:釋放this + 拷貝that)
	MemoryBlock& operator=(const MemoryBlock& that)
	{
		if (this != &that)
		{
			// 釋放自身的資源
			delete[] _data;

			// 拷貝that物件所擁有的資源
			_length = that._length;
			_data = new int[_length];
			std::copy(that._data, that._data + _length, _data);
		}
		return *this;
	}

	// 移動構造器(實現移動語義:移動that)
	MemoryBlock(MemoryBlock&& that)
		// 將自身的資源指標指向that物件所擁有的資源
		: _length(that._length)
		, _data(that._data)
	{
		// 將that物件原本指向該資源的指標設為空值
		that._data = nullptr;
		that._length = 0;
	}

	// 移動賦值運算子(實現移動語義:釋放this + 移動that)
	MemoryBlock& operator=(MemoryBlock&& that)
	{
		if (this != &that)
		{
			// 釋放自身的資源
			delete[] _data;

			// 將自身的資源指標指向that物件所擁有的資源
			_data = that._data;
			_length = that._length;

			// 將that物件原本指向該資源的指標設為空值
			that._data = nullptr;
			that._length = 0;
		}
		return *this;
	}
private:
	size_t _length; // 資源的長度
	int* _data; // 指向資源的指標,代表資源本身
};

MemoryBlock f() { return MemoryBlock(50); }

int main()
{
	MemoryBlock a = f();            // 呼叫移動構造器,移動語義
	MemoryBlock b = a;              // 呼叫拷貝構造器,拷貝語義
	MemoryBlock c = std::move(a);   // 呼叫移動構造器,移動語義
	a = f();                        // 呼叫移動賦值運算子,移動語義
	b = a;                          // 呼叫拷貝賦值運算子,拷貝語義
	c = std::move(a);               // 呼叫移動賦值運算子,移動語義
}

轉發型引用

如果某個變數或引數被宣告為T&&型別,並且T需要經過推導才可確定,那麼這個變數或引數就是一個轉發型引用(forwarding reference)。
轉發型引用由以下兩種語法形式產生
  • 如果某個變數被宣告為auto&&型別,那麼這個變數就是一個轉發型引用
  • 在函式模板中,如果某個引數被宣告為T&&型別,並且T是一個需要經過推導才可確定的模板引數型別,那麼這個引數就是一個轉發型引用
轉發型引用是不穩定的,它的實際型別由它所繫結的值來確定。轉發型引用既可以繫結左值,也可以繫結右值。如果繫結左值,轉發型引用就成了左值引用。如果繫結右值,轉發型引用就成了右值引用。
轉發型引用在被C++標準所承認之前曾經被稱作萬能引用(universal reference)。萬能引用這一術語的發明者,Effective C++系列的作者Scott Meyers認為,如此異常靈活的引用型別不屬於右值引用,它應該擁有自己的名字。

對於某個轉發型引用型別的變數(auto&&型別)來說
  • 如果初始化表示式為左值(型別為U&),該變數將成為左值引用(型別為U&)。
  • 如果初始化表示式為右值(型別為U&&),該變數將成為右值引用(型別為U&&)。
對於函式模板中的某個轉發型引用型別的形參(T&&型別)來說
  • 如果對應的實參為左值(型別為U&),模板引數T將被推導為引用型別U&,該形參將成為左值引用(型別為U&)。
  • 如果對應的實參為右值(型別為U&&),模板引數T將被推導為非引用型別U,該形參將成為右值引用(型別為U&&)。
struct X {};
X&& var1 = X();                            // var1是右值引用,只能繫結右值X()
auto&& var2 = var1;                        // var2是轉發型引用,可以繫結左值var1
                                           // var2的實際型別等同於左值var1,即X&
auto&& var3 = X();                         // var3是轉發型引用,可以繫結右值X()
                                           // var3的實際型別等同於右值X(),即X&&
template<typename T>
void g(std::vector<typename T>&& param1);  // param1是右值引用
template<typename T>
void f(T&& param2);                        // param2是轉發型引用

X a;
f(a);                // 模板函式f()的形參param2是轉發型引用,可以繫結左值a
                     // 在此次呼叫中模板引數T將被推導為引用型別X&
                     // 而形參param2的實際型別將等同於左值a,即X&
f(X());              // 模板函式f()的形參param2是轉發型引用,可以繫結右值X()
                     // 在此次呼叫中模板引數T將被推導為非引用型別X
                     // 而形參param2的實際型別將等同於右值X(),即X&&

// 更多右值引用和轉發型引用
const auto&& var4 = 10;                           // 右值引用
template<typename T>
void h(const T&& param1);                         // 右值引用
template <typename T/*, class Allocator = allocator*/>
class vector
{
public:
    void push_back( T&& t );                      // 右值引用
    template <typename Args...>
    void emplace_back( Args&&... args );          // 轉發型引用
};

完美轉發

完美轉發(perfect forwarding)問題是指函式模板在向其他函式轉發(傳遞)自身引數(形參)時該如何保留該引數(實參)的左右值屬性的問題。也就是說函式模板在向其他函式轉發(傳遞)自身形參時,如果相應實參是左值,它就應該被轉發為左值;同樣如果相應實參是右值,它就應該被轉發為右值。這樣做是為了保留在其他函式針對轉發而來的引數的左右值屬性進行不同處理(比如引數為左值時實施拷貝語義;引數為右值時實施移動語義)的可能性。如果將自身引數不分左右值一律轉發為左值,其他函式就只能將轉發而來的引數視為左值,從而失去針對該引數的左右值屬性進行不同處理的可能性。

轉發型引用的引入主要是為了解決完美轉發問題。在函式模板中需要保留左右值屬性的引數,也就是要被完美轉發的引數須被宣告為轉發型引用型別,即引數必須被宣告為T&&型別,而T必須被包含在函式模板的模板引數列表之中。按照轉發型引用型別形參的特點,該形參將根據所對應的實參的左右值屬性而分別蛻變成左右值引用。但無論該形參成為左值引用還是右值引用,該形參在函式模板內都將成為左值。這是因為該形參有名字,左值引用是左值,具名右值引用也同樣是左值。如果在函式模板內照原樣轉發該形參,其他函式就只能將轉發而來的引數視為左值,完美轉發任務將會失敗。
#include<iostream>
using namespace std;

struct X {};
void inner(const X&) {cout << "inner(const X&)" << endl;}
void inner(X&&) {cout << "inner(X&&)" << endl;}
template<typename T>
void outer(T&& t) {inner(t);}

int main()
{
	X a;
	outer(a);
	outer(X());
}
//inner(const X&)
//inner(const X&)

std::forward()

要在函式模板中完成完美轉發轉發型引用型別形參的任務,我們必須在相應實參為左值,該形參成為左值引用時把它轉發成左值,在相應實參為右值,該形參成為右值引用時把它轉發成右值。此時我們需要標準庫函式std::forward()。
標準庫函式 std::forward<T>(t) 有兩個引數:模板引數 T 與 函式引數 t。函式功能如下:
  • 當T為左值引用型別U&時,t 將被轉換為無名左值引用(左值,型別為U&)。
  • 當T為非引用型別U或右值引用型別U&&時,t 將被轉換為無名右值引用(右值,型別為U&&)。
使用此函式,我們在函式模板中轉發型別為T&&的轉發型引用引數 t 時,只需將引數 t 替換為std::forward<T>(t)即可完成完美轉發任務。這是因為
  • 如果 t 對應的實參為左值(型別為U&),模板引數T將被推導為引用型別U&,t 成為具名左值引用(型別為U&),std::forward<T>(t)就會把 t 轉換成無名左值引用(左值,型別為U&)。
  • 如果 t 對應的實參為右值(型別為U&&),模板引數T將被推導為非引用型別U,t 成為具名右值引用(型別為U&&),std::forward<T>(t)就會把 t 轉換成無名右值引用(右值,型別為U&&)。
#include<iostream>
using namespace std;

struct X {};
void inner(const X&) {cout << "inner(const X&)" << endl;}
void inner(X&&) {cout << "inner(X&&)" << endl;}
template<typename T>
void outer(T&& t) {inner(forward<T>(t));}

int main()
{
	X a;
	outer(a);
	outer(X());
}
//inner(const X&)
//inner(X&&)