為什麼寫這篇文章?
本文提到的絕大多數錯誤,都是作者歷經一番艱辛才得以發現,要麼是因為自己犯過,要麼是在別人的工作中見過。
本文並非意圖對程式設計師劃分等級,只是適合某些程式設計師閱讀,他們相信自己有能力判斷一件事情在什麼情況下是不良習慣的跡象,在什麼情況下則是特殊環境導致的結果。
寫這個系列是為了迫使作者自省,而釋出出來,是因為覺得大家也可能會從中找到感興趣的地方。
一、糟糕程式設計師的跡象
1. 無法對程式碼進行推理
對程式碼進行推理意味著能跟隨程式碼的執行路徑(“在腦子裡執行程式”),同時清楚地知道程式碼執行的目標。
特徵
- 程式裡有“巫毒程式碼( voodoo code )”;存在對程式目標毫無益處的程式碼,但卻仍然勤勉地維護它們(例如,初始化從來不用的變數、呼叫和目標毫不相關的函式、生成用不著的輸出,等等)。(譯者:巫毒程式碼應該就是隱藏危險的程式碼,不知道什麼時候就會給程式造成危害,就像“巫毒術”。)
- 多次執行冪等函式(例如:多次呼叫 save() 函式“只是為了確保無誤”)。(譯者:冪等函式,或冪等方法,是指可以使用相同引數重複執行,並能獲得相同結果的函式。這些函式不會影響系統狀態,也不用擔心重複執行會對系統造成改變。)
- 通過重寫錯誤程式碼的結果來修復程式 bug。
- “溜溜球式程式碼( Yo-Yo code )”就是將一個值轉換成另一種不同的格式,然後再轉換回到最初的格式(例如:將一個小數轉換成一個字串,然後再轉回成小數;或是填充一個字串,然後再裁剪它)。
- “推土機式程式碼( Bulldozer code )”將大塊程式碼分解成多個子程式,看起來像是重構,但不可能在其他環境下重用(耦合度太高)。
補救措施
程式猿可以通過實踐來克服這個缺點,如果 IDE 自帶的偵錯程式能單步除錯,就把它作為助手使用。比如說在 Visual Studio 裡,這就意味著要在問題區域的起始處打上斷點,然後按下‘ F11 ’單步除錯,檢視變數的值(變化前後都要檢視),直到你明白了程式碼正在做什麼。如果你的目標環境不具備這種特性,那就找一個擁有這種特性的環境去實踐。
這麼做的目的是,讓你做到不再需要偵錯程式就能在腦子裡跟隨程式碼的流程,而且有足夠的耐心去思考程式碼正在對整個程式的狀態做什麼。這麼做的好處就是能夠識別出冗餘且無用的程式碼,而且不需要從頭執行整個路徑就能在當前程式碼中找出 bug。
2.難以理解語言的程式設計模型
物件導向程式設計( Object Oriented Programming )就是一種語言模型,正如函數語言程式設計( Functional programming )或宣告式程式設計( Declarative programming )一樣。它們每一個都和過程式或指令式程式設計有著顯著不同,就像程式式程式設計明顯不同於彙編或基於 GOTO 的程式設計。此外,雖然有很多語言都跟隨同一個主流程式設計模型(如物件導向的程式設計),但它們都只介紹自己的改進,例如遞推式構造列表( list comprehensions )、泛型( generics )、鴨式分類( duck-typing )等等。
譯者:duck-typing 是動態語言的一種程式設計風格,用以實踐方法多型。Duck-typing 並不關注物件的實際型別,而是關注其表現。概念提出者 James Whitcomb Riley 這樣描述這個風格:當看到一隻鳥走起來像鴨子,遊起泳來像鴨子,叫起來也像鴨子,那這隻鳥就可以看出是鴨子。
特徵
- 使用任何所需的語法來擺脫模型的束縛,接著用他們熟悉的語言風格來完成程式的剩餘部分。
- (物件導向程式設計)試圖在未例項化的類中呼叫非靜態的函式或變數,並且無法理解為什麼這樣不能編譯。
- (物件導向程式設計)寫了大量“ xxxxxManager ”這樣的類,類中包含所有控制物件欄位的方法,而這些物件本身幾乎沒有定義方法。
- (關聯式程式設計)把關聯式資料庫當作物件倉庫,在客戶程式碼中執行所有的聯結( joins )和關係約束( relation enforcement )。
- (函數語言程式設計)為了處理不同型別的輸入或運算子,對同一個演算法建立多個版本實現,而不是向一個泛型實現傳入高階函式。
- (函數語言程式設計)非要在能自動快取的平臺上手動快取確定性函式的結果(比如 SQL 和 HasKell)。(譯者:確定性函式就是在輸入特定的值集合時,呼叫函式得到相同的結果。HasKell 是一種純函數語言程式設計語言。)
- 從別人的程式裡剪下貼上程式碼來處理 I/O 和 Monads。(譯者:Monads 是函數語言程式設計中一種代表計算指令的結構,詳見Monad。)
- (宣告式程式設計)在命令式程式碼中設定單一值,而不是使用資料繫結( data-binding )。
補救措施
如果你的技能不足,是因為別人教得不好或是自己沒學好,那編譯器自身就是一位備選老師。學習一個新的程式設計模型,最有效的辦法莫過於建立一個新工程,不管都有哪些新的構造方法,強迫自己去使用它們,無論在工程中的使用是否明智。你也需要練習用自己最熟悉且通俗易懂的措辭來解釋模型特性,然後遞迴地建立自己的新詞彙表,直到你對模型理解入微。舉個例子:
階段一:“OOP 就是方法的集合”
階段二:“OOP 裡的方法就是函式,它們執行在自帶全域性變數的小程式中”
階段三:“全域性變數被稱為欄位,其中有些是私有欄位,在小程式外不可見”
階段四:“擁有私有和公有元素是為了隱藏實現細節,暴露乾淨整潔的介面,這就叫封裝”
階段五:“封裝意味著實現細節不會破壞業務邏輯”
對所有程式語言來說階段五看起來都一樣,因為所有語言在階段五都試圖讓程式猿能表達出程式的意圖,而不需要將其隱藏在如何實現的細節之中。拿函數語言程式設計再舉個例子:
- 階段一:“函數語言程式設計做的所有事情就是將確定性函式連結在一起”
- 階段二:“當函式是確定的,編譯器就能夠預測什麼時候可以快取結果或跳過求值,甚至在什麼時候提前中止求值是安全的”
- 階段三:“為了支援惰性求值( Lzay Evaluation )和部分求值( Partial Evaluation ),編譯器要求函式定義如何轉換一個單一引數,甚至有時要將其轉換成另一個函式。這就叫函式柯里化( Currying )”
- 階段四:“有的時候編譯器可以替我們進行函式柯里化( Currying )”
- 階段五:“讓編譯器搞清楚普通細節,我就可以通過描述我想要什麼來寫程式,而不是告訴它怎麼給我結果”
3.缺乏研究技巧/長期缺乏對平臺特性的瞭解
如今,現代語言和框架都帶有非常了不起的內建命令和特性,一些主要的框架(像 Java 、 . Net 、 Cocoa)由於本身結構龐大,任何一個程式猿(甚至是一個很優秀的程式猿)都要花費好幾年時間去學習。但是,一個優秀的程式猿在自己開始構造所需函式之前,會先搜尋有沒有滿足需求的內建函式。而傑出的程式猿們則能夠分解並識別出任務中的抽象問題,接著在實際開始設計程式之前,去搜尋適用的現有框架、模式、模型和語言。
特徵
如果在應該掌握新平臺很久以後,這些特徵還繼續出現,那它們就暗示著存在問題。
- 重新發明或做一些費勁繁雜的工作來實現某種功能,而不使用語言內建的基礎機制,如事件-處理機制(events-and-handlers)或正規表示式。
- 重新發明框架的內建類和函式(比如定時器、資料集合、排序和搜尋演算法)。
- 在幫助論壇上釋出這樣的資訊“把程式碼發到我的郵箱,謝謝”。
- 用很多條指令來實現“冗餘程式碼”,實際上可以簡單得多(比如:把一個小數轉換成格式化字串來取整,然後再把這個字串轉回成小數)。
- 堅持使用過時的技術,即便在那些情況下使用新技術更佳(比如:還在寫命名委託函式,而不用lambda表示式)。
- 有一個很刻板的“舒適區( comfort zone )”,不顧一切地使用原語來解決複雜問題。
譯者:“ comfort zone ”就是使人感到安全、舒服或在其掌控之下的形式或狀態。
也會偶爾複製程式碼,複製的頻率和框架大小成比例,因此,按自己的程度來判斷吧。手寫連結串列的人也許知道自己正在做什麼,但手寫 StrCpy() 的人可能就不知道了。
補救措施
一個程式猿如果不放慢速度,就不可能學到這類知識。而且很有可能,這個人一直都在火急火燎地用任何需要的手段讓每個函式都工作起來。他需要在手邊放一本平臺的技術參考手冊,並且能夠花最小的代價瀏覽它,這就是說要麼在桌上的鍵盤右邊放一本列印稿,要麼還有一個屏專門用來開啟瀏覽器。為了開始培養這種習慣,他應該重構舊程式碼,目標是減少十分之一以上的指令數量。
4.無法理解指標
如果你不能理解指標,那你能寫的程式型別就非常有限,因為指標的概念創造出了很多複雜的資料結構和有效的 APIs。託管類語言使用引用來代替指標,兩者很像,但引用增加了自動解引用功能並禁止指標運算,從而消除特定型別的 bug。無論如何,它們還是非常相似,不能掌握這個概念就會導致資料結構的設計很差勁,並且出現一些由於不理解方法呼叫中值傳遞和引用傳遞的區別而導致的問題。
特徵
- 不會實現連結串列;從連結串列或樹中插入/刪除節點時,寫的程式碼總是丟失資料。
- 憑經驗為長度可變的集合分配大陣列,並且維護一個單獨的集合大小計數器,而不是使用動態資料結構。
- 無法找出或修復由指標運算錯誤導致的 bug。
- 對於作為引數傳遞給函式的指標,修改其指向的值,並且沒有預料到指標指向的物件會在函式外被改變。
- 複製指標,通過複製的指標改變其指向的值,然後假設原來的指標仍指向舊值。
- 在應該將指標的解引用值序列化時,卻把指標序列化到磁碟或網路上。
- 通過比較指標值來對指標陣列排序。
補救措施
“我有一個叫 Joe 的朋友待在賓館的某個房間裡,而我不知道他的房間號。但我知道他的熟人 Frank 待在哪個房間”,因此我跑去敲門問他‘Joe 在哪個房間?’,Frank 表示他也不知道,但他知道 Joe 的同事 Theodore 在哪個房間,並給了我 Theodore 的房間號。因此我又跑到Theodore的房間問 Joe 在哪,Theodore 告訴我 Joe 在414房間。實際上,Joe 就是在那個房間。”
對於指標,可以用很多種不同的隱喻來描述,而資料結構則可以描述成多種比喻。上面是對連結串列的簡單類比,而且任何人都能發明自己的版本,即使他們不是程式猿。提到指標大家都能理解,因此,你的描述不會比現有的描述還更全面。當程式猿試圖想象計算機的記憶體里正在發生什麼,並把這個想象和他們對普通變數的理解融合時,雖然這兩者很相似,但這個時候就會無法理解。也許將程式碼解釋成一個簡單的故事有利於推理當前的狀況,直到發現其中的區別,直到程式猿可以像面對標量值和陣列一樣直觀地想象指標和資料結構。
5.難以看透遞迴
遞迴的思想很容易理解,但程式猿們經常在自己腦子裡想象一次遞迴操作的結果時遇到困難,或想不通一個簡單函式是怎麼計算出複雜結果的。這些不解使得要設計一個遞迴函式變得難上加難,因為當你要對初始條件或遞迴呼叫的引數進行測試時,你想象不出“當前走到哪一步了”。
特徵
- 對問題設計極其複雜的迭代演算法,但其實可以通過遞迴解決(比如:遍歷一個檔案系統樹),尤其是在不用保證記憶體和效能的情況下。
- 遞迴函式在遞迴呼叫前後都會檢查相同的初始條件。
- 遞迴函式沒有測試初始條件。
- 遞迴子程式連線到一個全域性變數或支援輸出的變數上,或者累計這些變數的和。
- 對於遞迴呼叫中要傳遞什麼參數列現出明顯的困惑,或是不理解傳遞未修改引數的遞迴呼叫。
- 認為迭代的次數會被作為引數傳遞。
補救措施
先體會一下,準備好迎接某種堆疊溢位吧。首先,在程式碼裡只寫一個初始條件檢測並只呼叫一次遞迴,遞迴中使用同一個被傳遞的未修改引數。即使你覺得寫得不夠好也要停下來,無論如何,讓程式碼執行一下。它丟擲了一個堆疊溢位的異常,那麼現在返回去繼續寫,在遞迴呼叫中傳遞引數的已修改拷貝。產生了更多的堆疊溢位錯誤?輸出過度?那就接著反覆修改程式碼再執行,從修改初始條件測試轉向修改遞迴呼叫,直到你開始憑直覺就知道函式怎麼轉換它的輸入引數。忍住衝動,使用的初始條件測試或遞迴呼叫不要超過一次,除非你真的知道自己在做什麼。
你的目標是勇於進行遞迴呼叫,即使在這條想象中的遞迴路徑上,你沒有完全搞清楚“自己在哪裡”。那麼,等你需要為一個真正的專案去寫一個函式時,你會從寫單元測試開始,並且運用上面提到的相同技術來一步步推進。
6.不信任程式碼
特徵
- 寫這樣的函式:IsNull() 和 IsNotNull(), 或 IsTrue(bool) 和 IsFalse(bool)。
- 檢查一個布林變數會不會出現除了 true 或 false 以外的值。
補救措施
別人是按程式碼行數付錢給你嗎?這些舊習慣是不是你從一個擁有弱型別體系的語言中延續下來的?如果兩種都不是,那這種情況就類似於“無法推理程式碼”,但是似乎不是推理能力受損,而是無法信任和適應程式語言。有些特徵更像是經不起邏輯分析的“comfort code”,但程式猿非要強迫自己這麼寫。唯一的補救措施就是,多花時間熟悉程式語言。(譯者:因為不熟悉所以感到不確定,因此非要寫這樣的程式碼才能安心。)