程式碼調試的最佳指南

  • 2019 年 10 月 8 日
  • 筆記

相信很多開發者對於程式碼調試最難的地方是什麼依然雲里霧裡,而且這不僅僅是初學者需要面臨的問題——本文中就來探討下何為程式碼調試的最佳指南。

作者 | Julia Evans

譯者 | 蘇本如,責編 | 郭芮

出品 | CSDN(ID:CSDNnews)

以下為譯文:

昨天我和一些朋友一起調試程式碼,他們做程式設計師這一行都不太久,我向他們展示了一些程式碼調試技巧。

今天早上我在想,我應該如何教授他們學習程式碼調試?我在Twitter上發了一條推文說,我從來沒有見過任何好的調試程式碼的指南。像往常一樣,我得到了很多有幫助的回答,現在我對如何教授程式碼調試技巧/描述調試過程有了些想法。

調試資源

我希望有更多的關於程式碼調試的書籍/指南,在這裡我有兩個推薦:

David Agans 寫的《Debugging》:有幾個人向我推薦了這本《Debugging》,它看起來是一本很好的關於程式碼調試的書,用簡短的篇幅闡述了一些程式碼調試策略。這本書我還沒有讀過,但是我已經買了一本,我希望我讀完後決定是否應該推薦它。這本書中闡述的一些程式碼調試應該遵循的規則似乎很有道理,比如說「了解系統」,「讓它失敗」,「別想了,先看看」,「分而治之」,「一次只改變一件事情」,「保持審查詳細記錄」,「從一個新的角度看問題」,和「如果你沒有修復它,它就不會修復」等等。另外,這本書還有一張吸引人的的程式碼調試的海報。

