簡單聊聊C/C++中的左值和右值

前言

爲什麼標題要寫成簡單聊聊,而不是寫成什麼“C++中左值與右值詳解”或者現在很流行的“驚了!看了這一篇左值與右值講解,他吊打了面試官”,其實帶有詳解這個詞是需要勇氣的,最起碼要融會貫通之後纔敢這麼說吧,本來是學習右值引用的,結果涉及到了左值和右值,然後去了解他們歷史發現也是有些混亂,操作中又經常涉及到運算符優先級,真是越學越亂了。

問題

索性也把右值引用放一邊,從頭來看看這個左值和右值,其實我跟這兩個詞一點都不熟,最多就是在編譯報錯的提示框中看到他們,當然有時候也會看到他們的英文名字 lvaluervalue,這時候一般就是編譯器開始抱怨了,說我寫了什麼它不能理解的東西,其實嘛,我自己都沒完全理解,從現在開始邊學邊總結了,先展示一個常見報錯:

error: lvalue required as increment operand

這是什麼意思,這麼繞嘴,左值需要作爲增長操作數,請說人話:自增操作需要一個可以賦值的變量作爲操作數,需要變量就直說嘛,爲什麼要左值、右值的把人都繞蒙了。

歷史淵源

這個世界一直是在變化的,可能之前你一直引以爲豪的經驗大樓,轉眼之間就會傾塌。關於左值和右值的歷史,普遍的觀點是最初來源於 C 語言,後來被引入到了 C++,但是關於左值和右值的含義和實現卻在一直改變和完善,對於它的歷史講解發現一篇總結的比較好的文章 《C/C++ 左值和右值, L-value和R-value》

這是2012年的一篇文章,文中給出了歷史說明依據,最後還舉了一些例子來說明 CC++ 關於左值實現的不同,但是實際操作後你會發現,時間的車輪早已向前行進了一大截,文中提到的那些不同,在最新的 gccg++ 編譯器上早已變得相同,文中提到的反例現在看來幾乎沒有意義了。

簡單梳理下,左值的定義最早出現在 《The C Programming Language 》一書中,指的是引用一個對象,放在賦值表達式 = 左邊的值。

後來在新的 C 語言標準中提到左值是賦值表達式 = 左邊的值或者需要被改變的值,而等號的右邊的值被稱爲右值。左值更好的表達爲可以定位的值,而右值是一種表達數據的值,基於這個表述 L-value 可以理解爲 locator value,代表可尋址,而 R-value 可以理解爲 read value,代表可讀取。

不過以上的新解,完全是人們爲了理解左值、右值賦予的新含義,從歷史發展來看,一開始左值和右值完全就是通過等號的左邊和右邊來命名的,只不過隨着標準的完善和語言的發展、更替,雖然兩個名字保留了下來,但是它們的含義卻在逐步發生改變,與最初誕生時的 = 左右兩邊的值這個含義相比,已經相差很多了。

認識左值和右值

關於左值右值有幾條規則和特點,先列舉在這裏,後面可以跟隨例子慢慢體會:

  1. 左值和右值都是指的表達式,比如 int a = 1 中的 a 是左值,++a 是左值, func() 也可能是左值,而 a+1 是右值, 110 也是一個右值。
  2. 左值可以放在 = 的左邊,右值只能放在 = 的右邊,這其中隱含的意思就是左值也能放在 = 的右邊,但是右值不能放在 = 的左邊。
  3. 左值可以取地址,代表着內存中某個位置,可以存儲數據,右值僅僅是一個值,不能取地址,或者它看起來是一個變量,但它是臨時的無法取地址,例如一個函數的非引用的值返回。

以上規則從定義來看一點也不嚴謹,比如一個常量定義是可以賦值,後面就不行了,它也可以取地址,但是不能賦值的它到底是左值還是右值,這點其實不用糾結,心裏知道這個情況就可以了。

再比如一個普通變量,它原本是一個左值,當用它給其他變量賦值的時候,它又化身爲一個右值,這時它也可以取地址,好像與上面的說法相違背了,但是仔細想想真的是這樣嗎?它只是臨時化身爲右值,其實是一個左值,所以纔可以取地址的。

其實你如果不做學術研究、不斤斤計較,那麼完全可以把能夠賦值的表達式作爲左值,然後把左值以外的表達式看成右值,如果你不熟悉解左值和右值可能根本不會影響你平時的工作和學習,但是瞭解它有助於我們深入理解一些內置運算符和程序執行過程,以及在出現編譯錯誤的時候及時定位問題。

具體的示例

最簡單的賦值語句

int age = 18;

