典陷阱解析)
很多人在學(xué)C語言的時候一看到請將以下程序段輸入編輯器記錄兩個程序段的輸出結(jié)果并分析每個程序段結(jié)果這種題第一反應(yīng)就是趕緊把代碼復(fù)制進(jìn)編譯器跑一遍然后把屏幕上的輸出抄下來。這在我看來等于把一道好題做廢了。程序段分析題真正練的不是手速而是你在沒有運行環(huán)境的情況下能不能靠大腦把代碼的執(zhí)行過程一步步推理出來。我今天就拿一道很有代表性的練習(xí)題來演示這個過程編號就叫7-3。這道題包含兩個程序段一個涉及遞歸調(diào)用一個涉及指針自增運算都是C語言考試和面試?yán)锓磸?fù)出現(xiàn)的經(jīng)典考點。1. 題面原型兩道看似簡單卻藏了四個考點的程序段7-3這類題在不少教材和練習(xí)平臺里很常見題干一般不復(fù)雜就一句話將以下程序段輸入編輯器記錄輸出結(jié)果并分析每個程序段結(jié)果。但真正動手寫的人會意識到這兩段代碼幾乎把C語言的函數(shù)調(diào)用、遞歸、運算符優(yōu)先級、指針運算這幾個核心知識點全串起來了。1.1 程序段一遞歸求階乘和第一段代碼長這樣#include stdio.h int fact(int n) { if (n 1) { return 1; } return n * fact(n - 1); } int main(void) { int i; int sum 0; for (i 1; i 5; i) { sum fact(i); } printf(sum %d\n, sum); return 0; }乍一看這段代碼很短結(jié)構(gòu)也很清晰先定義了一個fact函數(shù)主函數(shù)里用一個for循環(huán)從1到5把fact(1)、fact(2)、fact(3)、fact(4)、fact(5)的值累加進(jìn)sum最后打印出來。難點在于fact這個函數(shù)本身是遞歸的你得搞清楚它在每一輪循環(huán)里到底返回了什么。1.2 程序段二指針自增運算的連環(huán)陷阱第二段代碼是這個#include stdio.h int main(void) { int a[5] {1, 3, 5, 7, 9}; int *p a; printf(%d\n, *p); printf(%d\n, *(p 1)); printf(%d\n, *p); printf(%d\n, *p--); return 0; }這段代碼沒有函數(shù)調(diào)用看起來更簡單實際上卻更容易翻車。四條printf語句長得幾乎一樣無非是星號、括號、加號和減號的排列組合但每一條對指針p的作用都不一樣。尤其是*p和*p這兩個寫法在C語言里是出了名的坑。1.3 記錄輸出的正確姿勢先手算別急著運行我的建議是拿到這樣的題目第一件事不是開編譯器而是找張紙把兩個程序段的輸出結(jié)果先手算一遍。算完再運行對照。這樣做的好處是運行結(jié)果只能告訴你輸出是什么但手算過程能逼你搞清楚為什么是這個結(jié)果。如果一開始就依賴編譯器你可能永遠(yuǎn)分不清*p和*p的區(qū)別下次遇到還是錯。為了方便對照我把最終答案先放在這里。程序段一輸出的結(jié)果是sum 153程序段二輸出的結(jié)果是四行數(shù)字分別是1、5、5、5。程序段輸出結(jié)果涉及考點程序段一sum 153遞歸調(diào)用、函數(shù)返回值、循環(huán)累加程序段二1 \n 5 \n 5 \n 5運算符優(yōu)先級、指針自增自減、數(shù)組訪問如果你第一次手算就能完全算對這個答案說明你對函數(shù)遞歸和指針運算的基礎(chǔ)已經(jīng)相當(dāng)扎實了。如果哪里對不上下面兩章就是為對不上的人準(zhǔn)備的逐行拆解。2. 程序段一的執(zhí)行推演遞歸不再靠背而是靠畫棧遞歸是C語言函數(shù)部分的核心也是很多人從入門到放棄的地方。程序段一里的fact函數(shù)是個非常典型的遞歸函數(shù)它求的是n的階乘也就是n!。數(shù)學(xué)上的定義是n! n × (n-1) × ... × 1并且0! 1。代碼里的實現(xiàn)思路是當(dāng)n小于等于1時直接返回1否則返回n乘以fact(n-1)。2.1 fact(5)的遞歸展開從調(diào)用到返回的完整順序先單獨看fact(5)是怎么執(zhí)行的。遞歸最難的地方在于你得從調(diào)用順序和返回順序兩個維度同時理解它。調(diào)用的時候是一層一層往下鉆返回的時候是一層一層往上爬。我把fact(5)的完整過程展開如下fact(5) 被調(diào)用 - 5 1因此執(zhí)行 5 * fact(4) - fact(4) 被調(diào)用 - 4 1因此執(zhí)行 4 * fact(3) - fact(3) 被調(diào)用 - 3 1因此執(zhí)行 3 * fact(2) - fact(2) 被調(diào)用 - 2 1因此執(zhí)行 2 * fact(1) - fact(1) 被調(diào)用 - 1 1return 1 - fact(1) 返回 1所以 2 * 1 2return 2 - fact(2) 返回 2所以 3 * 2 6return 6 - fact(3) 返回 6所以 4 * 6 24return 24 - fact(4) 返回 24所以 5 * 24 120return 120 fact(5) 最終返回值是 120這個過程用生活中的場景類比就像玩疊積木你先把一塊積木放上去再放一塊層層往上堆堆到第五層的時候發(fā)現(xiàn)最頂上那一塊就是終止條件然后你從最上面開始一塊一塊抽下來每抽一塊都要乘上一個數(shù)最后回到地面得到最終結(jié)果。2.2 sum的變化軌跡每一輪循環(huán)后到底加了什么單獨搞懂fact(5)還不夠因為主函數(shù)里的for循環(huán)是把fact(1)到fact(5)全部累加起來。很多人的誤區(qū)就在這里他們算出了fact(5)等于120就以為答案是120完全忽略了sum是五個值相加的結(jié)果。我把每一輪循環(huán)的執(zhí)行情況列成一張表i的值fact(i)的返回值累加后sum的值111223369424335120153從這張表能很清楚地看到當(dāng)i等于1的時候fact(1)直接命中終止條件返回1i等于2的時候fact(2)要先調(diào)用fact(1)拿到1之后乘以2返回2i等于3的時候fact(3)要先經(jīng)過fact(2)再到fact(1)一層層返回后得到6。到i等于5的時候前面已經(jīng)積累了33再加上120最終結(jié)果是153。2.3 為什么sum不等于120也不等于15我讓不少同學(xué)手算過這道題最常見的兩個錯誤答案分別是120和15。說120的人是只算了fact(5)丟掉for循環(huán)的累加過程說15的人是誤以為fact(i)返回的是i本身也就是12345完全把fact函數(shù)當(dāng)成恒等函數(shù)了。這兩個錯誤本質(zhì)上是同一個問題沒有搞清楚函數(shù)調(diào)用的返回值到底是什么。fact(i)不是返回i而是返回i的階乘。只有當(dāng)i1或i0時fact(i)才恰好等于1i2時它等于2i3時它等于6i4時等于24i5時等于120。所以sum是這五個數(shù)的和這個和是153跟單算5!的120差了一個33也就是fact(1)到fact(4)之和。這里有個細(xì)節(jié)值得多說一句fact函數(shù)在n1時返回1這個寫法其實是對數(shù)學(xué)定義做了簡化。嚴(yán)謹(jǐn)?shù)碾A乘定義中0! 1但這段代碼在n1時也返回1所以如果你把循環(huán)改成從0開始fact(0)依然返回1不會出錯。這種寫法在題目里常見不算Bug但你要能看出它的邊界行為。3. 程序段二的反直覺輸出*p 和 *p 到底誰先算程序段二比程序段一更容易讓人懷疑人生因為它考的是C語言里最讓人頭疼的運算符優(yōu)先級和指針自增自減的組合。四條printf語句每條看起來都差不多實際運行結(jié)果卻是四行獨立的數(shù)字而且里面有連續(xù)兩個5。如果不仔細(xì)推理很容易算出一堆亂七八糟的結(jié)果。3.1 優(yōu)先級與結(jié)合性的逐條拆解先明確第一條語句*p。在C語言里后綴自增運算符的優(yōu)先級比解引用運算符*高而且后綴自增的結(jié)合方向是從左到右。所以*p不是(*p)而是*(p)。關(guān)鍵點在于后綴自增表達(dá)式的值是自增之前的值。也就是說p這個表達(dá)式的值是p原本指向的地址副作用是讓p指向下一個元素。因此*p做的事情是先取出p當(dāng)前指向的元素再把p往后挪一格。第二條語句*(p 1)相對溫和。括號里的p 1計算的是p指向位置的下一個地址但這次加減操作不會修改p本身。它只是臨時算出一個新地址來讀取數(shù)據(jù)。第三條語句*p的規(guī)則跟前綴自增有關(guān)。前綴自增p的優(yōu)先級比*略低還是高準(zhǔn)確地說前綴自增和*的優(yōu)先級相同結(jié)合方向從右到左所以*p先執(zhí)行p也就是讓p先自增再對p解引用。跟后綴版本最大的區(qū)別是前綴自增表達(dá)式的值是自增之后的新地址。第四條語句*p--的處理方式跟第一條類似。后綴自減優(yōu)先級高整個表達(dá)式等價于*(p--)先取出p當(dāng)前指向的元素然后p再減一。3.2 p指針在第4條printf后的精確指向光講規(guī)則不夠我把指針p的變化過程完整跟蹤一遍。數(shù)組a在內(nèi)存中的布局是連續(xù)的五個int初始時p指向a[0]也就是元素1。語句表達(dá)式值執(zhí)行后p指向的位置對應(yīng)元素初始狀態(tài)—a[0]1printf(%d\n, *p);1a[1]3printf(%d\n, *(p 1));5a[1]3printf(%d\n, *p);5a[2]5printf(%d\n, *p--);5a[1]3逐條解釋一下這張表。初始狀態(tài)下p指向a[0]所以第一條語句輸出a[0]的值1緊接著p自增指向a[1]。第二條語句執(zhí)行時p指向a[1]所以p 1指向a[2]解引用后輸出a[2]的值5。注意這條語句從頭到尾沒有修改pp依然指向a[1]。第三條語句是*pp先自增從a[1]變成a[2]再解引用a[2]輸出5。第四條語句是*p--先解引用p當(dāng)前指向的a[2]輸出5然后p自減回到a[1]。這里的自減屬于副作用后續(xù)代碼如果還用到p會拿到a[1]的地址但在這段程序里已經(jīng)沒有任何用處了。所以最終屏幕上的輸出是1 5 5 53.3 常見的誤判結(jié)果和最容易被忽略的陷阱我在現(xiàn)實中見到最多的錯誤版本是把這四條語句的輸出理解成1, 3, 5, 5也就是認(rèn)為第二條語句*(p 1)輸出了a[1]的值3。這個誤判的核心在于他們沒有意識到*(p 1)中的p在第一條語句執(zhí)行后已經(jīng)指向a[1]了所以p加1指向的是a[2]而不是a[1]。還有一種錯誤是認(rèn)為第四行輸出3理由是p--讓p先減再取值這是把后綴自減跟前綴自減搞混了。后綴版本永遠(yuǎn)先取當(dāng)前值再移動指針。另一個容易被忽略的陷阱是如果你把第二條語句的括號去掉寫成*p 1那意思就完全變了它變成了取出p指向的值再加1輸出結(jié)果會變成6因為a[1]是5等等不對第二條執(zhí)行時p指向a[1]a[1]的值是33加1等于4。你看一個括號的差異就能改變整個輸出。C語言里像這樣差之毫厘謬以千里的地方太多了這就是為什么程序段分析題要求你不僅看結(jié)果還要分析原因。4. 動手改造實驗改一個條件輸出就可能天翻地覆程序段分析題還有一個很有價值的玩法就是改代碼。把條件改一改看看輸出會發(fā)生什么變化再想想變化的原因。這個過程中形成的直覺比單純做一百道題都管用。我用幾個有代表性的改造來說明。4.1 把遞歸終止條件從 n 1 改成 n 0第一個改動很隱蔽把程序段一里fact函數(shù)的if (n 1)改成if (n 0)其他都不動。此時fact(1)不再直接返回1而是執(zhí)行1 * fact(0)。由于fact(0)滿足n0返回1所以fact(1)的結(jié)果仍然是1。整個for循環(huán)從1到5sum依然等于153輸出不變。但如果終止條件改成if (n 1)也就是只對n等于1做處理那問題就來了。當(dāng)循環(huán)從i1開始時沒問題fact(1)直接返回1后面的fact(2)、fact(3)等都能一層層遞歸到fact(1)結(jié)束??梢坏┠隳程彀蜒h(huán)改成從i0開始fact(0)就會調(diào)用fact(-1)fact(-1)調(diào)用fact(-2)無限遞歸直到棧溢出崩潰。這就是邊界條件的威力。4.2 把循環(huán)改成從 i 0 開始累加接著說循環(huán)起點。如果把程序段一的for循環(huán)改成for (i 0; i 5; i)在原來的n 1條件下fact(0)返回1所以sum會從1開始累加最終結(jié)果是154。只是起點從1變成0答案就從153變成了154。這說明做題時不能只盯著函數(shù)體循環(huán)的邊界、初值、終值都必須一起看。4.3 指針程序段換成等價寫法用下標(biāo)方式重塑邏輯程序段二也可以改造。把四條printf改成完全等價的數(shù)組下標(biāo)寫法幫助理解背后到底發(fā)生了什么printf(%d\n, a[0]); p p 1; printf(%d\n, a[2]); p p 1; printf(%d\n, a[2]); p p - 1;這段改寫跟原代碼的輸出是一致的第一條輸出a[0]第二條輸出a[2]第三條輸出a[2]第四條原樣也是a[2]。通過這種指針寫法到下標(biāo)寫法的轉(zhuǎn)換你會發(fā)現(xiàn)指針自增運算的本質(zhì)就是讓指針在數(shù)組里游走配合下標(biāo)訪問能做到同樣效果。不同之處在于指針寫法更貼近底層內(nèi)存地址的概念也更考驗?zāi)銓\算符的理解。這個改造實驗很有實際價值。在真實項目里指針運算經(jīng)常出現(xiàn)在字符串處理、數(shù)組遍歷、緩沖區(qū)操作等場景中如果你能一眼看出*p到底干了什么調(diào)試代碼的速度會快很多。5. 這種題的通用解法把看答案變成驗答案最后我想總結(jié)一套我用了很多年的手算方法。不管是7-3這種練習(xí)題還是考試?yán)锏某绦蜷喿x題都可以用這套方法來應(yīng)對。核心思路就一句話在代碼運行之前先強(qiáng)迫自己當(dāng)一回人肉編譯器。5.1 手算五步法第一步把代碼抄到紙上一行一行看不要跳。第二步遇到復(fù)雜表達(dá)式先標(biāo)出運算符優(yōu)先級把高優(yōu)先級的運算先括起來。比如*p就寫成*(p)*p就寫成*(p)。第三步給關(guān)鍵變量畫狀態(tài)表像上面那張指針變化表一樣。第四步口算出每一步的結(jié)果并把每一次函數(shù)調(diào)用和返回都記錄下來。第五步運行程序?qū)φ战Y(jié)果。如果手算和運行不一致說明你對某個知識點的理解有偏差這時候才是真正進(jìn)步的機(jī)會。這套方法看著繁瑣但熟練之后像程序段二那樣的題三十秒內(nèi)就能手算出結(jié)果。因為指針自增自減就那么幾個規(guī)律見多了自然就記住。5.2 驗證時別用dev工具偷懶要用 -Wall 編譯實際操作中我建議用GCC驗證。如果你的環(huán)境是VS Code配的C語言開發(fā)環(huán)境在終端里執(zhí)行這樣一條命令就能編譯并運行g(shù)cc -stdc11 -Wall -Wextra 7-3.c -o 7-3 ./7-3-Wall -Wextra的作用是顯示所有警告。對于程序段二這種代碼雖然邏輯正確但有些編譯器在開啟警告時可能會提示未使用變量之類的信息。學(xué)會看這些警告對培養(yǎng)代碼敏感度很有幫助。很多人初學(xué)的時候覺得警告無所謂只要能跑就行這個習(xí)慣其實很危險。5.3 函數(shù)與指針專題的強(qiáng)化路徑如果你正在學(xué)C語言函數(shù)章節(jié)我覺得系統(tǒng)強(qiáng)化路徑可以是這樣先把課本上的遞歸練習(xí)題做一遍不限于階乘和斐波那契還要做漢諾塔、字符串逆序這類經(jīng)典題。然后做指針專題把自增自減、優(yōu)先級、數(shù)組名和指針的關(guān)系全部理清楚。最后做綜合題也就是像7-3這樣把函數(shù)、循環(huán)、指針混在一起考的題。評價標(biāo)準(zhǔn)只有一個你能不能在不運行代碼的情況下百分之百確定輸出結(jié)果。如果能那這部分內(nèi)容基本就過關(guān)了。我個人在做這類題目的時候最大的體會是把每一行代碼的執(zhí)行過程拆開來看尤其是搞清楚誰變了、誰沒變比背十個規(guī)律都管用。遞歸里的參數(shù)、指針里的地址、循環(huán)里的累加變量本質(zhì)上都是這個道理。你不需要一次記住一百個結(jié)論只需要掌握一種嚴(yán)謹(jǐn)?shù)耐评砹?xí)慣剩下的題目都能用這套習(xí)慣慢慢推出來。