C++中的左值、右值、參考與搬移語意

C++11中最重要的改變除了lambda表達式外大概就屬右值參考與搬移語意了(move semantics)
lambda表達式其實並沒有讓C++做到什麼原本做不到的事,嚴格來說他只算一個語法糖(syntax sugar),只是這個糖果還滿大顆的
而右值參考與搬移語意,若使用得當是能讓牽扯到記憶體配置的物件,例如容器,使用效率有革命性的提升

先從最簡單的開始,左值與右值的名稱由來就是因為一個出現在assignment operator的左邊,一個則是在右邊
例如以下程式碼:
a=1+3
a是左值,而1+2就是右值了
不過,用位置是在左在右來區分兩者也並不精確,例如,加上const修飾詞的左值不能出現在左邊
他們真正的分別是,左值為運算式結束後還會持續存在的非暫時物件,右值則為運算式結束後就不再存在之暫時物件
更深入地講,左值右值的概念並不只是侷限在指某個"值",或"物件",而是更廣義的"運算式"(expression)

那左值參考(以下簡寫為T&)與右值參考(以下簡寫為T&&)是怎麼來的呢?
在舊的C++標準裡參考只有左值參考:T&,它是一種特殊的型態,每個參考都會綁定一個物件
可以透過參考所作的任何改變也會同步反應到其所綁定之物件,以T&作為函式的參數型態意味著傳進函式的引數不會被複製,而是直接將函式中的參考綁定到該引數上
我們都知道,C++函式接收引數的方式預設是以傳值參考(pass by value),函式裡的變數實際上只是原來引數的副本而不是引數本身
使用參考可以省去複製龐大物件的成本,但需注意若在函式內改變了物件的狀態,那麼原本的物件也會受影響,因此若確定引數不該在函式裡改變的話我們通常會使用const T&作為參數型態
上面這些事其實使用指標也辦得到(除了暫時物件外,你不能對暫時物件解參考),以傳遞物件的指標取代傳遞物件本身也可以避免物件的複製,但是會多很多*、&、->等運算子的使用,看起來就囉嗦了些
而真正只有參考做得到事情是自訂拷貝建構子
拷貝運算子指的是接收相同型態的另一個物件作為引數的建構子,即使在程式裡沒有明確地用到它,它也可能依然存在,例如從函式以值語意回傳一個物件時就會呼叫拷貝建構子
這時若你沒有實作自己的版本,編譯器會提供一個

而關於右值參考:T&&,其與T&其實並不是直接對應到左值與右值
雖然說T&與T&&的確只能分別綁定左值與右值,而不能綁定另一者
但const T&型態可以綁定到任何左值與右值,如果不能的話,那在T&&還沒出現時的舊版C++標準,你沒有任何辦法傳遞一個暫時物件到函式裡而不作拷貝,而被const T&所綁定的暫時物件其生命週期會延長至與該參考相同
右值參考的用途在下面會講到

以下列出一些左值與右值的例子:
int& foo1();
int foo2();
foo3();
int a,b[3];
a;//左值
b;//左值
b[0];//左值
a+b[1];//右值
foo1();//左值
foo2();//右值
foo3();//右值

在上面例子中,比較需要注意的是最後兩個例子,foo1與foo2,foo2是右值的原因為它回傳的結果是在foo2內某個變數的副本,在該次運算式結束後變會銷毀,而foo1則是回傳一個綁定到某物件的參考,並不牽扯到任何複製
foo3則比較特別,它也是回傳綁定到某個物件的參考,但他是右值參考,屬於廣義上的右值
有關這點牽扯到的細節較多,在此先不做太多解釋
另外,回傳參考跟指標一樣,須得注意是否綁定到local變數,否則可能會造成其對應的物件在函式結束後被解構

要區分左值與右值,最根本的原因就是要實現搬移語意
我用下面的類別當例子,解釋搬移語意是要解決什麼問題
class List
{
public:
 List(int size)
 {
  data = new int[size];
  this->size = size;
 }
 