這個賦值語句很簡單,= 作爲分界線,左邊的 age 是左值,可以被賦值,可以取地址,它其實就是一個表達式,代表一個可以存儲整數的內存地址;右邊的 18 也是一個表達式,明顯只能作爲右值,不能取地址。

18 = age;

這個語句在編譯時會提示下面的錯誤:

error: lvalue required as left operand of assignment

錯誤提示顯示:賦值語句的左邊需要一個左值,顯然 18 不能作爲左值,它不代表任何內存地址,不能被改變。

如果程序中的表達式都這麼簡單就不需要糾結了,接着我們往下看一些複雜點的例子。

自增自減運算

++age++;

第一眼看到這個表達式,你感覺它會怎樣運算,編譯一下,你會發現編譯失敗了,錯誤如下:

error: lvalue required as increment operand

加個括號試試:

++(age++)

編譯之後會出現相同的錯誤:

error: lvalue required as increment operand

再換一種加括號的方式再編譯一次:

(++age)++

這次成功編譯了,並且輸出值之後發現 age 變量增加了兩次。

先不考慮左值右值的問題,我們可以從這個例子中發現自增運算的優先級,後置自增 age++ 的優先級要高於前置自增 ++age 的優先級。

現在回過頭來看看之前的編譯錯誤,爲什麼我們加括號改變運算順序之後就可以正常執行了呢?這其實和自增運算的實現有關。

前置自增

前置自增的一般實現,是直接修改原對象,在原對象上實現自增,然後將原對象以引用方式返回:

UPInt& UPInt::operator++()
{
 *this += 1;    // 原對象自增
 return *this;  // 返回原對象
}

這裏一直操作的是原對象,返回的也是原對象的引用,所以前置自增表達式的結果是左值,它引用的是原對象之前所佔用的內存。

後置自增

後置自增的一般實現,是先將原對象的數據存儲到臨時變量中,接着在原對象上實現自增,然後將臨時變量以只讀的方式返回:

const UPInt UPInt::operator++(int)
{
 UPInt oldValue = *this; // 將原對象賦值給臨時變量
 ++(*this);              // 原對象自增
 return oldValue;        // 返回臨時變量
}

這裏返回的是臨時變量,在函數返回後就被銷燬了,無法對其取地址,所以後置自增表達式的結果是右值,不能對其進行賦值。

所以表達式 ++age++; 先進行後置自增,然後再進行前置自增就報出編譯錯誤了,因爲不能修改右值,也不能對右值進行自增操作。

自增表達式賦值

前面說到前置自增表達式是一個左值,那能不能對其賦值呢?當然可以!試試下面的語句:

++age = 20;

這條語句是可以正常通過編譯的,並且執行之後 age 變量的值爲 20

函數表達式

函數可以作爲左值嗎?帶着這個疑問我們看一下這個賦值語句:

func() = 6;

可能有些同學會有疑問,這是正常的語句嗎?其實它是可以正常的,只要 func() 是一個左值就可以,怎麼才能讓他成爲一個左值呢,想想剛纔的前置自增運算可能會給你啓發,要想讓他成爲左值,它必須代表一個內存地址,寫成下面這樣就可以了。

int g;

int& func()
{
    return g;
}

int main()
{
    func() = 100;
}

函數 func() 返回的是全局變量 g 的引用,變量 g 是一個可取地址的左值,所以 func() 表達式也是一個左值,對其賦值後就改變了全局變量 g 的值。

那麼我們注意到這裏 func() 函數返回的是全局變量的引用,如果是局部變量會怎麼樣呢?

int& func()
{
    int i = 101;
    return i;
}

int main()
{
    func() = 100;
}

上面的代碼編譯沒有錯誤,但是會產生一個警告,提示返回了局部變量的引用:

warning: reference to local variable ‘i’ returned [-Wreturn-local-addr]

運行之後可就慘了,直接顯示段錯誤:

Segmentation fault (core dumped)

改爲局部變量之後,func() 函數雖然返回了一個值,但是這個值是一個臨時值,函數返回之後該值被銷燬,對應的內存空間也不屬於它了,所以在最後賦值的時候纔會出現段錯誤,就和我們訪問非法內存是產生的錯誤時一樣的。

總結

  • 可以被賦值的表達式是左值,左值可以取地址。
  • 右值應該是一個表示值的表達式,不是左值的表達式都可以看成是右值
  • 後置自增操作符的優先級要高於前置自增操作符,它們是按照從右向左結合的
  • 關於左值和右值的知識點還有很多,後續想到了再補充,我也是邊學邊總結,如果有錯誤也歡迎小夥伴們及時指出,我會及時改正的

時刻靜下來想想當初爲什麼出發,不要在現實的汪洋中偏離航向

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章