左值右值的一點總結

twoon發表於2015-12-11

再次來寫左值右值相關的東西我的內心是十分惴惴不安的,一來這些相關的概念十分不好理解,二來網上相關的文章實在太多了,多少人一看這類題目便大搖其頭,三來也怕說不清反而誤導了別人,反覆糾纏這些似乎無關大雅的語言細節實在也有成為 language lawyer 之嫌。但我還是決定再總結一次,因為這是我一直以來學習新東西的一種方式,只有把學到的東西真正寫清楚說明白了才是真的理解了,再者也希望自己的經驗總結能幫助到有同樣困惑的人。

左值右值

我們一直說左值右值,從 c++ 的術語角度來看,這其實並不十分準確,確切地說應該是左值表示式,右值表示式:表示式是有值的,值是有型別的,值是動態的,型別是靜態的,這是基本的概念。而我們說的左值右值,是對值的一種分類,這兩個稱呼也是從 c 時代遺傳下來的:左值是指能出現在等號左邊的值,右值是指只能出現在等號右邊的值。簡單來說,左值就是我們平時定義的變數,右值就是一些臨時變數。但到了 c++11,這個分類被細化擴充了,如下所示[N3690,3.10.1]:
左值右值的一點總結

因此:

一個表示式要麼是一個 glvalue(generalized lvalue),要麼是一個 rvalue;一個 glvalue 要麼是一個 lvalue,要麼是一個 xvalue(expiring value);一個 rvalue 要麼是一個 xvalue,要麼是一個 prvalue (pure rvalue)。

乍一看好像情況變得好複雜,其實不是,圖中所說 lvalue 與 c 時代的 lvalue 幾乎表達一個意思(不妨稱為純左值),prvalue 與 c 時代的 rvalue 也幾乎表達一個意思,所謂純右值 (pure rvalue),只是多了一個 xvalue,一個介於純左傳與純右值之間的奇怪物種。本質上來說,xvalue 是 c 時代的 lvalue,它不是中間變數臨時變數之類的沒有名字的純右值,之所以再創造出這樣一個新的值型別是因為有些時候,我們希望能夠將一個純左值當成臨時變數(純右值)一樣來使用,這種被當成純右值來使用的左值就是 xvalue,說起來很繞,本質上 xvalue 就是一些從程式邏輯上看要 "過時" 的變數(expiring value),它的名字也正是取義自這裡。

xvalue 只能通過兩種方式來獲得,這兩種方式都涉及到將一個左值賦給(轉化為)一個右值引用[N3690,3.10.1]:

  1. 強制型別轉化為右值引用,如 static_cast<T&&>(t); 該表示式得到一個 xvalue。
  2. 返回型別為右值引用的函式呼叫,如, T&& fun() { return t; };, 則呼叫 fun() 時, 返回一個 xvalue。

對於第 2 點,有一個與之類似的寫法值得大家注意:如果一個函式的返回型別是左值引用,那麼呼叫這個函式得到的返回值將是一個 lvalue[N3690,3.10.1],之所以特別地說這個事,是因為如果一個函式的返回值不是引用型別,那呼叫這個函式得到的結果將是一個臨時變數,是個右值,而且是純右值(prvalue),嗯,不要搞昏了。

值與引用

這是另外兩個容易混為一談的概念,引用在 c++ 裡是一個很特別的東西,就我的理解,確切地說引用應是一種型別,和 int, float 等類似,比如說 T& t = v;, 則 t 是一個變數,它的型別是引用,指向一個左值,因此也稱為左值引用,所以 t 本身是一個左值,型別是一個左值引用。與此相對應,我們也有右值引用: T&& t2 = fun();,同樣的 t2 是一個左值,但它的型別是右值引用。所以,我們平時說的"引用"確切來說應該稱作"引用變數"才準確,與"整型變數","字元變數"相對(N3690, 8.5.3.1)。當然引用變數這個說法比較牽強(甚至有些政治不正確),畢竟它和一般變數相比實在太不相似了,比如它一般不佔記憶體,比如對它取地址,得到的不是變數本身的地址,比如定義時必須初始化,且不能再次被賦值等等特殊之處,怎麼看都是異類。很多人傾向於把引用與指標並論來理解,它們其實也不大一樣,雖然實現上基本可以認為引用是一個由編譯器自動幫你解引用(deference)的指標。

需要注意的是,左值引用變數只能用左值(lvalue)來初始化,右值引用變數只能用右值(xvalue 及 prvalue)來初始化,唯一的例外是 const 型別的左值引用,它也能用右值來初始化。值得注意的是,引用對臨時變數的生命週期是有影響的,如前面所說,臨時變數是右值,當一個臨時變數被一個引用(const 左值引用或右值引用)指向時,它的生命週期會被延長[N3690,12.2.5]。

關於右值引用,還有一個很容易讓人迷惑的語義需要說一說,寫法上定義一個右值引用變數的語法如右所示:some_type&& rv_ref = some_rvalue;,但這裡要求 somt_type 必須是一個具體的完整的型別,而不能是模板引數,auto,或 decltype 等需要推導的型別,如果 T 是一個需要推導的型別,則 T&& u_t_ref 稱為 universal reference 或 forwarding reference,根據 reference collapsing 原則及右值引用推導原則,u_t_ref 最後既可能是左值引用也可能是右值引用,具體可以參看這裡

move 與 forward

接下來是很多人特別關心的 std::move()std::forward(),對這倆恰恰想總結的卻不多,總的來說,以我的淺見,std::move() 的主要作用是將一個左值轉為 xvalue, 它的實現,本質上就是一個 static_cast<>。而 std::forward() 則是用來配合 forwarding reference 實現完美轉發,它主要的作用是將一個型別為引用(左值引用或右值引用)的左值,轉化為它的型別所對應的值型別,這個說法實在是太無法理解太不知所云了哈哈,所以我放棄用自己的話來解釋了,請參看 cppreference 上的例子

move 語義

move 語義是一個大家一定要注意,接受,掌握,並理解好的東西。過去使用 c++ 98/03 我們常說 rule of three,即是:如果一個類定義了解構函式,拷貝建構函式,賦值建構函式之一,那麼這三個函式都應該要明確定義,目的是為了確保該類的拷貝語義被正確地處理。

到了 c++11,這個 rule of three 得改成 rule of five。我們知道,一個類如果定義了拷貝建構函式和賦值建構函式,則我們稱它為 copyable 的類。同理,如果一個類定義了 move constructor 和 move assignment operator,那麼我們稱它為 movable 的類。從使用上來說,右值引用是一個很 tricky 的東西,它的正確使用場合應該只有兩個:一個是為自定義型別實現 move 語義,一個是配合 forwarding reference 來實現完美轉發。當你考慮寫一個以右值引用作為引數型別的一般函式時,往往這是錯誤的開始,正確的作法是為相應的型別定義 move 語義,具有 move 語義的型別在作為引數傳遞時,要麼直接傳值(sink parameter),要麼傳 const 左值引用(read only),根本不需要右值引用這種 tricky 的東西。

因此,能夠在適當時候為自定義型別實現 move 語義是一個基本素質,就正如以前處理 copy 語義一樣(會不會將一個類繼承自 boost::noncopyable 也是基本素質)。STL 中所有的容器演算法都妥善定義了對適用型別 move 語義的要求,如只適用於 copyable,或只適用於 movable 等,容器本身更都是 movable 的。一般來說,move 是一個更輕量的操作,對容器其實更友好(內部 copy 可以改為 move,效率更高),比如 vector<>,以前要求其所儲存型別必須 copyable,現在 c++11 以後,只 movable(且 noexcept)的型別也被允許了(當然此時使用者就不能呼叫那些需要 copy 的操作了)。所以,明確定義好自定義型別的 move 語義,意義是很大的。

相關文章