值類別
注意:這部分的內容很可能對算法競賽無用,但如果你希望更深入地理解 C++,寫出更高效的代碼,那麼本文的內容也許會對你有所幫助。
每個 C++ 表達式都有兩個屬性:類型 (type) 和值類別 (value category)。前者是大家都熟悉的,但作為算法競賽選手,很可能完全不知道後者是什麼。不管你在不在意,值類別是 C++ 中非常重要的一個概念。
關於名詞的翻譯
type 和 category 都可以翻譯為「類型」或「類別」,但為了區分兩者,下文中統一將 type 翻譯為「類型」,category 翻譯為「類別」。
從 CPL 語言的定義説起
左值與右值的概念最早出現在 C 語言的祖先語言:CPL。
在 CPL 的定義中,lvalue 意為 left-hand side value,即能夠出現在賦值運算符(等號)左側的值,右值的定義亦然。
C 和 C++11 以前
C 語言沿用了相似的分類方法,但左右值的判斷標準已經與賦值運算符無關。在新的定義中,lvalue 意為 locate value,即能進行取地址運算 (&) 的值。
可以這麼理解:左值是有內存地址的對象,而右值只是一箇中間計算結果(雖然編譯器往往需要在內存中分配地址來儲存這個值,但這個內存地址是無法被程序員感知的,所以可以認為它不存在)。中間計算結果就意味着這個值馬上就沒用了,以後不會再訪問它。
比如在 int a = 0; 這段代碼中,a 就是一個左值,而 0 是一個右值。
常見的關於左右值的誤解
以下幾種類型是經常被誤認為右值的左值:
- 字符串字面量:由於 C++ 兼容 C 風格的字符串,需要能對一個字符串字面量取地址(即頭指針)來傳參。但是其他的字面量,包括自定義字面量,都是右值。
- 數組:數組名就是數組首個元素的指針這種説法似乎誤導了很多人,但這個説法顯然是錯誤的,對數組進行取地址是可以編譯的。數組名可以隱式的退化成首個元素的指針,這才是右值。
C++11 開始
從 C++11 開始,為了配合移動語義,值的類別就不是左值右值這麼簡單了。
考慮一個簡單的場景:
1 2 3 | |
我們知道第三行的賦值運算複雜度是正比於 a 的長度的,複製的開銷很大。但有些情況下,比如 a 在以後的代碼中不會再使用,那麼我們完全可以把 a 所持有的內存「轉移」到 b 上,這就是移動語義乾的事情。
我們姑且不管移動是怎麼實現的,先來考慮一下我們如何標記 a 是可以移動的。顯然不管能否移動,這個表達式的類型都是 vector 不變,所以只能對值類別下手。不可移動的 a 是左值,如果要在原有的體系下標記可以移動的 a,我們只能把它標記為右值。但標記為右值又是不合理的,因為這個 a 實際上擁有自己的內存地址,與其他右值有有根本上的不同。所以 C++11 引入了 亡值 (xvalue) 這一值類別來標記這一種表達式。
於是我們現在有了三種類別:左值 (lvalue)、純右值 (prvalue)、亡值 (xvalue)(純右值就是原先的右值)。
然後我們發現亡值同時具有一些左值和純右值的性質,比如它可以像左值一樣取地址,又像右值一樣不會再被訪問。
所以又有了兩種組合類別:泛左值 (glvalue)(左值和亡值)、右值 (rvalue)(純右值和亡值)。
有一個初步的感性理解後,來看一下標準委員會對它們的定義:
- A glvalue(generalized lvalue) is an expression whose evaluation determines the identity of an object, bit-field, or function.
- A prvalue(pure rvalue) is an expression whose evaluation initializes an object or a bit-field, or computes the value of an operand of an operator, as specified by the context in which it appears, or an expression that has type cv void.
- An xvalue(eXpiring value) is a glvalue that denotes an object or bit-field whose resources can be reused(usually because it is near the end of its lifetime)。
- An lvalue is a glvalue that is not an xvalue.
- An rvalue is a prvalue or an xvalue.
上述定義中提到了一個叫位域 (bit-field) 的東西。如果你不知道位域是什麼,忽略它即可,後文也不會提及。
其中關鍵的兩個概念:
- 是否擁有身份 (identity):可以確定表達式是否與另一表達式指代同一實體,例如比較它們所標識的對象或函數的(直接或間接獲得的)地址
- 是否可以被移動 (resources can be reused):對象的資源可以移動到別的對象中
這 5 種類型無非就是根據上面兩種屬性的是與否區分的,所以用下面的這張表格可以幫助理解:
| 擁有身份(glvalue) | 不擁有身份 | |
|---|---|---|
| 可移動(rvalue) | xvalue | prvalue |
| 不可移動 | lvalue | 不存在 |
注意不擁有身份就意味着這個對象以後無法被訪問,這樣的對象顯然是可以被移動的,所以不存在不擁有身份不可移動的值。
C++17 帶來的新變化
從拷貝到移動提升了不少速度,那麼我們是否能夠優化的更徹底一點,把移動的開銷都省去呢?
考慮這樣的代碼:
1 2 3 4 5 6 7 | |
make_vector 函數根據一輸入生成一個 vector。這個 vector 一開始在 make_vector 的棧上被構造,隨後又被移動到調用者的棧上,需要一次移動操作,這顯然很浪費,能不能省略這次移動?
答案是肯定的,這就是 RVO 優化,即省略拷貝。通常的方法是編譯器讓 make_vector 返回的對象直接在調用者的棧上構造,然後 make_vector 在上面進行修改。這相當與這樣的代碼:
1 2 3 4 5 6 | |
在 C++17 以前,儘管標準未做出規定,但主流編譯器都實現了這種優化。在 C++17 以後,這種優化成為標準的硬性規定。
回到和移動語義剛被提出時的問題,如何確定一個移動賦值是可以省略的?再引入一種新的值類別?
不,C++11 的值類別已經夠複雜了。我們意識到在 C++11 的標準下,亡值和純右值都是可以移動的,那麼就可以在這兩種類別上做文章。
C++17 以後,純右值不再能移動,但可以隱式地轉變為亡值。對於純右值用於初始化的情況下,可以省略拷貝,而其他不能省略的情況下,隱式轉換為亡值進行移動。
所以在 C++17 之後的值類別,被更為整齊的劃分為泛左值與純右值兩大塊,右值存在的意義被削弱。這樣的改變某種程度上簡化了整個值類別體系。
參考文獻與推薦閲讀
本页面最近更新:,更新历史
发现错误?想一起完善? 在 GitHub 上编辑此页!
本页面贡献者:OI-wiki
本页面的全部内容在 CC BY-SA 4.0 和 SATA 协议之条款下提供,附加条款亦可能应用