天天看點

情景劇:C/C++中的未定義行為(undefined behavior)

本文嘗試以情景劇的方式,輕松、直覺地解釋C/C++中未定義行為(undefined behavior)的概念、設計動機、優缺點等内容,希望讀者能夠通過閱讀本文,對undefined behavior有一個清晰、深刻、全面的認識。

寫在前面

本文嘗試以情景劇的方式,輕松、直覺地解釋C/C++中未定義行為(undefined behavior)的概念、設計動機、優缺點等内容1,希望讀者能夠通過閱讀本文,對undefined behavior有一個清晰、深刻、全面的認識。

正文

人物

彪哥:可将其視為C/C++标準(standard)或标準的制定者。

小編們:可将其視為編譯器或編譯器的編寫者(生産商),分别記為“小編1”、“小編2”、…、“小編N”。注意,這裡的編譯器是“廣義”的,即指将源代碼轉換為可執行檔案的“處理器”。

小猿們:程式員,即C/C++的使用者,分别記為“小猿1”、“小猿2”、…、“小猿N”。

布景

這是一間寬敞明亮的會議室,牆上的橫幅上寫着“關于C語言标準制定與實作的三方洽談會第一次會議”。橫幅的正下方、長方形會議桌的一條短邊旁,是一位年約四十、微微發福的光頭中年男人,他手握茶杯,正襟危坐。在他面前的名牌上,赫然寫着“彪哥”。同時,在他的左右兩旁,即會議桌的兩個長邊上,各坐着幾個不同年齡、神情各異的人。他們依發量多少依次就坐,發量越少的離彪哥越近,其中,彪哥左手邊的人面前的名牌上依次寫着“小猿1”、“小猿2”、…、“小猿N”,而彪哥右手邊的人面前的名牌上依次寫着“小編1”、“小編2”、…、“小編N”。令人驚訝的是,整個會議室中,竟沒有一個頭發濃密之人,就連唯一一位女同志,發量也隻能勉強算是正常。

第一幕 彪哥開場

彪哥(輕咳一聲):咳咳,那個,咱們就開始吧。我先說兩句啊,我們的語言,C,主打的是什麼?啊?是快!是高效!正所謂魚與熊掌不可兼得,是以啊,安全性這一塊,必要的時候,可以适當地放寬一些嘛!你比如說,數組越界通路,越不越界,(看着小猿們)你們心裡沒點數嗎?代碼是你們自己寫的啊!再說了,從邏輯上講,你通路你已經定義的範圍,這是有意義的,但是,如果你通路的範圍你之前都沒有定義,這本身就沒有任何意義嘛!就好比一個老師做家訪,班上有10個學生,他偏要通路第11家,這不是扯呢嗎?再比如,解引用空指針,指針空不空,(再次看向小猿們)你們心裡沒點數嗎?代碼是你們自己寫的啊!再說了,從邏輯上講,空指針沒有指向任何對象,你去解引用它,這本身就沒有任何意義嘛!還有啊,像 char *p = "hello"; 這樣的代碼,你既然直接寫出了 "hello" 這一字元串字面值(sring literal),潛台詞就是将其作為一個整體、一個常量來用,後面不打算再改了。我的想法是,直接将字元串字面值放入全局的隻讀資料區2,在記憶體中僅儲存一份拷貝,這樣,當下次再使用的時候,比如 char *q = "hello"; ,就可以直接共享記憶體了3。可你們,(再次看向小猿們)偏偏有人要寫 p[0] = 'y'; 這樣的代碼,你說你,如果打算将 "hello" 作為字元數組的話,一開始就直接寫 char arr[] = {'h', 'e', 'l', 'l', 'o'}; 不好嗎?是以啊,對以上這些邏輯上錯誤的、不合常理的、毫無意義的行為,我不打算花費任何額外的功夫來提供任何保障,因為我沒有必要因為個别人的愚蠢而犧牲語言的效率。換言之,我打算将以上行為稱為“未定義行為”,undefined behavior,并且,對這些行為,我不做任何的規定,(看着小編們,比了個V的手勢)具體你們怎麼搞,我的意見就兩個字——随便!(看着大家一時愣在那裡,彪哥頓了頓,又故作鎮定道地)當然了,我這麼做并不是想偷懶,而且,這對你們雙方都是有好處的。(看着小編們)一方面,這給了你們很大的自由發揮空間,(又看向小猿們)另一方面,這麼一來,我們語言的學習路線一下子就陡峭了,學習門檻一下子就拔高了,那你們的工資,不也随之提高了嗎?你們天天嚷嚷着“要提高程式員的薪資待遇”、“要提高程式員的薪資待遇”,這種事情,光從制度和政策上解決是治标不治本滴,要解決,還是要從技術上徹底解決嘛!你們說,是不是啊?

