1. 程式人生 > >程式設計真可怕,我們日常都在寫 Bug

程式設計真可怕,我們日常都在寫 Bug

640?wx_fmt=gif

作為開發者,我們一直走在寫 Bug 的路上,而什麼樣的程式碼才是最好的?又該如何掌握除錯的正確姿勢呢?

640?wx_fmt=jpeg

640?wx_fmt=png

編寫易於刪除且易於除錯的程式碼

可以除錯的程式碼那必然是不如你大腦聰明的程式碼。現實生活中,我們總會遇到一些不好除錯的程式碼,比如有隱藏行為的程式碼、錯誤處理很糟糕的程式碼、意義模糊的程式碼、結構化程度太低或太高的程式碼,或者在修改過程中的程式碼。如果專案的規模足夠大,那你最終會遇到你無法理解的程式碼。

在老專案上,你根本不記得你寫過哪些程式碼,如果不是提交日誌,或許你會認為那些都是別人寫的。隨著專案規模的增長,想要記住每部分程式碼的功能變得越來越難,如果程式碼的行為與預期的不一致就會難上加難。在修改你不理解的程式碼時,你只能用最難的方式來參透:除錯

編寫易於除錯的程式碼,第一步就是要認識到:你以後會忘記你曾寫過的所有程式碼。其次,就要遵循以下幾條規則:

640?wx_fmt=png

好的程式碼也會有缺點

許多傳教士都說,編寫易於理解的程式碼本質,就是編寫乾淨的程式碼。這句話的關鍵點在於“乾淨”這個詞,它的意思完全依賴於語境。有時,程式碼乾淨的原因是不好的程式碼被寫入別的地方了。因此,好的程式碼不一定是乾淨的程式碼。

程式碼乾淨還是骯髒,其實更多是在評價你作為開發者對於這段程式碼的自尊心,或者說是羞恥心,而不是評價它是否易於維護或修改。因此,我們追求的並不是乾淨的程式碼,而是那種修改方式一目瞭然的“無聊”的程式碼。我發現,這種任何修改都觸手可及的程式碼更容易讓他人做出貢獻。因此,最好的程式碼就是能很快弄明白的程式碼:

  • 不要想著讓醜陋的問題變得好看,或者讓無聊的問題變得有趣。

  • 錯誤應當很明顯,行為應當是清晰的。我們不需要沒有明顯錯誤和晦澀行為的程式碼。

  • 程式碼的文件不需要追求完美。

  • 程式碼的行為十分明顯,任何開發者都可以想出無數種修改方法。

有時,程式碼看起來很噁心,但任何試圖修改它的行為都會讓它變得更糟糕。在不理解後果的情況下編寫乾淨的程式碼無異於試圖召喚可維護程式碼。

並不是說乾淨的程式碼不好,而是說有時候編寫乾淨的程式碼更像是把髒東西藏到地毯下面。可除錯的程式碼不一定要乾淨,而充滿了錯誤檢查和處理的程式碼通常讀起來並不愉快。

640?wx_fmt=png

計算機總會崩潰

計算機總會卡住,程式永遠會在上次執行時崩潰。

程式設計師應當做的第一件事就是在啟動時確保一個已知的、良好的、安全的狀態,再進行任何其他工作。有時候會由於使用者刪除、電腦升級等情況導致狀態不乾淨。程式上次執行時會崩潰,再次啟動時不應當陷入相互矛盾的狀態,而是永遠像第一次執行一樣乾淨。

例如,如果要從檔案中讀寫狀態,那麼可能會發生以下一系列問題:

  • 檔案丟失;

  • 檔案破損;

  • 檔案是舊版本,或比程式還新的版本;

  • 上次對檔案的修改未完成;

  • 檔案系統返回了錯誤的資料;

這些並不是新問題,資料庫系統從時間開始的那一刻起(1970年1月1日)就在處理這些問題了。使用 SQLite 之類的東西會幫你處理許多類似的問題,但如果程式上次執行時崩潰了,那麼程式碼執行時也許會遇到錯誤的資料,或者以錯誤的方式執行。

