c++11 中的 move 與 forward


[update: 關於左值右值的另一點總結,請參看這篇]

一. move

關於 lvaue 和 rvalue,在 c++11 以前存在一個有趣的現象:T&  指向 lvalue (左傳引用), const T& 既可以指向 lvalue 也可以指向 rvalue。但卻沒有一種引用類型,可以限制為只指向 rvalue。這乍看起來好像也不是很大的問題,但實際與看起來不一樣,右值引用的缺失有時嚴重限制了我們在某些情況下,寫出更高效的代碼。舉個粟子,假設我們有一個類,它包含了一些資源:

class holder { public: holder() { resource_ = new Resource(); } ~holder() { delete resource_; } holder(const holder& other) { resource_ = new Resource(*other.resource_); } holder(holder& other) { resource_ = new Resource(*other.resource_); } holder& operator=(const holder& other) { delete resource_; resource_ = new Resource(*other.resource_);
return *this; } holder
& operator=(holder& other) { delete resource_; resource_ = new Resource(*other.resource_);
return *this; }
private: Resource* resource_; };

這是個 RAII 類,構造函數與析構函數分別負責資源的獲取與釋放,因此也相應處理了拷貝構造函數 (copy constructor) 和重載賦值操作符 (assignment operator),現在假設我們這樣來使用這個類。

// 假設存在如下一個函數,返回值為 holder 類型的臨時變量
holder get_holder() { return holder(); } holder h; foo(h);
h
= get_holder();

理想情況下(不考慮返回值優化等因素),這一小段代碼的最后一條語句做了如下三件事情:

1)  銷毀 h 中的資源。

2)  拷由 get_holder() 返回的資源。

3)  銷毀 get_holder() 返回的資源。

顯然我們可以發現這些事情中有些是不必要的:假如我們可以直接交換 h 中的資源與 get_holder() 返回的對象中的資源,那我們就可以直接省略掉第二步中的拷貝動作了。而這里之所以交換能達到相同的效果,是因為 get_holder() 返回的是臨時的變量,是個 rvalue,它的生命周期通常來說很短,具體在這里,就是賦值語句完成之后,任何人都沒法再引用該 rvalue,它馬上就要被銷毀了,它所包含的資源也無法再被訪問。而如果是像下面這樣的用法,我們顯然不可以直接交換兩者的資源:

holder h1; holder h2; h1 = h2;

foo(h2);

因為 h2 是個 lvalue,它的生命周期較長,在賦值語句結束之后,變量仍然存在,還有可能要被別的地方使用。因此,rvalue 的短生命周期給我們提供了在某些情況優化代碼的可能。但這種可能在 c++11 以前是沒法利用到的,因為我們沒法在代碼中對 rvalue 區別對待:在函數體中,程序員無法分辨傳進來的參數到底是不是 rvalue,我們缺少一個 rvalue 的標記。

回憶一下,T& 指向的是 lvalue,而 const T& 指向的,卻可能是 lvalue 或 rvalue,我們沒有任何方式能夠確認當前參數是不是 rvalue!為了解決這個問題,c++11 中引入了一個新的引用類型: some_type_t &&,這種引用指向的變量是個 rvalue, 有了這個引用類型,我們前面提到的問題就迎刃而解了。

class holder { public: holder() { resource_ = new Resource(); } ~holder() { if (resource_) delete resource_; } holder(const holder& other) { resource_ = new Resource(*other.resource_); } holder(holder& other) { resource_ = new Resource(*other.resource_); } holder(holder&& other) { resource_ = other.resource_; other.resource_ = NULL; } holder& operator=(const holder& other) { delete resource_; resource_ = new Resource(*other.resource_);
          return *this; } holder
& operator=(holder& other) { delete resource_; resource_ = new Resource(*other.resource_);
return *this; } holder
& operator=(holder&& other) { std::swap(resource_, other.resource_);
return *this; }
private: Resource* resource_; };

因為有了右值引用,當我們再寫如下代碼的時候:

holder h1; holder h2; h1 = h2; // 調用operator(holder&);
h1 = get_holder(); // 調用operator(holder&&)

編譯器就能根據當前參數的類型選擇相應的函數,顯然后者的實現是更高效的。寫到里,有的人也許會有疑問:  some_type_t&& ref  指向的是右值(右值引用),那 ref 本身在函數內是左值還是右值?具體來說就是如下代碼中,第三行所調用的是 operator=(holder&) 還是 operator=(holder&&)?

1 holder& operator=(holder&& other) 2 { 3       holder h = other;4       return *this; 5 }

這個問題的本質還是怎么區分 rvalue? c++11 中對 rvalue 作了明確的定義:

Things that are declared as rvalue reference can be lvalues or rvalues. The distinguishing criterion is: if it has a name, then it is an lvalue. Otherwise, it is an rvalue.

如果一個變量有名字,它就是 lvalue,否則,它就是 rvalue。根據這樣的定義,上面的問題中,other 是有名字的變量(變量的類型是右值引用),因此是個 lvalue,因此第3行調用的是 operator=(holder&)。好了說了這么久,一直沒說到 move(),現在我們來給出它的定義:

c++11 中的 move() 是這樣一個函數,它接受一個參數,然后返回一個該參數對應的右值引用.

就這么簡單!你甚至可以暫時想像它的原型是這樣的(當然是錯的,正確的原型我們后面再講)。

T&& move(T& val);

那么,這樣一個 move() 函數,它有什么使用呢?用處大了!回到前面例子,我們用到了 std::swap() 這個函數,回想一下以前我們是怎么想來實現 swap 的呢?

1 void swap(T& a, T& b) 2 { 3     T tmp = a; 4     a = b; 5     b = tmp; 6 }

想像一下,如果 T 是我們之前定義的 holder,這里面就多做了很多無用功,每一個賦值語句,就有一次資源銷毀以及一次拷貝!而事實上我們只是要交換 a 與 b 的內容,中間的拷貝都是額外的負擔,完全可以考慮消除這些無用功。

1 void swap(T& a, T& b) 2 { 3      T tmp = move(a); 4      a = move(b); 5      b = move(tmp); 6 }

這樣一來,如果 holder 提供了 operator=(T&&) 重載,上述操作就相當於只是交換了三次指針,效率大大提升!move() 使得程序員在有需要的情況下能把 lvalue 當成右值來對待。

二. forward()

1. 轉發問題

除了 move() 語義之外,右值引用的提出還解決另一個問題:完美轉發 (perfect forwarding),轉發問題針對的是模板函數,這些函數主要處理的是這樣一個問題:假設我們有這樣一個模板函數,它的作用是:緩存一些 object,必要的時候創建新的。

template<class TYPE, class ARG>
TYPE* acquire_obj(ARG arg) { static list<TYPE*> caches; TYPE* ret; if (!caches.empty()) { ret = caches.pop_back(); ret->reset(arg); return ret; } ret = new TYPE(arg); return ret; }

這個模板函數的作用簡單來說,就是轉發一下參數 arg 給 TYPE 的 reset() 函數和構造函數,除此它就沒再干別的事情,在這個函數當中,我們用了值傳遞的方式來傳遞參數,顯然是比較低效的,多了次沒必要的拷貝,於是我們准備改成傳遞引用的方式,同時考慮到要能接受 rvalue 作為參數,最后做出艱難的決定改成如下樣子:

template<class TYPE, class ARG> TYPE* acquire_obj(const ARG& arg) { //...
}

但這樣寫很不靈活:

1) 首先,如果 reset() 或 TYPE 的構造函數不接受 const 類型的引用,那上述的函數就不能使用了,必須另外提供非 const TYPE& 的版本,參數一多的話,很麻煩。

2) 其次,如果 reset() 或 TYPE 的構造函數能夠接受 rvalue 作為參數的話,這個特性在 acquire_obj() 里頭永遠用不上。

其中1) 好理解,2) 是什么意思?

2) 說的是這樣的問題,即使 TYPE 存在 TYPE(TYPE&& other) 這樣的構造函數,它在上述 acquire_obj() 中也永遠不會被調用,原因是在 acquire_obj() 中,傳遞給 TYPE 構造函數的,永遠是 lvalue(因為 arg 有名字),哪怕外面調用 acquire_obj() 時,用戶傳遞進來的是 rvalue,請看如下示例:

holder get_holder(); holder* h = acquire_obj<holder, holder>(get_holder());

雖然在上面的代碼中,我們傳遞給 acquire_obj() 的是一個 rvalue,但是在 acuire_obj() 內部,我們再使用這個參數時,它卻永遠是 lvalue,因為它有名字 --- 有名字的就是 lvalue。acquire_obj() 這個函數它的基本功能本來只是傳發一下參數,理想狀況下它不應該改變我們傳遞的參數的類型:假如我們傳給它 lvalue,它就應該傳 lvalue 給 TYPE,假如我們傳 rvalue 給它,它就應該傳 rvalue 給 TYPE,但上面的寫法卻沒有做到這點,而在 c++11 以前也沒法做到。forward() 函數的出現,就是為了解決這個問題。

forward() 函數的作用:它接受一個參數,然后返回該參數本來所對應的類型的引用。

2. 兩個原則

C++11 引入了右值引用的符號:&&,從前面一路看下來,可能有人已經習慣了一看到 T&& 就以為這是右值引用,這確實很容易誤解,但事實是,T&&  為右值引用只有當 T 為一個具體的類型時才成立,而如果 T 是推導類型時(如模板參數, auto 等)這就不一定了,比如說如下代碼中的 ref_int,根據定義這個變量的類型必定是一個右值引用,但模板函數 func 的參數 arg 則不定是右值引用了,因為此時 T 是一個推導類型。

int&& ref_int = get_int(); template <typename T>
void func(T&& arg) { }

Scott Meyer 曾對 T&& 這個特殊的東西作過一個專門的演講,他稱 T&& 為 universal reference(更新:不久后,c++ 社區認為叫作 forwarding reference 更准確),Universal reference 被實例化后(instantiate),即可能是一個左值引用,也可能是一個右值引用,具體來說,對於推導類型 T,  如果 T&& v  被一個左值初始化,那 v 就是左值引用,如果 v 被右值初始化,那它就是右值引用,很神奇!實現這是怎么做到的呢?主要來說,在參數類型推導上,c++11 加入了如下兩個原則:

原則 (1):

引用折疊原則 (reference collapsing rule),注意,以下條目中的 T 為具體類型,不是推導類型。

1)  T& & (引用的引用) 被轉化成 T&.

2)T&& & (rvalue的引用)被傳化成 T&.

3)  T& && (引用作rvalue) 被轉化成 T&.

4)  T&& && 被轉化成 T&&.

原則 (2):

對於以 rvalue reference 作為參數的模板函數,它的參數推導也有一個特殊的原則,假設函數原型為:

template<class TYPE, class ARG> TYPE* acquire_obj(ARG&& arg);

1) 如果我們傳遞 lvalue 給 acquire_obj(),則 ARG 就會被推導為 ARG&,因此如下代碼的第二行,acquire_obj 被推導為: TYPE* acquire_obj(ARG& &&)。

1 ARG arg;
2 acquire_obj(arg);

然后根據前面說的折疊原則,我們得到原型如下的函數: TYPE* acquire_obj(ARG&);

2) 如果我們如下這樣傳遞 rvalue 給 acquire_obj(),則 ARG 就會被推導為 ARG。

acquire_obj(get_arg()); 

最后,模板函數實例化為原型如下的函數:TYPE* acquire_obj(ARG&&); 

綜上討論可見,原則 2 其實是有些令人討厭的,它與一般模板函數的參數類型推導並不一致,甚至可以說有些相背(主要在於 top level cv removal principle),這些隨處可見的例外增加了語言的復雜性,加大了學習和記憶的難度,是如此令人討厭,但在 c++ 中這種現象又那么常見,真是無奈。

3.結論

有了以上兩個原則,現在我們可以給出理想的 acquire_obj() 原型,以及 forward() 原型。

template<class TYPE> TYPE&& forward(typename remove_reference<TYPE>::type& arg) { return static_cast<TYPE&&>(arg); } template<class TYPE, class ARG> TYPE* acquire_obj(ARG&& arg) { return new TYPE(forward<ARG>(arg)); }

注意上面 forward 的原型,這里只給出了參數是左值引用的原型,其實還有一個接受右值引用的重載(用來處理傳入的參數是右值的情況)。另外需要額外注意的是,forward 的模板參數類型 TYPE 與該函數的參數類型並不直接等價,因此無法根據傳入的參數推導模板參數,使得調用方必需顯式地指定模板參數的類型,如: forward<ARG>(xx),否則會有編譯錯誤。

下面我們驗證一下,上述函數是否能正常工作,假如我們傳給 acquire_obj() 一個 lvalue,根據上面說的模板推導原則 2,ARG 會被推導為 ARG&,我們得到如下函數:

TYPE* acquire_obj(ARG& && arg) { return new TYPE(forward<ARG&>(arg)); } 以及相應的 forward()函數。 TYPE& && forward(typename remove_reference<TYPE&>::type& arg) { return static_cast<TYPE& &&>(arg); } 再根據折疊原則,我們得到如下的函數:
TYPE*
acquire_obj(ARG& arg) { return new TYPE(forward<ARG&>(arg)); } 以及相應的forward()函數。 TYPE& forward(typename remove_reference<TYPE&>::type& arg) { return static_cast<TYPE&>(arg); }

 所以,最后在 acquire_obj 中,forward 返回了一個 lvalue 引用, TYPE 的構造函數接受了一個 lvaue 引用, 這正是我們所想要的。 而假如我們傳遞給 acquire_obj 一個 rvalue 的參數,根據模板推導原則,我們知道 ARG 會被推導為 ARG,於是得到如下函數: 

TYPE* acquire_obj(ARG&& arg) { return new TYPE(forward<ARG>(arg)); } 以及相應的 forward() 函數。 TYPE&& forward(typename remove_reference<TYPE>::type& arg) { return static_cast<TYPE&&>(arg); }

最后 acquire_obj() 中 forward() 返回了一個 rvalue reference,TYPE 的構造函數接受了一個 rvalue,也是我們所想要的。可見,上面的設計完成了我們所想要的功能,這時的 acquire_obj() 函數才是完美的轉發函數。

三.move的原型

顯然,move() 必定是一個模板函數,它的參數類型推導完全遵循前面提到兩個原則,這就是為何我把它的原型放到現在才寫出來,用心良苦啊。

template<class T> typename remove_reference<T>::type&& std::move(T&& a) { typedef typename remove_reference<T>::type&& RvalRef; return static_cast<RvalRef>(a); } 

根據模板推導原則和折疊原則,我們很容易驗證,無論是給 move 傳遞了一個 lvalue 還是 rvalue,最終返回的,都是一個rvalue reference。而這正是 move 的意義,得到一個 rvalue 的引用。看到這里有人也許會發現,其實就是一個 cast 嘛,確實是這樣,直接用 static_cast 也是能達到同樣的效果,只是 move 更具語義罷了。

 

【參考文獻】

http://thbecker.net/articles/rvalue_references/section_01.html

https://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM