作為開發(fā)者,我們一直走在寫 Bug 的路上,而什么樣的代碼才是最好的?又該如何掌握調(diào)試的正確姿勢呢? 編寫易于刪除且易于調(diào)試的代碼 可以調(diào)試的代碼那必然是不如你大腦聰明的代碼。現(xiàn)實生活中,我們總會遇到一些不好調(diào)試的代碼,比如有隱藏行為的代碼、錯誤處理很糟糕的代碼、意義模糊的代碼、結(jié)構(gòu)化程度太低或太高的代碼,或者在修改過程中的代碼。如果項目的規(guī)模足夠大,那你最終會遇到你無法理解的代碼。 在老項目上,你根本不記得你寫過哪些代碼,如果不是提交日志,或許你會認(rèn)為那些都是別人寫的。隨著項目規(guī)模的增長,想要記住每部分代碼的功能變得越來越難,如果代碼的行為與預(yù)期的不一致就會難上加難。在修改你不理解的代碼時,你只能用最難的方式來參透:調(diào)試。 編寫易于調(diào)試的代碼,第一步就是要認(rèn)識到:你以后會忘記你曾寫過的所有代碼。其次,就要遵循以下幾條規(guī)則: 好的代碼也會有缺點 許多傳教士都說,編寫易于理解的代碼本質(zhì),就是編寫干凈的代碼。這句話的關(guān)鍵點在于“干凈”這個詞,它的意思完全依賴于語境。有時,代碼干凈的原因是不好的代碼被寫入別的地方了。因此,好的代碼不一定是干凈的代碼。 代碼干凈還是骯臟,其實更多是在評價你作為開發(fā)者對于這段代碼的自尊心,或者說是羞恥心,而不是評價它是否易于維護(hù)或修改。因此,我們追求的并不是干凈的代碼,而是那種修改方式一目了然的“無聊”的代碼。我發(fā)現(xiàn),這種任何修改都觸手可及的代碼更容易讓他人做出貢獻(xiàn)。因此,最好的代碼就是能很快弄明白的代碼: 不要想著讓丑陋的問題變得好看,或者讓無聊的問題變得有趣。 錯誤應(yīng)當(dāng)很明顯,行為應(yīng)當(dāng)是清晰的。我們不需要沒有明顯錯誤和晦澀行為的代碼。 代碼的文檔不需要追求完美。 代碼的行為十分明顯,任何開發(fā)者都可以想出無數(shù)種修改方法。 有時,代碼看起來很惡心,但任何試圖修改它的行為都會讓它變得更糟糕。在不理解后果的情況下編寫干凈的代碼無異于試圖召喚可維護(hù)代碼。 并不是說干凈的代碼不好,而是說有時候編寫干凈的代碼更像是把臟東西藏到地毯下面??烧{(diào)試的代碼不一定要干凈,而充滿了錯誤檢查和處理的代碼通常讀起來并不愉快。 計算機總會崩潰 計算機總會卡住,程序永遠(yuǎn)會在上次運行時崩潰。 程序員應(yīng)當(dāng)做的第一件事就是在啟動時確保一個已知的、良好的、安全的狀態(tài),再進(jìn)行任何其他工作。有時候會由于用戶刪除、電腦升級等情況導(dǎo)致狀態(tài)不干凈。程序上次運行時會崩潰,再次啟動時不應(yīng)當(dāng)陷入相互矛盾的狀態(tài),而是永遠(yuǎn)像第一次運行一樣干凈。 例如,如果要從文件中讀寫狀態(tài),那么可能會發(fā)生以下一系列問題: 文件丟失; 文件破損; 文件是舊版本,或比程序還新的版本; 上次對文件的修改未完成; 文件系統(tǒng)返回了錯誤的數(shù)據(jù); 這些并不是新問題,數(shù)據(jù)庫系統(tǒng)從時間開始的那一刻起(1970年1月1日)就在處理這些問題了。使用 SQLite 之類的東西會幫你處理許多類似的問題,但如果程序上次運行時崩潰了,那么代碼運行時也許會遇到錯誤的數(shù)據(jù),或者以錯誤的方式運行。 以定時運行的程序為例,我可以保證下面這些事故一定會發(fā)生: 夏令時導(dǎo)致程序在同一時刻運行兩次; 由于操作員忘記它已運行過,而導(dǎo)致運行兩次; 由于機器磁盤空間滿,或神秘的網(wǎng)絡(luò)問題而錯過某次運行; 運行時間超過一小時,導(dǎo)致后續(xù)的運行被延誤; 在一天內(nèi)的錯誤時間運行; 由于不可避免地在邊界時間(如午夜、月末、年末)運行而導(dǎo)致算術(shù)錯誤。 編寫強壯的軟件的第一步,就是要假設(shè)上次運行的結(jié)果是崩潰,而且需要在不知道如何進(jìn)行下一步時主動崩潰。拋出異常的最好方法就是在異常中留下類似于“這個狀況不應(yīng)當(dāng)發(fā)生”之類的注釋,這樣一旦發(fā)生,就能知道應(yīng)當(dāng)從何處開始調(diào)試。 易于調(diào)試的代碼需要在執(zhí)行操作之前檢查情況是否正確,可以輕松返回到已知良好狀態(tài)并再次嘗試,并且擁有多層防御,使得錯誤盡早浮現(xiàn)。 代碼會跟自己打架 Google 最大的 DoS 攻擊來自于自己。我們的系統(tǒng)非常龐大,盡管一直都有人提出給我們的系統(tǒng)做收費的壓力測試,但我們認(rèn)為我們自己才是最適合做這項工作的人。 對于任何系統(tǒng)都是這樣。 ——AstridAtkinson,Long Game 的工程師 軟件總會在上次運行時崩潰,也永遠(yuǎn)會用盡所有 CPU、占據(jù)所有內(nèi)存,還會用光所有硬盤。所有的工作進(jìn)程都會遇到空隊列,每個進(jìn)程都會重試超時的網(wǎng)絡(luò)請求,所有服務(wù)器都會在同一時間暫停進(jìn)行垃圾回收。系統(tǒng)不僅會被破壞,而且還會隨時嘗試破壞自己。 就連想檢查系統(tǒng)是否真的在運行,都可能非常困難。 實現(xiàn)檢查服務(wù)器是否運行的代碼很容易,但如果服務(wù)器不能處理請求,就沒那么容易了。除非你去檢查 uptime,但有可能程序在兩次檢查之間崩潰。健康檢查也可能會觸發(fā) Bug:我曾經(jīng)寫過一個健康檢查代碼,但在三個月后,那段代碼卻讓它保護(hù)的代碼崩潰了兩次。 在軟件中,編寫錯誤處理代碼會不可避免地導(dǎo)致更多需要處理的錯誤,其中許多錯誤都是由錯誤處理代碼本身導(dǎo)致的。類似地,性能優(yōu)化經(jīng)常會成為系統(tǒng)的瓶頸——讓應(yīng)用在一個標(biāo)簽內(nèi)運行得很流暢,會使得 20 個副本同時運行時變得很難用。 還有個例子,流水線中的某個工作進(jìn)程運行得太快,在流水線中的下一步驟執(zhí)行之前耗光了所有內(nèi)存。用汽車打個比方,那就是堵車。堵車的罪魁禍?zhǔn)拙褪浅伲叶萝嚳梢哉J(rèn)為是擁塞部分在車流中向后移動。優(yōu)化會導(dǎo)致系統(tǒng)在高壓力下以某種神秘的方式崩潰。 換句話說,進(jìn)程越快,就越難被推延,而如果系統(tǒng)不能推延該進(jìn)程,那么崩潰就在所難免了。 反向壓力是系統(tǒng)內(nèi)的反饋的一種,而容易調(diào)試的程序能夠讓用戶參與到反饋循環(huán)中,查看系統(tǒng)的所有有意或無意、需要或不需要的行為??烧{(diào)式的代碼很容易檢視,從而可以觀察并理解其內(nèi)部發(fā)生的一切。 現(xiàn)在不弄清楚,以后就得調(diào)試 換句話說,查看程序中的變量的含義并弄清楚它發(fā)生了什么應(yīng)該不難。使用某種線性代數(shù)的過程,應(yīng)該可以將代碼的狀態(tài)以盡可能清晰的方式表示。也就是說,不要做類似于在程序中土改變變量含義的事情,即把一個變量用于兩個不同的用途。 這也意味著要避免半謂詞問題,即永遠(yuǎn)不要用一個變量(count)表示一對值(boolean, count)。不要做類似于返回正數(shù)表示結(jié)果,返回 -1 表示沒有匹配的事情。理由很簡單,有可能會出現(xiàn)“0,但為真”的需求(需要提一句,Perl 5就正好有這個功能),或者寫出的代碼很難與系統(tǒng)中的其他部分組合(對于下一個程序來說,-1可能是個有效的輸入,而不是錯誤)。 除了把一個變量用作兩個用途之外,為一個用途使用兩個變量也同樣糟糕,特別是兩個都是布爾值的情況。我并不是說用兩個值表示一個范圍糟糕,而是說用多個布爾值表示程序的狀態(tài)的情況。后者的本質(zhì)通常是個狀態(tài)機。 如果狀態(tài)的流向不是從頂至下,比如是個循環(huán),那么最好是給狀態(tài)定義一個變量,并清理下羅技。如果在一個對象內(nèi)部有多個布爾值,可以用一個名為state的枚舉變量(如果需要保存的話,也可以使用字符串)替換它們。if語句就可以寫成if state == name,而不是if bad_name &&!alternate_option。 即使顯式寫出狀態(tài)機,也有可能寫出糟糕的代碼:一些代碼可能會包含兩個狀態(tài)機。我在寫一個HTTP代理時遇到了極大的困難,直到我明確寫出每個狀態(tài)機,并分別對連接狀態(tài)和解析狀態(tài)進(jìn)行跟蹤之后才解決。如果把兩個狀態(tài)機合成一個,那就很難添加新狀態(tài),或者判斷當(dāng)前處于什么狀態(tài)。 這一條更多的是在討論如何讓代碼免于被調(diào)試,而不是使之容易調(diào)試。列出有效的狀態(tài),可以更容易地拒絕無效狀態(tài),而不是在無意中允許一兩個無效狀態(tài)通過。 無意的行為就是預(yù)期的行為 如果你不能深刻理解某個數(shù)據(jù)結(jié)構(gòu),用戶就會來填充空白,使得你的代碼的任何可能的行為,有意的或無意的行為,最終出現(xiàn)在某個地方。比如,許多主流編程語言都有哈希表,在多數(shù)情況下,哈希表在遍歷時通常會保持插入時的順序 一些語言會讓哈希表盡可能地符合大多數(shù)用戶的預(yù)期,按照鍵值添加的順序去遍歷,但另一些語言會在每次遍歷時使用完全不同的順序返回。后者的情況下,一些用戶反而會抱怨這個行為的隨機性不夠。 可悲的是,程序中的任何隨機性最終都會被用于統(tǒng)計模擬過程,或者更糟糕的情況下會被用于加密,任何順序最終都會被用于排序。 在數(shù)據(jù)庫中,一些標(biāo)識符包含的信息要比其他標(biāo)識符更多。創(chuàng)建表時,開發(fā)者可以用不同的類型作為主鍵。正確的做法是使用 UUID,或類似于 UUID 的東西。其他類型不僅會提供唯一性,還會提供順序,即不僅會提供 a == b,還會提供 a 自增類型會在每次表中加入新行時自動增加 1。這就導(dǎo)致了模糊的順序——人們無法判斷數(shù)據(jù)中的哪個屬性才能被用作排序的基準(zhǔn)。換句話說,是應(yīng)該按照鍵值排序,還是應(yīng)該按照時間戳排序?就像前面說過的哈希表一樣,人們會自己決定他們認(rèn)為正確的做法。這種方式的另一個問題是,用戶還能很容易地猜到主鍵附近的其他記錄。 最終,任何自認(rèn)為比 UUID 聰明的方案都會誤傷自己。我們嘗試過郵政編碼、電話號碼、IP 地址,無一不以失敗告終。UUID 可能不會讓代碼更容易調(diào)試,但更少的無意行為意味著更少的事故。 人們從主鍵中得到的信息不僅僅是順序。如果數(shù)據(jù)庫的主鍵是通過其他字段構(gòu)建的,那么人們會拋棄其他數(shù)據(jù),而直接利用主鍵來重構(gòu)其他數(shù)據(jù)。這樣就有兩個問題了:程序的狀態(tài)被保存在兩個以上的地方,這兩者很容易出現(xiàn)不一致。如果無法確定哪個已被改變,哪個需要被改變,那么想要同步都不可能。 不管你允許用戶做什么,他們最后都會去做。編寫可調(diào)試的代碼意味著提前考慮數(shù)據(jù)被誤用的情況,考慮其他人可能怎樣使用這些數(shù)據(jù)。 調(diào)試先是社會過程,再是技術(shù)過程 當(dāng)軟件項目分成多個組件和系統(tǒng)時,尋找 Bug 通常會變得非常困難。在理解 Bug 的發(fā)生原因后,通常需要與多個團(tuán)隊進(jìn)行協(xié)調(diào),才能改正 Bug。在大型項目中修改項目的主要工作并不是尋找 Bug,而是說服其他人 Bug 的存在,甚至要說服他人該 Bug 是可修復(fù)的。 軟件中到處都存在 Bug,因為沒人肯定誰該為 Bug 負(fù)責(zé)。換句話說,如果責(zé)任不明確,那調(diào)試代碼就會很困難,任何事情都要先在 Slack(聊天群組工具) 上詢問,而只有等到真正知道的人上線后,這些問題才會得到回答。 計劃、工具、過程和文檔正是解決這個問題的關(guān)鍵。 計劃可以避免意外的壓力,規(guī)劃好的結(jié)構(gòu)可以管理事故。計劃可以讓客戶了解項目進(jìn)展,在需要時更換人員,并跟蹤問題、引入變更以減少未來的風(fēng)險。工具可以降低工作需要的技能,使得他人也可以完成工作。過程可以避免依賴個人的控制,將控制權(quán)交還給團(tuán)隊。 人會變,交流也會變,但過程和工具會在團(tuán)隊中一直傳承下去。這并不是說后者的變化的意義大于前者,而是需要通過構(gòu)建后者來支持前者的變化。流程也可以起到消除團(tuán)隊控制的作用,所以并沒有好壞區(qū)分,但是總有一些流程會起作用,即便沒有寫下來,記錄文檔的行為是讓其他人改變它的第一步。 文檔并不僅僅是 .txt:文檔是關(guān)于如何交付職責(zé)、如何讓新人加快速度,以及如何將變更后的內(nèi)容傳達(dá)給受這些變更影響的人的方式。編寫文檔需要比編寫代碼更多的感情交流,也需要更多的技巧,它不像代碼那樣只需簡單的編譯器標(biāo)記或類型檢查器就能保證正確,并且很容易寫很多言之無物的文檔。 如果沒有文檔,你怎能期望人們做出明智的決定,甚至同意使用軟件的后果呢?沒有文檔,工具或流程,就無法分擔(dān)維護(hù)的負(fù)擔(dān),甚至無法替換目前負(fù)責(zé)該任務(wù)的人員。 簡化調(diào)試同樣適用于編程等代碼本身的流程,你需要搞清楚必須站在什么位置上才能修復(fù) Bug。 易于調(diào)試的代碼很容易解釋 調(diào)試時常見的情況,就是在向其他人解釋問題時就會發(fā)現(xiàn)解決問題的關(guān)鍵。因此,即便沒有人在,你也必須強迫自己從頭開始解釋情況、問題,以及重現(xiàn)步驟。通常,這個過程足以讓我們找到答案。 當(dāng)我們尋求幫助時,我們經(jīng)常會沒有問到點上,而且我和所有人一樣對此感到郁悶——事實上,這是一個常見的煩惱問題,它有一個名字叫做:“X-Y 問題”:我怎樣才能拿到文件名的最后三個字母?哦?不,我想說的是怎樣獲取文件擴展名。 我們從自己理解的解決方案出發(fā)談?wù)搯栴},并且從自己意識到的后果出發(fā)來討論解決方案。調(diào)試是了解意外后果和找到替代解決方案的艱難方法,調(diào)試還涉及程序員最難做到的事情之一:承認(rèn)他們錯了。 畢竟,這不是編譯器的 Bug。 |
|