小猿1(笑嘻嘻):對對對!老大您說的太對了!

小猿2(小聲嘟囔):完犢子,這行算是混不下去了!

彪哥(義正辭嚴):再說了,我們的語言是高大上的語言,不是什麼人上幾個月的教育訓練班就能學會的。我要做到的是,當其它語言的教育訓練廣告爛大街的時候,關于C語言的教育訓練一個都沒有!憑什麼我發明語言,卻讓這幫人拿來掙錢啊!(像是忽然想起什麼)哎哎哎,你看我,一不留神又跑題了!那個,我就先說這麼多,接下來,你們雙方有什麼意見,都說一說,說一說嘛!

第二幕 各抒己見

小編3(滿臉深沉):老大,您這個“随便”資訊量有點大啊!按照您的意思,對于未定義行為,我可以給出警告或者直接報錯;也可以睜隻眼閉隻眼,置之不理;還可以關機、格盤、删庫、下病毒喽?随便嘛!

彪哥(笃定地):對!理論上确實如此!(用下巴指指小猿們)如果你不怕被他們打死的話!

小編3(看了眼對面殺氣騰騰的小猿們,滿臉賠笑):我就說說,說說而已!

小編4(鄙夷地看了小編3一眼,又滿臉谄媚地看着彪哥):作為一個有擔當的編譯器,理應為彪哥和程式員朋友們分憂!對于某些未定義行為,我們也可以給出自己的定義嘛!比如,我們可以指定一個編譯選項 -fwrapv ,使得編譯器在任何情況下(無論是否啟用優化或啟用何種級别的優化),對有符号整數的溢出做wrap around處理4。對吧,彪哥?

彪哥(贊許地看着小編4):對!都說了,對于未定義行為,你們要怎麼搞,我不管。你們完全可以提供自己的實作,将某些未定義行為變成已定義行為嘛!5。

小編1(鼓掌):undefined behavior,wonderful!老大,你這麼一搞哇,可給我們編譯器減輕了不少的工作。就拿數組越界通路這件事來說吧,如果在編譯期間檢測數組越界,那肯定得做額外的工作,這樣一來,效率必然是會受到影響的嘛!現在好了,老大你把數組越界通路定義成undefined behavior,那我們就可以對越界通路這件事置之不理了,換句話說,我們預設所有通路都是有效的、安全的,直接生成彙編代碼并最終生成可執行檔案,至于執行時發生什麼,那就不關我們的事了!再說了,安全通路數組元素,本就應該由程式員自己來保障嘛!

小猿4(陰陽怪氣):幺~,真是甩得一手好鍋呀!

小編2(急切地):我……我說,美……美女,話不……不能這麼說呀!你……你比如,下……下面這段代碼:

1 #include <stdio.h>
 2 
 3 int main()
 4 {
 5     int arr[] = {1, 2, 3, 4, 5};
 6     int idx = 0;
 7     scanf("%d", &idx);
 8     printf("%d\n", arr[idx]);
 9     return 0;
10 }      

索引是……是運作時由使用者輸入的,你讓我們怎……麼在編譯期檢測,(兩手一攤)臣妾做不到啊!相……相反地,你們程式員加一個對索引的條……條件判斷,卻是易……易如反掌的事!你說我說得對嗎,美……美女?

小編4(得意地):未定義行為,要得!這給我們進行編譯優化提供了更大的空間和更多的可能性。比如,下面這段代碼:

1 #include <stdio.h>
 2 #include <limits.h>
 3 
 4 int foo(int x)
 5 {
 6     return x + 1 > x;
 7 }
 8 
 9 int main()
10 {
11     int res = foo(INT_MAX);
12     printf("%d\n", res);
13     return 0;
14 }      

如果不啟用優化(即使用-O0選項), foo 函數對應的彙編代碼是這樣的:

情景劇:C/C++中的未定義行為(undefined behavior)

                    圖1  -O0下foo函數的彙編代碼 

并且輸出結果是06。

然而,如果使用O2級别優化, foo 函數對應的彙編代碼是這樣的:

情景劇:C/C++中的未定義行為(undefined behavior)

              圖2  -O2下foo函數的彙編代碼 

顯然,輸出結果是17。

 小編4(繼續口沫橫飛):從數學,或者自然科學的角度來講,一個數加上1,一定比之前大。然而,在現代計算機中,有符号整數通常用2進制補碼(2's complement)表示,同時,存儲一個整數的bit位也是有限的(如32bit),于是,就出現了 INT_MAX + 1 = INT_MIN 這種奇葩結果(前提是采用wrap around規則)。對于 return x + 1 > x; ,如果為了照顧 INT_MAX + 1 = INT_MIN 這唯一的特例,就不得不生成圖1所示的一大堆彙編代碼。反之,如果我們不考慮這一特例,即不考慮有符号整數的溢出,那 x + 1 > x 就永遠是成立的,于是,就可以直接生成圖2所示的彙編代碼,進而最終提升程式的執行效率。但是,能夠這樣優化的大前提是,标準允許我們對溢出的情況置之不理。幸運的是,彪哥将有符号整數的溢出定義為undefined behavior,這相當于給了我們全權處置權,這才使以上優化成為可能,這充分展現出了未定義行為的好處!

小猿4(厲聲道):你這是狡辯!現實中誰會寫 x + 1 > x 這樣的不等式!

小編4(不慌不忙):好,那我就來個不狡辯的。(笑嘻嘻地沖小猿4)美女,你猜下面的代碼段在-O2選項下會生成怎樣的彙編代碼?

1 int fun(int i) 
2 { 
3     int j, k = 0; 
4     for (j = i; j < i + 10; ++j) 
5     {
6         ++k;
7     } 
8     return k; 
9 }      

 小編4(得意洋洋):怎麼樣?猜不出來吧?美女,請上眼!

情景劇:C/C++中的未定義行為(undefined behavior)

            圖3  -O2下fun函數的彙編代碼

小編4(搖頭晃腦,手舞足蹈):怎麼樣?驚不驚喜?意不意外?

小猿3(左手手指張開放在嘴上做驚訝狀):我的天呐!我們寫的代碼,讓你們霍霍成啥樣了!

小編3(表情鄙夷,全身嘚瑟):什麼叫霍霍呀,是優化,優化懂不懂!

小猿4(疾言厲色):你這還是狡辯,循環次數跟 i 毫無關系,我直接寫 int k = 10; 不好嗎?什麼爛代碼!

小編4(故做嚴肅狀):你也知道這是爛代碼啊!即使你們程式員寫出了屎一樣的代碼,我們編譯器也能把它優化得如春風般簡潔清新,而這,正是undefined behavior存在的意義!

小猿4(氣急敗壞地指向小編4):你……你!

小猿2(沖小猿4和小編4做了個稍安勿躁的手勢):那個,兩位,不要着急,消消氣,請讓我來說兩句。那個,要我說呀,這undefined behavior确實能帶來一定的好處,但不可否認,它也造成了一定的負面影響。首先,它大大增加了程式的調試難度,比如說,最開始老大提到的 p[0] = 'y'; ,這句試圖修改隻讀資料區,但編譯器沒有任何警告,直到運作時,程式崩潰。對于沒有經驗的程式員來說,他可能根本意識不到究竟是哪裡出問題了!其次,未定義行為可能會帶來安全隐患,比如說,黑客可能會利用數組越界通路執行惡意代碼。是以啊,要我說,這undefined behavior,我們還是不要搞了。我建議,對所有行為,我們都應該給出明确的定義,至于怎麼定義,我們大家可以群策群力呀。大家說,是不是啊?

小編2(指手畫腳):老……弟,你前邊說的,都……都對!但……是,無論是修改字元串字面值還……還是數組越界通路,都是不應該的,是邏輯錯誤的,這……這些,都應該由……你……你們程式員努力避免,你不……不能為了自己編碼和調試友善,就……增加我們的工作量、降低語言的效率吧!這……事不該我……我們負責啊!

小猿4(咬牙切齒):我說你們除了甩鍋還會什麼!我們不是不盡力,可我們再怎麼盡力保障代碼的正确性和有效性,也不可能做到萬無一失啊!從編譯層面進行規範和檢測,才是根本上的正解啊!是以,我建議,對于undefined behavior,直接廢除!

小編4(針鋒相對):不行,要堅決落實!

小猿2、3、4(大聲):直接廢除!

小編1、2、3、4(亦大聲):堅決落實!

(雙方炒作一團。)

彪哥(大喝一聲):停!别吵吵了!(語氣緩和下來)這樣吧,我們舉手表決,支援堅決落實的舉左手,支援直接廢除的舉右手!開始表決!

(小編們齊刷刷地舉起了左手,小猿2、3、4齊刷刷地舉起了右手,小猿1在同伴的怒視下,心不甘情不願地将剛舉起的左手放下,緩緩舉起了右手。)

彪哥(看看左右):還真是泾渭分明啊!下面,輪到我表态了。(說着,舉起了左手)好了,5:4,堅決落實派以微弱優勢勝出!堅決落實undefined behavior!少數服從多數,任何人不得反對!

第三幕 會議紀要

彪哥(又将語氣放緩和了些):那個,既然都已經做出決定了,我們的會議差不多也要結束了。下面,讓我們一起總結一下這次會議的要點。那個,我先說。

彪哥(正色道):

  1. 第一,我們的C語言,是有國際标準的,标準,是語言的藍圖,也是靈魂和基石;編譯器,是語言的實作,理論上,對于标準中任何明确規定的條款,都應該落實,否則,就不是C編譯器;C程式,也就是.c檔案和.h檔案,是C語言的應用。這一點,是正确了解undefined behavior的大前提。
  2. 第二,undefined behavior是從标準的角度而言的。所謂“未定義”,是指對那些“錯誤”的行為(編碼),标準沒有說明如何處理,也沒有做任何的規定。

彪哥(看看左右兩邊):那個,我就說這麼多。下面,你們雙方也各派一名代表,發表一下會議總結吧!

小編4(站起來,看着手中的筆記本,嚴肅道地):

  1. 第一,所謂未定義行為,是指對于程式員寫的某些在常理上或邏輯上存在錯誤,并且很容易在執行時出錯(如崩潰、結果不符合預期、或是引發其它更加嚴重、出乎意料的結果)的代碼,标準并未做任何規定說該怎樣處理,因為這些代碼本來就是沒有任何意義的。
  2. 第二,既然标準未做任何規定,也就是說沒有對我們編譯器做任何限制,那我們編譯器就可以自(wei)由(suo)發(yu)揮(wei)了。對于未定義行為,我們可以給出自己的定義,如報錯、警告、或是其它特定的處理方式;也可以置之不理,完全無視;當然,理論上也可以做其它任何事情,如關機、格盤、删庫等等。
  3. 第三,對于未定義行為引發的任何後果,編譯器概不負責。因為未定義行為本身就是不受法律(即标準)保護的。編譯器的職責,隻是為符合标準的“已定義行為”生成高效的代碼(彙編及可執行檔案)8。
  4. 第四,編譯器之是以對某些未定義行為不做檢測,主要有以下原因。首先,檢測需要做額外的工作,某些情況下可能會明顯降低編譯乃至執行效率,是以,出于效率考慮,沒有做檢測。其次,确實無法檢測,如數組元素索引來自運作時的使用者輸入或是傳感器的實時輸入等。最後,在某些情況下, 編譯器不檢測和處理未定義行為(即預設所有行為都是合理合法、有明确定義的),可以極大地優化程式,生成高效的可執行代碼。
  5. 第五,未定義行為可以帶來好處,主要是未定義行為允許編譯器可以不檢測和處理某些“錯誤”,減輕了編譯負擔,提升了編譯效率,進一步地,為編譯器優化代碼提供了更大的自由和更多可能性。
  6. 第六,“未定義”并不完全等于“非确定性”,更不等于“随機”。事實上,許多編譯器對許多特定的未定義行為都有自己固定的處理方式(注意,不作任何處理也是一種處理),隻不過,由于标準未做任何規定和限制,是以,對于同一未定義行為,不同的編譯器可能作出不同的處理,或者同一編譯器在不同的系統(環境)下可能作出不同的處理。即,對于同一未定義行為,其結果可能随使用的編譯器、運作的系統(環境)的不同而不同,但如果在同一條件下,使用同一編譯器,其結果很有可能是确定的(如鐵定編譯時報錯或鐵定運作時崩潰)。當然,如果某未定義行為被編譯器置之不理,那麼,即使是在相同的條件下,也可能産生完全不可預測的結果。這,可能是對“未定義行為”最切合實際的描述。

小猿1(同樣一臉嚴肅):

  1. 第一,未定義行為在某些情況下确實會帶來速度的提升,但這也是以在一定程度上犧牲易用性和安全性為代價的,不可否認,未定義行為使得代碼的調試和排錯變得更加困難,也更容易産生安全漏洞。
  2. 第二,我們不得不承認,相比于編譯器檢測未定義行為,程式員通過在源代碼中添加特定的語句(如條件判斷、斷言等)來避免未定義行為往往更加容易,但前提是程式員有足夠的素養能夠意識到哪些代碼可能會出現未定義行為以及出現怎樣的未定義行為。
  3. 第三,在程式員層面防範未定義行為的發生終究是治标不治本,從編譯器層面檢測和處理未定義行為,才是根本解決之道。
  4. 第四,有時候,特别是啟用了優化選項的時候,編譯器的工作可能與我們預想的大相徑庭,對這一點,我們程式員應當時刻保持警醒。
  5. 第五,想要好好愛一個人,了解他/她的缺點比了解他/她的優點更重要。同樣,想用好一種程式設計語言,了解它的缺點比了解它的優點更重要。

(聽完小猿1發言中的最後一條,會議室裡爆發出雷鳴般的掌聲。)

彪哥(待大家安靜下來,朗聲道):好了,我宣布,關于C語言标準制定與實作的三方洽談會第一次會議,圓滿成功!(會議室裡再次響起掌聲)

彪哥(等會議室再度歸于沉寂)同時,我宣布一下我們下一階段的議題,那就是關于未指定行為(unspecified behavior)的讨論,會議時間另行通知。好了,散會!

(全劇終)

注:

1.本文的讨論主要是基于C的,對于某些未定義行為,C++的表現可能與C不同,但本文不詳細讨論這些細節。

2.在Windows下,為.rdata section,在Linux下,為.rodata section,參考這裡。

3.讀者可以自行列印p和q的值,可以看到兩者是相同的。同時,對于較高版本的gcc編譯器(如gcc 5.1.0),如果 char *p = "hello"; 位于.cpp檔案中,是會給出warning的,提示從字元串常量到char*的轉換已經廢棄。

4.事實上,GCC編譯器就是這麼做的。關于wrap around,參考這裡。

5.出自《Rationale for International Standard Programming Languages C》,原文是“the implementor may augment the language by providing a definition of the officially undefined behavior.”,下載下傳連結。

6.彙編代碼通過線上編譯器Compiler Explorer生成。

7.注意,對于更高版本的編譯器,如gcc 11.1,即使指定 -O0 選項,也會進行優化,除非如前文所述,指定 -fwrapv 選項。

8.原文是“Remember, their main goal is to give you fast code that obeys the letter of the law”,參考這裡。

結束語

在下才疏學淺,能力有限,文中難免有錯誤纰漏之處,如果您在閱讀的過程中發現了本文的錯誤和不足,請您務必指出。您的批評指正就是在下前進的不竭動力!