最近這問(wèn)題有從日經(jīng)變時(shí)經(jīng)的趨勢(shì),這里貼出裘老的解釋。求加精。
---------------------------------------------------------------------------------------------
裘宗燕:C/C++ 語(yǔ)言中的表達(dá)式求值
經(jīng)??梢栽谝恍┯懻摻M里看到下面的提問(wèn):“誰(shuí)知道下面C語(yǔ)句給n賦什么值?”
m = 1; n = m+++m++;
最近有位不相識(shí)的朋友發(fā)email給我,問(wèn)為什么在某個(gè)C++系統(tǒng)里,下面表達(dá)式打印出兩個(gè)4,而不是4和5:
a = 4; cout << a++ << a;
C++ 不是規(guī)定 << 操作左結(jié)合嗎?是C++ 書(shū)上寫(xiě)錯(cuò)了,還是這個(gè)系統(tǒng)的實(shí)現(xiàn)有問(wèn)題?
要弄清這些,需要理解的一個(gè)問(wèn)題是:如果程序里某處修改了一個(gè)變量(通過(guò)賦值、增量/減量操作等),什么時(shí)候從該變量能夠取到新值?有人可能說(shuō),“這算什么問(wèn)題!我修改了變量,再?gòu)倪@個(gè)變量取值,取到的當(dāng)然是修改后的值!”其實(shí)事情并不這么簡(jiǎn)單。
C/C++ 語(yǔ)言是“基于表達(dá)式的語(yǔ)言”,所有計(jì)算(包括賦值)都在表達(dá)式里完成。“x = 1;”就是表達(dá)式“x = 1”后加表示語(yǔ)句結(jié)束的分號(hào)。要弄清程序的意義,首先要理解表達(dá)式的意義,也就是:1)表達(dá)式所確定的計(jì)算過(guò)程;2)它對(duì)環(huán)境(可以把環(huán)境看作當(dāng)時(shí)可用的所有變量)的影響。如果一個(gè)表達(dá)式(或子表達(dá)式)只計(jì)算出值而不改變環(huán)境,我們就說(shuō)它是引用透明的,這種表達(dá)式早算晚算對(duì)其他計(jì)算沒(méi)有影響(不改變計(jì)算的環(huán)境。當(dāng)然,它的值可能受到其他計(jì)算的影響)。如果一個(gè)表達(dá)式不僅算出一個(gè)值,還修改了環(huán)境,就說(shuō)這個(gè)表達(dá)式有副作用(因?yàn)樗嘧隽祟~外的事)。a++ 就是有副作用的表達(dá)式。這些說(shuō)法也適用于其他語(yǔ)言里的類(lèi)似問(wèn)題。
現(xiàn)在問(wèn)題變成:如果C/C++ 程序里的某個(gè)表達(dá)式(部分)有副作用,這種副作用何時(shí)才能實(shí)際體現(xiàn)到使用中?為使問(wèn)題更清楚,我們假定程序里有代碼片段“...a[i]++ ... a[j] ...”,假定當(dāng)時(shí)i與j的值恰好相等(a[i] 和a[j] 正好引用同一數(shù)組元素);假定a[i]++ 確實(shí)在a[j] 之前計(jì)算;再假定其間沒(méi)有其他修改a[i] 的動(dòng)作。在這些假定下,a[i]++ 對(duì) a[i] 的修改能反映到 a[j] 的求值中嗎?注意:由于 i 與 j 相等的問(wèn)題無(wú)法靜態(tài)判定,在目標(biāo)代碼里,這兩個(gè)數(shù)組元素訪問(wèn)(對(duì)內(nèi)存的訪問(wèn))必然通過(guò)兩段獨(dú)立代碼完成?,F(xiàn)代計(jì)算機(jī)的計(jì)算都在寄存器里做,問(wèn)題現(xiàn)在變成:在取 a[j] 值的代碼執(zhí)行之前,a[i] 更新的值是否已經(jīng)被(從寄存器)保存到內(nèi)存?如果了解語(yǔ)言在這方面的規(guī)定,這個(gè)問(wèn)題的答案就清楚了。
程序語(yǔ)言通常都規(guī)定了執(zhí)行中變量修改的最晚實(shí)現(xiàn)時(shí)刻(稱為順序點(diǎn)、序點(diǎn)或執(zhí)行點(diǎn))。程序執(zhí)行中存在一系列順序點(diǎn)(時(shí)刻),語(yǔ)言保證一旦執(zhí)行到達(dá)一個(gè)順序點(diǎn),在此之前發(fā)生的所有修改(副作用)都必須實(shí)現(xiàn)(必須反應(yīng)到隨后對(duì)同一存儲(chǔ)位置的訪問(wèn)中),在此之后的所有修改都還沒(méi)有發(fā)生。在順序點(diǎn)之間則沒(méi)有任何保證。對(duì)C/C++ 語(yǔ)言這類(lèi)允許表達(dá)式有副作用的語(yǔ)言,順序點(diǎn)的概念特別重要。
現(xiàn)在上面問(wèn)題的回答已經(jīng)很清楚了:如果在a[i]++ 和a[j] 之間存在一個(gè)順序點(diǎn),那么就能保證a[j] 將取得修改之后的值;否則就不能保證。
C/C++語(yǔ)言定義(語(yǔ)言的參考手冊(cè))明確定義了順序點(diǎn)的概念。順序點(diǎn)位于:
1. 每個(gè)完整表達(dá)式結(jié)束時(shí)。完整表達(dá)式包括變量初始化表達(dá)式,表達(dá)式語(yǔ)句,return語(yǔ)句的表達(dá)式,以及條件、循環(huán)和switch語(yǔ)句的控制表達(dá)式(for頭部有三個(gè)控制表達(dá)式);
2. 運(yùn)算符 &&、||、?: 和逗號(hào)運(yùn)算符的第一個(gè)運(yùn)算對(duì)象計(jì)算之后;
3. 函數(shù)調(diào)用中對(duì)所有實(shí)際參數(shù)和函數(shù)名表達(dá)式(需要調(diào)用的函數(shù)也可能通過(guò)表達(dá)式描述)的求值完成之后(進(jìn)入函數(shù)體之前)。
假設(shè)時(shí)刻ti和ti+1是前后相繼的兩個(gè)順序點(diǎn),到了ti+1,任何C/C++ 系統(tǒng)(VC、BC等都是C/C++系統(tǒng))都必須實(shí)現(xiàn)ti之后發(fā)生的所有副作用。當(dāng)然它們也可以不等到時(shí)刻ti+1,完全可以選擇在時(shí)段 [t, ti+1] 之間的任何時(shí)刻實(shí)現(xiàn)在此期間出現(xiàn)的副作用,因?yàn)镃/C++ 語(yǔ)言允許這些選擇。
前面討論中假定了a[i]++ 在a[i] 之前做。在一個(gè)程序片段里a[i]++ 究竟是否先做,還與它所在的表達(dá)式確定的計(jì)算過(guò)程有關(guān)。我們都熟悉C/C++ 語(yǔ)言有關(guān)優(yōu)先級(jí)、結(jié)合性和括號(hào)的規(guī)定,而出現(xiàn)多個(gè)運(yùn)算對(duì)象時(shí)的計(jì)算順序卻常常被人們忽略??聪旅胬樱?br>(a + b) * (c + d) fun(a++, b, a+5)
這里“*”的兩個(gè)運(yùn)算對(duì)象中哪個(gè)先算?fun及其三個(gè)參數(shù)按什么順序計(jì)算?對(duì)第一個(gè)表達(dá)式,采用任何計(jì)算順序都沒(méi)關(guān)系,因?yàn)槠渲械淖颖磉_(dá)式都是引用透明的。第二個(gè)例子里的實(shí)參表達(dá)式出現(xiàn)了副作用,計(jì)算順序就非常重要了。少數(shù)語(yǔ)言明確規(guī)定了運(yùn)算對(duì)象的計(jì)算順序(Java規(guī)定從左到右),C/C++ 則有意不予規(guī)定,既沒(méi)有規(guī)定大多數(shù)二元運(yùn)算的兩個(gè)對(duì)象的計(jì)算順序(除了&&、|| 和 ,),也沒(méi)有規(guī)定函數(shù)參數(shù)和被調(diào)函數(shù)的計(jì)算順序。在計(jì)算第二個(gè)表達(dá)式時(shí),首先按照某種順序算fun、a++、b和a+5,之后是順序點(diǎn),而后進(jìn)入函數(shù)執(zhí)行。
不少書(shū)籍在這些問(wèn)題上有錯(cuò)(包括一些很流行的書(shū))。例如說(shuō)C/C++ 先算左邊(或右邊),或者說(shuō)某個(gè)C/C++ 系統(tǒng)先計(jì)算某一邊。這些說(shuō)法都是錯(cuò)誤的!一個(gè)C/C++ 系統(tǒng)可以永遠(yuǎn)先算左邊或永遠(yuǎn)先算右邊,也可以有時(shí)先算左邊有時(shí)先算右邊,或在同一表達(dá)式里有時(shí)先算左邊有時(shí)先算右邊。不同系統(tǒng)可能采用不同的順序(因?yàn)槎挤险Z(yǔ)言標(biāo)準(zhǔn));同一系統(tǒng)的不同版本完全可以采用不同方式;同一版本在不同優(yōu)化方式下,在不同位置都可能采用不同順序。因?yàn)檫@些做法都符合語(yǔ)言規(guī)范。在這里還要注意順序點(diǎn)的問(wèn)題:即使某一邊的表達(dá)式先算了,其副作用也可能沒(méi)有反映到內(nèi)存,因此對(duì)另一邊的計(jì)算沒(méi)有影響。
回到前面的例子:“誰(shuí)知道下面C語(yǔ)句給n賦什么值?”
m = 1; n = m++ +m++;
正確回答是:不知道!語(yǔ)言沒(méi)有規(guī)定它應(yīng)該算出什么,結(jié)果完全依賴具體系統(tǒng)在具體上下文中的具體處理。其中牽涉到運(yùn)算對(duì)象的求值順序和變量修改的實(shí)現(xiàn)時(shí)刻問(wèn)題。對(duì)于:
cout << a++ << a;
我們知道它是
(cout.operator <<(a++)).operator << (a);
的簡(jiǎn)寫(xiě)。先看外層函數(shù)調(diào)用,這里需要算出所用函數(shù)(由加下劃線的一段得到),還需要計(jì)算a的值。語(yǔ)言沒(méi)有規(guī)定哪個(gè)先算。如果真的先算函數(shù),這一計(jì)算中出現(xiàn)了另一次函數(shù)調(diào)用,在被調(diào)函數(shù)體執(zhí)行前有一個(gè)順序點(diǎn),那時(shí)a++的副作用就會(huì)實(shí)現(xiàn)。如果是先算參數(shù),求出a的值4,而后計(jì)算函數(shù)時(shí)的副作用當(dāng)然不會(huì)改變它(這種情況下輸出兩個(gè)4)。當(dāng)然,這些只是假設(shè),實(shí)際應(yīng)該說(shuō)的是:這種東西根本不該寫(xiě),討論其效果沒(méi)有意義。
有人可能說(shuō),為什么人們?cè)O(shè)計(jì) C/C++時(shí)不把順序規(guī)定清楚,免去這些麻煩?C/C++ 語(yǔ)言的做法完全是有意而為,其目的就是允許編譯器采用任何求值順序,使編譯器在優(yōu)化中可以根據(jù)需要調(diào)整實(shí)現(xiàn)表達(dá)式求值的指令序列,以得到效率更高的代碼。像Java那樣嚴(yán)格規(guī)定表達(dá)式的求值順序和效果,不僅限制了語(yǔ)言的實(shí)現(xiàn)方式,還要求更頻繁的內(nèi)存訪問(wèn)(以實(shí)現(xiàn)副作用),這些可能帶來(lái)可觀的效率損失。應(yīng)該說(shuō),在這個(gè)問(wèn)題上,C/C++和Java的選擇都貫徹了它們各自的設(shè)計(jì)原則,各有所獲(C/C++ 潛在的效率,Java更清晰的程序行為),當(dāng)然也都有所失。還應(yīng)該指出,大部分程序設(shè)計(jì)語(yǔ)言實(shí)際上都采用了類(lèi)似C/C++的規(guī)定。
討論了這么多,應(yīng)該得到什么結(jié)論呢?C/C++ 語(yǔ)言的規(guī)定告訴我們,任何依賴于特定計(jì)算順序、依賴于在順序點(diǎn)之間實(shí)現(xiàn)修改效果的表達(dá)式,其結(jié)果都沒(méi)有保證。程序設(shè)計(jì)中應(yīng)該貫徹的規(guī)則是:如果在任何“完整表達(dá)式”(形成一段由順序點(diǎn)結(jié)束的計(jì)算)里存在對(duì)同一“變量”的多個(gè)引用,那么表達(dá)式里就不應(yīng)該出現(xiàn)對(duì)這一“變量”的副作用。否則就不能保證得到預(yù)期結(jié)果。注意:這里的問(wèn)題不是在某個(gè)系統(tǒng)里試一試的問(wèn)題,因?yàn)槲覀儾豢赡茉囼?yàn)所有可能的表達(dá)式組合形式以及所有可能的上下文。這里討論的是語(yǔ)言,而不是某個(gè)實(shí)現(xiàn)??偠灾?,絕不要寫(xiě)這種表達(dá)式,否則我們或早或晚會(huì)某種環(huán)境中遇到麻煩。
后記:去年參加一個(gè)學(xué)術(shù)會(huì)議,看到有同行寫(xiě)文章討論某個(gè)C系統(tǒng)里表達(dá)式究竟按什么順序求值,并總結(jié)出一些“規(guī)律”。從討論中了解到某“程序員水平考試”出了這類(lèi)題目。這使我感到很不安。今年給一個(gè)教師學(xué)習(xí)班講課,發(fā)現(xiàn)許多專(zhuān)業(yè)課教師也對(duì)這一基本問(wèn)題也不甚明了,更覺(jué)得問(wèn)題確實(shí)嚴(yán)重。因此整理出這篇短文供大家參考。
后后記:4年多過(guò)去了,許多新的和老的教科書(shū)仍然在不厭其煩地討論在C語(yǔ)言里原本并無(wú)意義的問(wèn)題(如本文所指出的)。希望學(xué)習(xí)和使用C語(yǔ)言的人不要陷入其中。