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