以定時執行的程式為例,我可以保證下面這些事故一定會發生:

  • 夏令時導致程式在同一時刻執行兩次;

  • 由於操作員忘記它已執行過,而導致執行兩次;

  • 由於機器磁碟空間滿,或神祕的網路問題而錯過某次執行;

  • 執行時間超過一小時,導致後續的執行被延誤;

  • 在一天內的錯誤時間執行;

  • 由於不可避免地在邊界時間(如午夜、月末、年末)執行而導致算術錯誤。

編寫強壯的軟體的第一步,就是要假設上次執行的結果是崩潰,而且需要在不知道如何進行下一步時主動崩潰。丟擲異常的最好方法就是在異常中留下類似於“這個狀況不應當發生”之類的註釋,這樣一旦發生,就能知道應當從何處開始除錯。

易於除錯的程式碼需要在執行操作之前檢查情況是否正確,可以輕鬆返回到已知良好狀態並再次嘗試,並且擁有多層防禦,使得錯誤儘早浮現。

640?wx_fmt=png

程式碼會跟自己打架

Google 最大的 DoS 攻擊來自於自己。我們的系統非常龐大,儘管一直都有人提出給我們的系統做收費的壓力測試,但我們認為我們自己才是最適合做這項工作的人。

對於任何系統都是這樣。

——AstridAtkinson,Long Game 的工程師

軟體總會在上次執行時崩潰,也永遠會用盡所有 CPU、佔據所有記憶體,還會用光所有硬碟。所有的工作程序都會遇到空佇列,每個程序都會重試超時的網路請求,所有伺服器都會在同一時間暫停進行垃圾回收。系統不僅會被破壞,而且還會隨時嘗試破壞自己。

就連想檢查系統是否真的在執行,都可能非常困難。

實現檢查伺服器是否執行的程式碼很容易,但如果伺服器不能處理請求,就沒那麼容易了。除非你去檢查 uptime,但有可能程式在兩次檢查之間崩潰。健康檢查也可能會觸發 Bug:我曾經寫過一個健康檢查程式碼,但在三個月後,那段程式碼卻讓它保護的程式碼崩潰了兩次。

在軟體中,編寫錯誤處理程式碼會不可避免地導致更多需要處理的錯誤,其中許多錯誤都是由錯誤處理程式碼本身導致的。類似地,效能優化經常會成為系統的瓶頸——讓應用在一個標籤內執行得很流暢,會使得 20 個副本同時執行時變得很難用。

還有個例子,流水線中的某個工作程序執行得太快,在流水線中的下一步驟執行之前耗光了所有記憶體。用汽車打個比方,那就是堵車。堵車的罪魁禍首就是超速,而且堵車可以認為是擁塞部分在車流中向後移動。優化會導致系統在高壓力下以某種神祕的方式崩潰。

換句話說,程序越快,就越難被推延,而如果系統不能推延該程序,那麼崩潰就在所難免了。

反向壓力是系統內的反饋的一種,而容易除錯的程式能夠讓使用者參與到反饋迴圈中,檢視系統的所有有意或無意、需要或不需要的行為。可調式的程式碼很容易檢視,從而可以觀察並理解其內部發生的一切。

640?wx_fmt=png

現在不弄清楚,以後就得除錯

換句話說,檢視程式中的變數的含義並弄清楚它發生了什麼應該不難。使用某種線性代數的過程,應該可以將程式碼的狀態以儘可能清晰的方式表示。也就是說,不要做類似於在程式中土改變變數含義的事情,即把一個變數用於兩個不同的用途。

這也意味著要避免半謂詞問題,即永遠不要用一個變數(count)表示一對值(boolean, count)。不要做類似於返回正數表示結果,返回 -1 表示沒有匹配的事情。理由很簡單,有可能會出現“0,但為真”的需求(需要提一句,Perl 5就正好有這個功能),或者寫出的程式碼很難與系統中的其他部分組合(對於下一個程式來說,-1可能是個有效的輸入,而不是錯誤)。