John Regehr寫的「How to debug(如何調試)」:How to Debug是John Regehr基於他自己在大學裡教授嵌入式系統課程的經驗寫的一篇非常好的部落格文章(https://blog.regehr.org/archives/199),裡面有很多針對程式碼調試的好建議。他還發表了一篇博文(https://blog.regehr.org/archives/849)來評論4本關於程式碼調試的書籍,包括了David Agans s寫的這本《Debugging》。

重現你的bug(但是要怎麼做?)

接下來在這篇文章里,我將嘗試整理大家針對我的關於程式碼調試的推文發來的各種不同的觀點和看法。

從這些看法中很明顯地看出,所有人都同意這一點:如果你想弄清楚發生了什麼,那麼能夠持續地重現一個bug非常重要。我對如何做到這一點有直覺,但是對於怎樣才能從「我看到這個bug兩次」跨越到「我可以根據需要在筆記型電腦電腦上持續地再現這個bug」這一點,我不知道怎麼解釋,而且我想知道你用來調試的技術是否依賴於這些不同的開發領域:後端web開發,前端開發,移動開發,遊戲開發,C++編程,嵌入式開發等等。

快速重現bug

所有人也都同意,能夠快速地重現bug是非常有用的(如果每次更改都需要3分鐘來檢查是否有幫助,那麼迭代就太慢了)。

這裡有一些建議的方法:

  • 對於那些需要在瀏覽器中進行很多次點擊才能重現的bug,用Selenium記錄你點擊的內容,並讓Selenium重播UI交互(詳細的建議請見這裡:https://twitter.com/AnnieTheObscure/status/1142843984642899968);
  • 如果你能夠的話,編寫一個重現錯誤的單元測試。這樣做還有另外一個好處:如果這個單元測試有意義的話,你可以稍後將它添加到測試套件中;
  • 編寫一個腳本,或者找到一個命令行命令幫助你做它(比如curl MY_APP.local/whatever))。

承認bug可能是你寫的程式碼引起

有時我看到一個問題,我會說「哦,X庫有個bug」,或者「哦,這是DNS錯誤造成的」,或者「哦,不是我的程式碼,而是其它地方的錯誤造成的」。確實有時候一個bug不是我寫的程式碼造成的!但一般來說,在一個已經驗證的庫和我上個月編寫的程式碼之間,通常是我上個月編寫的程式碼才是真正的問題所在 。

開始實驗

@act_gardnerd在Twitter上給出了一個很好的簡短的回答(https://twitter.com/act_gardner/status/1142838587437830144),解釋了你在再現你的bug之後,你需要做什麼。原文如下:

我試著鼓勵人們首先對這個bug有個全面的理解,比如說:什麼正在發生?你期望會發生什麼?什麼時候會發生?什麼時候不發生?然後運用他們對系統的心理模型來猜測可能發生的破壞,並進行實驗。 實驗可以是更改或刪除程式碼,從一個REPL調用API,嘗試新的輸入,使用調試器(debugger)或print語句來獲取記憶體中的值。

我認為這裡可能需要循環地重複以下步驟:

  • 猜測可能發生的錯誤的某一個方面(比如說,「這個變數被設置為X,它應該是Y」,或「發送到伺服器的請求是錯誤的」,或「這段程式碼根本沒有運行過」等等)。
  • 做實驗來驗證這個猜測。
  • 重複循環,直到你明白髮生了根源所在。

一次只改變一件事情——所有人都肯定地同意,在做實驗來驗證一個假設時,一次只改變一件事情是很重要的。

檢查你的假設

很多調試工作都基於一個假設:你確定的事情是真的(比如說:「等一下,這個請求是要發送到新伺服器,對吧,不是舊伺服器????)。但是實際上……不是真的。我試圖列出一些常見的錯誤假設。下面是一些例子:

  • 此變數設置為X(「該文件名絕對正確」);
  • 該變數的值不可能在X和Y之間變化;
  • 這段程式碼以前沒有問題;
  • 此函數執行X;
  • 我正在編輯正確的文件;
  • 我寫的那一行程式碼不可能有任何拼寫錯誤,只是一行程式碼而已;
  • 文檔是正確的;
  • 我正在查看的程式碼在某個時刻被執行;
  • 這兩段程式碼是按順序執行的,而不是並行執行的;
  • 這段程式碼在調試模式和發布模式下編譯(使用或不使用-O2開關,或…)時,會做同樣的事情;
  • 編譯器沒有錯誤(這是故意放在最後的一個錯誤,很少有人會認為編譯器會出錯)。

獲取資訊的奇招

有很多正常的方法可以做實驗來檢查你對程式碼所做的假設/猜測(比如,列印變數值,使用調試器,等等)。但是,有時候你所處的環境更為困難,你無法列印出內容,也無法訪問調試器(可能是執行這些操作不方便,因為要處理的事件太多)。這裡有一些應對方法:

  • 在手機上添加聲音:「在移動開發世界裡,這條建議給了我很大幫助。Xcode可以在你遇到斷點時播放聲音(並且程式碼不停止而繼續執行下去)。我把它們放在程式碼中的某個位置,然後聽嗡嗡的叮噹聲來指示程式碼中發生的錯誤」(欲知詳情,請查看上面提到的推文)。
  • 關於使用Xcode播放iOS程式碼調試的聲音,這裡(https://qnoid.com/2013/06/08/Sound-Debugging.html)有一些很有趣的討論。
  • 添加發光二極體(LED):「很久以前,當我們在Transputer網格上做嵌入式開發時,我們將發光二極體連接到每個晶片的一個未使用的管腳上。它在診斷並行性問題上出奇地有效。」
  • string: 「我的網路教授告訴我這樣一個故事,在早期的乙太網時代,他在施樂公司(Xerox)看到了一個黑客:他使用一個帶有放大器,馬達和一根繩子的同軸電纜接頭。網路越忙,線就轉得越快。」
  • Peep是一個「Network Auralizer」,可以將系統上發生的事情轉換成聲音。我花了10分鐘試圖讓它編譯,但迄今為止失敗了,但它看起來很有趣,我想繼續嘗試它!!

這裡我想重點強調一下:資訊是最重要的,你需要做任何必要的事情來獲取資訊。

編寫程式碼使其更易於調試

一些人提到的另外一個觀點是:我們可以改進程式,使其更加易於調試。tef對此有一篇很好的文章:編寫易於刪除和調試的程式碼(https://programmingisterrible.com/post/173883533613/code-to-debug)。我覺得下面這一點很正確:

可調試的程式碼並不一定乾淨,而充斥著檢查或錯誤處理的程式碼很少能讓人愉快地閱讀。

我個人認為:「易於調試」的一種解釋是「每當出現錯誤時,程式都會以易於理解的方式向你準確地報告發生的事情」。每當我的程式有問題並且報告這樣的錯誤資訊「Error:無法連接到某個IP的埠443:連接超時」時,我都想說:「謝謝,這就是我想知道的事情」。有了這樣的錯誤資訊,我就可以檢查我是否需要修復防火牆,或者我是否由於某種原因得到了錯誤的IP地址。

最近我碰到一個簡單的例子:我向一個我寫的伺服器發出請求,得到的回應是「upstream connect error or disconnect/reset before headers」。這是一個nginx錯誤,在本例中基本上是因為「程式在響應一個請求而發送任何內容之前崩潰了」。找出崩潰的原因是很容易的,但是有更好的錯誤處理方式(返回錯誤而不是崩潰)可以節省我一點時間,因為我不必去檢查崩潰的原因,我只需閱讀錯誤資訊,知道發生了什麼就可以了。

錯誤消息好過無提示的程式失敗

為了更接近「每次出現錯誤時,程式都會以一種易於理解的方式向你報告發生的事情」的夢想,你還需要遵守這條「立即返回錯誤消息」的鐵律,而不是默默地向另一個功能寫入不正確的數據或者傳遞無意義的數據,誰都不知道它會拿這些數據做什麼,結果只會讓你頭痛。要做到這點,意味著你要添加如下程式碼:

if UNEXPECTED_THING:      raise "oh no THING happened"

獲得正確的錯誤資訊並不容易,因為你在程式當中哪裡犯了錯誤並不總是顯而易見的,但是這樣做確實有很大幫助。

failure:返回一堆錯誤,而不僅僅是一個錯誤

為了返回更加易於調試的有用錯誤,Rust提供了一個非常令人難以置信的錯誤處理庫failure,它基本於允許你返回一系列錯誤,而不僅僅是一個錯誤,因此你可以列印出一堆錯誤,如:

"error starting server process" caused by  "error initializing logging backend" caused by  "connection failure: timeout connecting to 1.2.3.4 port 1234".

這比僅僅返回connection failure: timeout connecting to 1.2.3.4 port 1234本身要有用得多,因為它還告訴你和IP 1.2.3.4有關的其它一些重要的資訊(比如上面這個錯誤就顯示它和日誌後端有關!)。我認為它也比返回帶有堆棧跟蹤資訊的connection failure: timeout connecting to 1.2.3.4 port 1234的錯誤資訊更加有用:因為它將堆棧跟蹤資訊中的關鍵的出錯部分總結出來,這樣你就不需要讀取堆棧跟蹤中的每一行(因為其中一些可能不相關!).

其它語言中的類似於Rust語言failure庫的工具有:

  • Go語言:它的習慣用法似乎是把你的一堆錯誤串成一個大字元串,這樣你就得到了一長串的像這樣的錯誤提示:「error:第一個錯誤:error:第二個錯誤:error:第二個錯誤」。它工作得很好,但是它的錯誤資訊的結構比failure庫能提供的要差得多。
  • Java語言:我聽說Java可以給出異常的原因(Causes of exceptions), 但是我自己沒有用過。
  • Python 3:你可以使用raise … from設置異常的「__cause__」屬性,然後你的異常將被這句話分開:The above exception was the direct cause of the following exception:..

如果你知道其它語言中如何處理程式錯誤的方法,請告訴我,我會很感興趣!

了解錯誤消息的含義

我經常理所當然地認為程式碼調試的一個子技巧是:正確理解錯誤消息的含義!我在這裡(https://pythonforbiologists.com/29-common-beginner-errors-on-one-page/)看到了這個很好的圖形,它解釋了常見的Python錯誤以及它們的含義,並且將一些錯誤如 NameError, IOError,等等分離開來。

我認為解釋錯誤消息很困難的一個原因是理解一個新的錯誤消息可能意味著學習一個新的概念。比如,NameError可能代表「你的程式碼使用了一個它定義的變數作用域之外的一個變數」,但是要真正理解它的意思,你首先得搞清楚什麼是變數作用域。我在學習Rust的時候經常碰到這樣的問題,Rust編譯器會提示我「你有一個奇怪的lifetime錯誤」,而我就會想「呃,好吧,Rust,我知道了,現在我就去搞清楚lifetime是如何工作的!」

很多時候,錯誤消息都往往是由一個與消息文本根本不相干的錯誤引起的,比如說「upstream connect error or disconnect/reset before headers」這個錯誤可能意味著「Julia,你的伺服器崩潰了!」當你切換到一個新的開發領域時,理解錯誤消息的技能通常是不可轉移的(假如我明天開始大量地編寫React或其它程式語言的程式碼,一開始我可能根本不知道任何錯誤消息的含義!)。所以這個問題絕對不僅僅是初學者需要面臨的問題。

結束語

當我在談到程式碼調試技巧時,我總感覺我遺漏了一件重要的事情,那就是對人們在程式碼調試中哪裡會遇到困難的一種更深入的理解。通常我們很容易說:「好吧,你需要重現這個問題。那麼先讓我們進行最小化的重現,你可以開始猜測和驗證你的猜測,改進你對系統的思維模式,找出問題所在,然後解決問題。最後寫一個測試,希望它不再重現」,但是,實際上,我們很難確定人們到底會在哪裡遇到困難和最難的部分是什麼。對我自己而言程式碼調試最難的地方是什麼,我通常會有點思路。但是對那些新人而言,程式碼調試最難的地方是什麼,我依然是雲里霧裡,毫無頭緒。

原文:

https://jvns.ca/blog/2019/06/23/a-few-debugging-resources/

本文為 CSDN 翻譯,轉載請註明來源出處。

【END】

騰訊項目經理:如何快速上手新項目?

來了!微信車載版首次公開演示

那些熟悉卻說不出的設計法則