斷點單步跟蹤的交互式調(diào)試器是軟件開發(fā)史上的一項重大發(fā)明。但我認(rèn)為,它和圖形交互界面一樣,都是用犧牲效率來降低學(xué)習(xí)門檻。本質(zhì)上是一種極其低效的調(diào)試方法。
我在年少的時候( 2005 年以前的十多年開發(fā)經(jīng)歷)都極度依賴這類調(diào)試器,從 Turbo C 到 Visual C++ ,各個版本都仔細(xì)用過。任何工具用上十年后熟能生巧是很自然的事。我認(rèn)為自己已經(jīng)可以隨心所欲用這類工具高效的定位出 bug 了。但在 2005 年之后轉(zhuǎn)向跨平臺開發(fā)后,或許是因為一開始沒能找到 Linux 平臺上合適的圖形工具,我有了一些時間反思調(diào)試方法的問題。GDB 固然強大,但當(dāng)時的圖形交互外殼并不像今天的版本這么完善。當(dāng)時比較主流的 insight ddd 都有些小問題,用起來不是十分順手。我開始轉(zhuǎn)換自己平時做開發(fā)的方式。除了盡量提高自己的代碼質(zhì)量:寫簡潔的、明顯沒有問題的代碼之外,多采用不斷的代碼復(fù)核(Code Review),有意識地增加日志輸出,來定位 Bug 。
后來開發(fā)重心從客戶端圖形開發(fā)逐步轉(zhuǎn)向服務(wù)器,更加顯露出用調(diào)試器中斷程序運行的劣勢來。對于 C/S 結(jié)構(gòu)的軟件,中斷一邊的代碼運行,用人的交互頻率單步跟蹤運行,而另一邊是以機器的交互頻率運作,像讓軟件運行流程保持正常是非常困難的。
這些年的工作中又慢慢加入一些 Windows 下的開發(fā)工作。我發(fā)現(xiàn)經(jīng)過了再一個十年的訓(xùn)練,即使偶爾用上交互式調(diào)試器,也體會不到什么優(yōu)勢了。往往手指按在跟蹤調(diào)試按鍵上機械的操作,腦子里想的卻不是眼前看到的屏幕上的代碼。往往都沒執(zhí)行到觸發(fā) Bug 的位置,已經(jīng)恍然大悟發(fā)現(xiàn)寫錯的地方了。這種事情多了,自然會對過去的方法質(zhì)疑,是什么導(dǎo)致了調(diào)試器的低效。
有時和人聊天,談及該怎么定位 Bug 。我總是半開玩笑的說,你就打開編輯器,盯著代碼看啊。盯久了,Bug 自然就高亮出來了。這固然是玩笑,但我的理念中,一切調(diào)試方法都比不上 Code Review 。無論是自己寫的代碼,還是半途介入的別人的代碼。第一要務(wù)就是要先理解程序的總體結(jié)構(gòu)。
程序總是由一段段順序執(zhí)行的小片代碼段輔以分支結(jié)構(gòu)構(gòu)成。順序執(zhí)行的代碼段是很穩(wěn)定的,它的代碼段入口的輸入狀態(tài)決定了輸出結(jié)果。我們關(guān)心的是輸入狀態(tài)是什么,多半可以跳過過程,直接看結(jié)果。因為這樣一段代碼無論多長,都有唯一的執(zhí)行流程。而分支結(jié)構(gòu)的存在會讓執(zhí)行流依據(jù)不同的中間狀態(tài)做不同的數(shù)據(jù)處理??紤]代碼的正確性時,所有的分支點都需要考慮。是什么條件導(dǎo)致代碼會走向這條分支,什么條件導(dǎo)致代碼走向那條分支??梢哉f分支的多少決定了代碼的復(fù)雜度?,F(xiàn)在比較主流的衡量代碼復(fù)雜度的方法 McCabe 代碼復(fù)雜度大致就是這樣。
一個軟件的整體 McCabe 復(fù)雜度一定遠(yuǎn)超人腦可以一次處理的極限。但通常我們可以對軟件進(jìn)行模塊劃分,高內(nèi)聚低耦合的結(jié)構(gòu)能減少軟件復(fù)雜度。一個高內(nèi)聚的模塊,可以和外部隔離,方便我們聚焦到模塊內(nèi)部來分析。當(dāng)焦點代碼的規(guī)模足夠小的時候,包含一切分支結(jié)構(gòu)的所有流程就能一次性的被大腦處理了。對于用調(diào)試器輔助觀察程序的執(zhí)行流程來說,每次用真實的輸入數(shù)據(jù)驅(qū)動的執(zhí)行過程一定是沿唯一的路徑運行的。為了定位 Bug ,我們需要設(shè)計出可以觸發(fā) Bug 的輸入狀態(tài)。對于一個局部模塊來說,這并不總是容易的事。但靠大腦分析一個模塊則不同,在 McCabe 復(fù)雜度不高時,幾乎是可以并行的處理所有的執(zhí)行路徑的。也就是說,你在掃描代碼的同時,大腦其實是在同時分析所有可能的情況,同時還能對不太重要的分支做剪枝。當(dāng)然,和所有技能一樣,分析速度和能分析的寬度(復(fù)雜度)以及剪枝的正確性是需要反復(fù)訓(xùn)練才能拓展的。過于依賴交互式調(diào)試工具會影響這種訓(xùn)練,大腦受工具的影響,會更關(guān)心眼下的狀態(tài):目前運行到哪里了,(為了提高調(diào)試效率)下個斷點設(shè)到哪里去,現(xiàn)在這組變量的值是什么…… 而不太關(guān)心:如果輸入是另外一種情況,程序?qū)⒃趺催\行。因為工具已經(jīng)把這些沒有發(fā)生的過程剪掉了,等著你設(shè)計另一組輸入下次再展示給你。
交互調(diào)試工具通常缺乏回溯能力,也就是它們通常反應(yīng)當(dāng)下的狀態(tài),而不記錄過去的。這有些可以通過改進(jìn)工具來完善,有些則不能。一個常見的場景是,你定下了下一個斷點的位置,當(dāng)調(diào)試器停下來的時候,發(fā)現(xiàn)狀態(tài)異常,只能確定問題出在上次斷點到當(dāng)前的位置之間,但想回溯到底發(fā)生了什么,某個中間狀態(tài)是什么,工具卻無能為力。而靠大腦推演程序的運行過程的話,一切都是靜態(tài)圖譜,回溯和前行并無太大區(qū)別,只是聚焦到時間軸上某個位置而已。這就是為什么受過良好訓(xùn)練的程序員可以一眼看出 Bug 在哪里,而調(diào)試器運用高手卻需要反復(fù)運行兩三次才能找到 Bug 的緣故。
在大腦中正確運行程序當(dāng)然需要足夠的訓(xùn)練,比訓(xùn)練使用調(diào)試器難的多,但卻是值得的。不知道其它同學(xué)有沒有類似經(jīng)歷:我在中學(xué)時代參加信息學(xué)競賽的時候,考卷并不全是編程題,尤其是初賽階段,一般是紙面考卷,有很多題目都是給出程序和輸入,寫出輸出結(jié)果。感謝這段經(jīng)歷,我不得不在初學(xué)編程的時候就進(jìn)行這類訓(xùn)練。初中的時候,每天可以摸到真機的時間是按小時計的,大部分時間還是在傳統(tǒng)的學(xué)業(yè)上。為了編寫自己玩的游戲程序,我只能在上課的時候偷偷的在本子上手寫代碼。寫完了后如果沒有下課,我會在大腦中模擬運行一下,看看有沒有 bug ,能在上機前改過來,就可以更有效的利用每天有限的上機時間。這些經(jīng)歷讓我覺得讀代碼其實沒那么枯燥,是提高效率的一種方法。
用 Code Review 作為主要的定位 Bug 的手段,可以促進(jìn)你寫出復(fù)雜度更?。ǜ蝗菀壮鲥e)的程序。因為知道以你目前的能力大腦能一次處理的復(fù)雜極限在哪。在減少分支方面,我看過 Linus 的一個訪談節(jié)目。他談及代碼品位,舉了一個很小的例子:一段對鏈表的處理程序。鏈表的頭部通常和中間的結(jié)構(gòu)不同,頭部之外的節(jié)點都有一個 next 指針引用下一個節(jié)點,而頭節(jié)點是個例外,是由不同的數(shù)據(jù)結(jié)構(gòu)引用的。再 Linus 列出的反面例子中,代碼判斷了頭指針是否為空;而在正面例子中,next 指針是用一個指針引用變量實現(xiàn)的,對于頭節(jié)點,它引用在不同的數(shù)據(jù)結(jié)構(gòu)變量上,這樣就回避了多一次的例外(對于頭節(jié)點)判斷。代碼可以一致的處理。在那個只有 5,6 行代碼的小片段中,似乎判斷語義非常清晰,多一次判斷微不足道,但 Linus 強調(diào)這是品位選擇的問題。我認(rèn)為,這其實就是將減少代碼復(fù)雜度提升到書寫代碼的本能中。
對于中途介入的他人的項目,你無法控制代碼的質(zhì)量。但長期的 Code Review 訓(xùn)練可以幫助你快速切分軟件的模塊。通常,你需要運用你對相關(guān)領(lǐng)域的知識,和同類軟件通常的設(shè)計模式,預(yù)設(shè)軟件可能的模塊劃分方式。這個過程需要對領(lǐng)域的理解,不應(yīng)過度陷入代碼實現(xiàn)細(xì)節(jié)。一上手就開調(diào)試器先跑跑軟件的大致運行流程是我不太推薦的方法。這樣視野太狹窄了,花了不少時間只觀察到了局部。其實不必執(zhí)著于從頂向下還是從下置上??梢韵却笾驴纯丛创a的文件結(jié)構(gòu)做個模塊劃分猜測,然后隨便挑選一個模塊,找到關(guān)聯(lián)的部分再順藤摸瓜。對于需要構(gòu)建的項目,摸清程序脈絡(luò)的時間甚至可以在第一次等待編譯構(gòu)建的時間同步完成,而不需要等待構(gòu)建完畢在一步步跟蹤運行,甚至不需要下載代碼到本地,github 這種友好的 web 界面已經(jīng)可以舒適的在瀏覽器里閱讀了,有個 ipad 就可以舒服的躺在床上進(jìn)行。
我不太喜歡 C++ 的一個原因是:C++ 代碼從一個局部去閱讀,很難有唯一的解釋。它的代碼字面意思很可能對應(yīng)有多種實際操作含義,確定性不足。函數(shù)名重載、操作符重載都是隱藏在局部代碼之外的。甚至你看到一個變量名,不去同時翻閱上下文及頭文件的話,都很難確定這是一個局部變量還是一個類成員變量(前者的影響范圍和后者大為不同,大腦在做分析的時候剪枝的策略完全不同);看到一個變量,原本以為是一個輸入值,直到看到最后,發(fā)現(xiàn)它還可以做輸出,回頭一看函數(shù)聲明,其實它是一個引用量。如果用到模板泛型就更可怕,連數(shù)據(jù)類型都不確定。只從局部代碼無法得知模板實例化之后那些關(guān)聯(lián)的操作到底做了些什么。閱讀 C++ 項目往往需要在代碼間相互參考,增加了大腦太多的負(fù)擔(dān)。
那么,光靠大腦 Code Review 是不是就夠了呢?如果自身能力無限提高,我認(rèn)為有可能。通過積累經(jīng)驗,我這些年能直接分度閱讀的代碼復(fù)雜程度明顯超過往年。但總有人力所不及的時候。這時候最好的方法是加入日志輸出作為輔助手段。
試想我們在用交互調(diào)試工具時,其實是想知道些什么?無非是程序的運行路徑,是不是真的走到了這里,以及程序運行到這里的時候,變量的狀態(tài)是怎樣的,有沒有異常情況。日志輸出其實在做同樣的工作。關(guān)鍵路徑上輸出一行日志,可以表達(dá)程序的運行路徑。把重要的變量輸出在日志里,可以查詢當(dāng)時的程序運行狀態(tài)。怎樣有效的輸出日志自然也是需要訓(xùn)練的技能。不要過于擔(dān)心日志輸出對性能的影響,最終軟件有 20% 上下的性能波動對于軟件的可維護性來說是微不足道的。
和外掛的調(diào)試工具相比,日志具備良好的回溯查詢能力。作為 Code Review 的一個輔助,我們大腦其實需要的只是對判斷的一個修正:確認(rèn)程序是否是沿著腦中模擬的路線在行進(jìn),內(nèi)部狀態(tài)是否一致正常。和調(diào)試工具不同,日志不會打斷運行過程,對多個程序并行運行的軟件,例如 C/S 結(jié)構(gòu)的系統(tǒng)就更為重要了。
其實保留狀態(tài)信息在交互調(diào)試工具中也是非常重要的技巧。我相信很多人和我一樣,在調(diào)試程序時有時會增加一些臨時的全局變量,把一些中間狀態(tài)寫到這些變量中。在交互調(diào)試過程中偶爾需要去查看這些狀態(tài)值。這種臨時狀態(tài)暫存變量,其實也充當(dāng)了日志的功能。
文本日志的好處是可以利用文本處理工具做信息二次提取。grep awk vim python lua 都是分析日志的好手段。如果日志巨大,且存在在遠(yuǎn)程機器上,你很可能找不到更有效快捷的手段。很多時候,不斷的重新運行有 bug 的程序的代價,是遠(yuǎn)超一次運行得到詳細(xì)日志后再對日志做分析的。
那么,學(xué)會使用交互調(diào)試工具重要嗎?我認(rèn)為依然重要。偶爾用之,也能起到奇效。尤其是程序崩潰的時候,attach 到進(jìn)程中觀察崩潰時的狀態(tài)。操作系統(tǒng)大多也能 dump 出崩潰時的進(jìn)程狀態(tài)供事后分析。這些都需要你會用調(diào)試工具。但通過靜態(tài)狀態(tài)的草灰蛇線反推出崩潰前到底發(fā)生了些什么,卻也更需要對代碼本身有足夠的理解。因為用的時機不多,我認(rèn)為命令行的 gdb 就足夠用了。在分析損壞的棧幀、編寫腳本分析一些復(fù)雜數(shù)據(jù)結(jié)構(gòu)方面,命令行版本更具靈活性,應(yīng)用范圍也較廣。而交互上的不便,增加的學(xué)習(xí)成本,都是可以接受的。