除了把一個變數用作兩個用途之外,為一個用途使用兩個變數也同樣糟糕,特別是兩個都是布林值的情況。我並不是說用兩個值表示一個範圍糟糕,而是說用多個布林值表示程式的狀態的情況。後者的本質通常是個狀態機。

如果狀態的流向不是從頂至下,比如是個迴圈,那麼最好是給狀態定義一個變數,並清理下邏輯。如果在一個物件內部有多個布林值,可以用一個名為state的列舉變數(如果需要儲存的話,也可以使用字串)替換它們。if語句就可以寫成if state == name,而不是if bad_name &&!alternate_option。

即使顯式寫出狀態機,也有可能寫出糟糕的程式碼:一些程式碼可能會包含兩個狀態機。我在寫一個HTTP代理時遇到了極大的困難,直到我明確寫出每個狀態機,並分別對連線狀態和解析狀態進行跟蹤之後才解決。如果把兩個狀態機合成一個,那就很難新增新狀態,或者判斷當前處於什麼狀態。

這一條更多的是在討論如何讓程式碼免於被除錯,而不是使之容易除錯。列出有效的狀態,可以更容易地拒絕無效狀態,而不是在無意中允許一兩個無效狀態通過。

640?wx_fmt=png

無意的行為就是預期的行為

如果你不能深刻理解某個資料結構,使用者就會來填充空白,使得你的程式碼的任何可能的行為,有意的或無意的行為,最終出現在某個地方。比如,許多主流程式語言都有雜湊表,在多數情況下,雜湊表在遍歷時通常會保持插入時的順序

一些語言會讓雜湊表儘可能地符合大多數使用者的預期,按照鍵值新增的順序去遍歷,但另一些語言會在每次遍歷時使用完全不同的順序返回。後者的情況下,一些使用者反而會抱怨這個行為的隨機性不夠。

可悲的是,程式中的任何隨機性最終都會被用於統計模擬過程,或者更糟糕的情況下會被用於加密,任何順序最終都會被用於排序。

在資料庫中,一些識別符號包含的資訊要比其他識別符號更多。建立表時,開發者可以用不同的型別作為主鍵。正確的做法是使用 UUID,或類似於 UUID 的東西。其他型別不僅會提供唯一性,還會提供順序,即不僅會提供 a == b,還會提供 a <= b,有些甚至直接使用自增型別。

自增型別會在每次表中加入新行時自動增加 1。這就導致了模糊的順序——人們無法判斷資料中的哪個屬性才能被用作排序的基準。換句話說,是應該按照鍵值排序,還是應該按照時間戳排序?就像前面說過的雜湊表一樣,人們會自己決定他們認為正確的做法。這種方式的另一個問題是,使用者還能很容易地猜到主鍵附近的其他記錄。

最終,任何自認為比 UUID 聰明的方案都會誤傷自己。我們嘗試過郵政編碼、電話號碼、IP 地址,無一不以失敗告終。UUID 可能不會讓程式碼更容易除錯,但更少的無意行為意味著更少的事故。

人們從主鍵中得到的資訊不僅僅是順序。如果資料庫的主鍵是通過其他欄位構建的,那麼人們會拋棄其他資料,而直接利用主鍵來重構其他資料。這樣就有兩個問題了:程式的狀態被儲存在兩個以上的地方,這兩者很容易出現不一致。如果無法確定哪個已被改變,哪個需要被改變,那麼想要同步都不可能。

不管你允許使用者做什麼,他們最後都會去做。編寫可除錯的程式碼意味著提前考慮資料被誤用的情況,考慮其他人可能怎樣使用這些資料。

640?wx_fmt=png

除錯先是社會過程,再是技術過程

