c++11 中的 move 與 forward

twoon發表於2014-01-07

[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

 

相關文章