 List(List &other):List(other.length())
 {
  for (int i = 0; i < length(); i++)
  {
   data[i] = other[i];
  }
 }

 List& operator=(const List &other)
 {
  delete[] data;
  size = other.size;
  data = new int(other.length());
  for (int i = 0; i < length(); i++)
  {
   data[i] = other[i];
  }
  return *this;
 }

 int& operator[](int index) const
 {
  return data[index];
 }

 int length() const
 {
  return size;
 }

 ~List()
 {
  delete[] data;
 }
private:
 int *data;
 int size;
};
上面的程式碼是了一個非常簡易的容器類別:List
這個類別唯一比原生陣列好的地方在於他的建構式能接受一個變數來初始化容器的大小
為了完成這件事,我們必須從heap動態配置記憶體給我們的內部指標:data
除了接收整數的建構式外它也有一個拷貝建構子,接受另一個同類別的物件
拷貝建構子將配置另一段記憶體給data,並拷貝內部陣列裡的每一個值到新的data裡
=運算子也是做一樣的事情,但多了一個動作,他必須先將自己原先持有的記憶體釋放掉再配置新的

現在,假如有這麼一個函式,他在內部建構了一個List,做了一些處裡後回傳
List foo()
{
 List aList(100);
 //some stuff...
 return aList;
}
現在,思考一下以下程式碼的運作
List L(5);
L=foo();
在上面的程式碼中,L呼叫了=運算子並根據foo所回傳的List物件將其值複製到自身
在這邊仔細地思考一下,這樣做是不是非常沒效率?
foo所回傳的物件是個暫時物件,是個右值,在L的=運算子結束後就要被解構了!為什麼我們不直接拿它的記憶體來用?還要重新配置新的記憶體?
要這樣做的話,我們就得有一個方法區分右值與左值,並多載兩種情況寫出不同版本的=運算子,區分的方法就是使用右值參考作為參數型態:
//in class List
 List& operator=(List &&other)
 {
  delete[] data;
  size = other.size;
  data = other.data;
  other.data = nullptr;
  return *this;
 }
右值參考版本的=運算子做的事很簡單,把原來的記憶體清除掉,然後將data指標指向與引數的data相同的記憶體位置,再將引數的data指標指向nullptr,以避免暫時物件解構時把我們還要有的記憶體區塊給清除
這就相當於將暫時物件的記憶體"搬移"給原本的物件了!也就是所謂的參考語意

這裡還有一點細節可以注意,const T&型態其實是可以綁定到右值的,所以實作搬移語意前程式還是可以運作,而多載右值參考的版本後則因為右值引數傾向於呼叫右值參考的版本
所以一但此版本的成員函式被多載後當傳入右值時就會被優先呼叫

結論:
經過上面的說明應該已經可以體會使用搬移語意為何可以提升程式效率,STL中的容器包括string,都藉由搬移語意使其效能大幅地提升
了解語法並不困難,了解何時使用正確的語法則需要經驗與智慧的累積
希望這篇文章能幫助到大家對右值參考與搬移語意有個基本的理解
其實左右值還有分得更細到prvalue xvalue glvalue等等等
這個就等以後有機會再寫吧!

附註:
1.之所以使用=運算子當例子而不是拷貝建構子是因為拷貝建構子可能會被RVO(Return Value Optimization)的優化所省略掉

2.const T&型態根據標準是不能綁定到右值的,但VS預設的錯誤等級下是可以的

參考資料:
http://en.cppreference.com/w/cpp/language/overload_resolution 這篇有講解多載函式的優先順序
https://www.ptt.cc/man/C_and_CPP/DD8B/M.1469695733.A.5FD.html  ptt的精華文,講得非常好
https://en.wikipedia.org/wiki/Return_value_optimization RVO的維基百科介紹

留言

這個網誌中的熱門文章

MIT演算法開放式課程 Lecture 1: Algorithmic Thinking, Peak Finding

Python中的iterator、iterable、generator

Python資料型態,可變與不可變物件