當軟體專案分成多個元件和系統時,尋找 Bug 通常會變得非常困難。在理解 Bug 的發生原因後,通常需要與多個團隊進行協調,才能改正 Bug。在大型專案中修改專案的主要工作並不是尋找 Bug,而是說服其他人 Bug 的存在,甚至要說服他人該 Bug 是可修復的。

軟體中到處都存在 Bug,因為沒人肯定誰該為 Bug 負責。換句話說,如果責任不明確,那除錯程式碼就會很困難,任何事情都要先在 Slack(聊天群組工具) 上詢問,而只有等到真正知道的人上線後,這些問題才會得到回答。

計劃、工具、過程和文件正是解決這個問題的關鍵。 

計劃可以避免意外的壓力,規劃好的結構可以管理事故。計劃可以讓客戶瞭解專案進展,在需要時更換人員,並跟蹤問題、引入變更以減少未來的風險。工具可以降低工作需要的技能,使得他人也可以完成工作。過程可以避免依賴個人的控制,將控制權交還給團隊。

人會變,交流也會變,但過程和工具會在團隊中一直傳承下去。這並不是說後者的變化的意義大於前者,而是需要通過構建後者來支援前者的變化。流程也可以起到消除團隊控制的作用,所以並沒有好壞區分,但是總有一些流程會起作用,即便沒有寫下來,記錄文件的行為是讓其他人改變它的第一步。

文件並不僅僅是 .txt:文件是關於如何交付職責、如何讓新人加快速度,以及如何將變更後的內容傳達給受這些變更影響的人的方式。編寫文件需要比編寫程式碼更多的感情交流,也需要更多的技巧,它不像程式碼那樣只需簡單的編譯器標記或型別檢查器就能保證正確,並且很容易寫很多言之無物的文件。

如果沒有文件,你怎能期望人們做出明智的決定,甚至同意使用軟體的後果呢?沒有文件,工具或流程,就無法分擔維護的負擔,甚至無法替換目前負責該任務的人員。

簡化除錯同樣適用於程式設計等程式碼本身的流程,你需要搞清楚必須站在什麼位置上才能修復 Bug。

640?wx_fmt=png

易於除錯的程式碼很容易解釋

除錯時常見的情況,就是在向其他人解釋問題時就會發現解決問題的關鍵。因此,即便沒有人在,你也必須強迫自己從頭開始解釋情況、問題,以及重現步驟。通常,這個過程足以讓我們找到答案。

當我們尋求幫助時,我們經常會沒有問到點上,而且我和所有人一樣對此感到鬱悶——事實上,這是一個常見的煩惱問題,它有一個名字叫做:“X-Y 問題”:我怎樣才能拿到檔名的最後三個字母?哦?不,我想說的是怎樣獲取副檔名。

我們從自己理解的解決方案出發談論問題,並且從自己意識到的後果出發來討論解決方案。除錯是瞭解意外後果和找到替代解決方案的艱難方法,除錯還涉及程式設計師最難做到的事情之一:承認他們錯了。

畢竟,這不是編譯器的 Bug。

原文:https://programmingisterrible.com/post/173883533613/code-to-debug

作者:tef

譯者:彎月,責編:屠敏


徵稿啦

CSDN 公眾號秉持著「與千萬技術人共成長」理念,不僅以「極客頭條」、「暢言」欄目在第一時間以技術人的獨特視角描述技術人關心的行業焦點事件,更有「技術頭條」專欄,深度解讀行業內的熱門技術與場景應用,讓所有的開發者緊跟技術潮流,保持警醒的技術嗅覺,對行業趨勢、技術有更為全面的認知。

如果你有優質的文章,或是行業熱點事件、技術趨勢的真知灼見,或是深度的應用實踐、場景方案等的新見解,歡迎聯絡 CSDN 投稿,聯絡方式:微信(guorui_1118,請備註投稿+姓名+公司職位),郵箱([email protected])。

————— 推薦閱讀 —————

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=gif

640?wx_fmt=gif