當前位置:
首頁 > 科技 > 編程真可怕,我們日常都在寫 Bug

編程真可怕,我們日常都在寫 Bug

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

編寫易於刪除且易於調試的代碼

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

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

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

好的代碼也會有缺點

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

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

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

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

代碼的文檔不需要追求完美。

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

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

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

計算機總會崩潰

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

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

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

文件丟失;

文件破損;

文件是舊版本,或比程序還新的版本;

上次對文件的修改未完成;

文件系統返回了錯誤的數據;

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

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

夏令時導致程序在同一時刻運行兩次;

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

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

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

在一天內的錯誤時間運行;

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

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

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

代碼會跟自己打架

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

對於任何系統都是這樣。

——AstridAtkinson,Long Game 的工程師

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

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

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

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

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

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

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

現在不弄清楚,以後就得調試

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

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

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

如果狀態的流向不是從頂至下,比如是個循環,那麼最好是給狀態定義一個變數,並清理下羅技。如果在一個對象內部有多個布爾值,可以用一個名為state的枚舉變數(如果需要保存的話,也可以使用字元串)替換它們。if語句就可以寫成if state == name,而不是if bad_name &&!alternate_option。

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

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

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

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

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

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

在資料庫中,一些標識符包含的信息要比其他標識符更多。創建表時,開發者可以用不同的類型作為主鍵。正確的做法是使用 UUID,或類似於 UUID 的東西。其他類型不僅會提供唯一性,還會提供順序,即不僅會提供 a == b,還會提供 a

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

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

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

不管你允許用戶做什麼,他們最後都會去做。編寫可調試的代碼意味著提前考慮數據被誤用的情況,考慮其他人可能怎樣使用這些數據。

調試先是社會過程,再是技術過程

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

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

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

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

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

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

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

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

易於調試的代碼很容易解釋

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

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

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

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

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

作者:tef

譯者:彎月,責編:屠敏

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 CSDN 的精彩文章:

繼 Linux 之父之後獨立開發者 Jonathan Blow 再次炮轟 C++ 可怕
BAT 爭搶的全棧工程師真的存在?

TAG:CSDN |