1.摘要
這是爛程式碼系列的第二篇,在文章中我會跟大家討論一下如何儘可能高效和客觀的評價程式碼的優劣。
在釋出了《關於爛程式碼的那些事(上)》之後,發現這篇文章竟然意外的很受歡迎,很多人也描(tu)述(cao)了各自程式碼中這樣或者那樣的問題。
最近部門在組織bootcamp,正好我負責培訓程式碼質量部分,在培訓課程中讓大家花了不少時間去討論、改進、完善自己的程式碼。雖然剛畢業的同學對於程式碼質量都很用心,但最終呈現出來的質量仍然沒能達到“十分優秀”的程度。 究其原因,主要是不瞭解好的程式碼“應該”是什麼樣的。
2.什麼是好程式碼
寫程式碼的第一步是理解什麼是好程式碼。在準備bootcamp的課程的時候,我就為這個問題犯了難,我嘗試著用一些精確的定義區分出“優等品”、“良品”、“不良品”;但是在總結的過程中,關於“什麼是好程式碼”的描述卻大多沒有可操作性
2.1.好程式碼的定義
隨便從網上搜尋了一下“優雅的程式碼”,找到了下面這樣的定義:
Bjarne Stroustrup,C++之父:
- 邏輯應該是清晰的,bug難以隱藏;
- 依賴最少,易於維護;
- 錯誤處理完全根據一個明確的策略;
- 效能接近最佳化,避免程式碼混亂和無原則的優化;
- 整潔的程式碼只做一件事。
Grady Booch,《物件導向分析與設計》作者:
- 整潔的程式碼是簡單、直接的;
- 整潔的程式碼,讀起來像是一篇寫得很好的散文;
- 整潔的程式碼永遠不會掩蓋設計者的意圖,而是具有少量的抽象和清晰的控制行。
Michael Feathers,《修改程式碼的藝術》作者:
- 整潔的程式碼看起來總是像很在乎程式碼質量的人寫的;
- 沒有明顯的需要改善的地方;
- 程式碼的作者似乎考慮到了所有的事情。
看起來似乎說的都很有道理,可是實際評判的時候卻難以參考,尤其是對於新人來說,如何理解“簡單的、直接的程式碼”或者“沒有明顯的需要改善的地方”?
而實踐過程中,很多同學也確實面對這種問題:對自己的程式碼總是處在一種心裡不踏實的狀態,或者是自己覺得很好了,但是卻被其他人認為很爛,甚至有幾次我和新同學因為程式碼質量的標準一連討論好幾天,卻誰也說服不了誰:我們都堅持自己對於好程式碼的標準才是正確的。
在經歷了無數次code review之後,我覺得這張圖似乎總結的更好一些:
程式碼質量的評價標準某種意義上有點類似於文學作品,比如對小說的質量的評價主要來自於它的讀者,由個體主觀評價形成一個相對客觀的評價。並不是依靠字數,或者作者使用了哪些修辭手法之類的看似完全客觀但實際沒有什麼意義的評價手段。
但程式碼和小說還有些不一樣,它實際存在兩個讀者:計算機和程式設計師。就像上篇文章裡說的,即使所有程式設計師都看不懂這段程式碼,它也是可以被計算機理解並執行的。
所以對於程式碼質量的定義我需要於從兩個維度分析:主觀的,被人類理解的部分;還有客觀的,在計算機裡執行的狀況。
既然存在主觀部分,那麼就會存在個體差異,對於同一段程式碼評價會因為看程式碼的人的水平不同而得出不一樣的結論,這也是大多數新人面對的問題:他們沒有一個可以執行的評價標準,所以寫出來的程式碼質量也很難提高。
有些介紹程式碼質量的文章講述的都是傾向或者原則,雖然說的很對,但是實際指導作用不大。所以在這篇文章裡我希望儘可能把評價程式碼的標準用(我自認為)與實際水平無關的評價方式表示出來。
2.2.可讀的程式碼
在權衡很久之後,我決定把可讀性的優先順序排在前面:一個程式設計師更希望接手一個有bug但是看的懂的工程,還是一個沒bug但是看不懂的工程?如果是後者,可以直接關掉這個網頁,去做些對你來說更有意義的事情。
2.2.1.逐字翻譯
在很多跟程式碼質量有關的書裡都強調了一個觀點:程式首先是給人看的,其次才是能被機器執行,我也比較認同這個觀點。在評價一段程式碼能不能讓人看懂的時候,我習慣讓作者把這段程式碼逐字翻譯成中文,試著組成句子,之後把中文句子讀給另一個人沒有看過這段程式碼的人聽,如果另一個人能聽懂,那麼這段程式碼的可讀性基本就合格了。
用這種判斷方式的原因很簡單:其他人在理解一段程式碼的時候就是這麼做的。閱讀程式碼的人會一個詞一個詞的閱讀,推斷這句話的意思,如果僅靠句子無法理解,那麼就需要聯絡上下文理解這句程式碼,如果簡單的聯絡上下文也理解不了,可能還要掌握更多其它部分的細節來幫助推斷。大部分情況下,理解一句程式碼在做什麼需要聯絡的上下文越多,意味著程式碼的質量越差。
逐字翻譯的好處是能讓作者能輕易的發現那些只有自己知道的、沒有體現在程式碼裡的假設和可讀性陷阱。無法從字面意義上翻譯出原本意思的程式碼大多都是爛程式碼,比如“ms代表messageService“,或者“ms.proc()是發訊息“,或者“tmp代表當前的檔案”。
2.2.2.遵循約定
約定包括程式碼和文件如何組織,註釋如何編寫,編碼風格的約定等等,這對於程式碼未來的維護很重要。對於遵循何種約定沒有一個強制的標準,不過我更傾向於遵守更多人的約定。
與開源專案保持風格一致一般來說比較靠譜,其次也可以遵守公司內部的編碼風格。但是如果公司內部的編碼風格和當前開源專案的風格衝突比較嚴重,往往代表著這個公司的技術傾向於封閉,或者已經有些跟不上節奏了。
但是無論如何,遵守一個約定總比自己創造出一些規則要好很多,這降低了理解、溝通和維護的成本。如果一個專案自己創造出了一些奇怪的規則,可能意味著作者看過的程式碼不夠多。
一個工程是否遵循了約定往往需要程式碼閱讀者有一定經驗,或者需要藉助checkstyle這樣的靜態檢查工具。如果感覺無處下手,那麼大部分情況下跟著google做應該不會有什麼大問題:可以參考google code style,其中一部分有對應的中文版。
另外,沒有必要糾結於遵循了約定到底有什麼收益,就好像走路是靠左好還是靠右好一樣,即使得出了結論也沒有什麼意義,大部分約定只要遵守就可以了。
2.2.3.文件和註釋
文件和註釋是程式很重要的部分,他們是理解一個工程或專案的途徑之一。兩者在某些場景下定位會有些重合或者交叉(比如javadoc實際可以算是文件)。
對於文件的標準很簡單,能找到、能讀懂就可以了,一般來說我比較關心這幾類文件:
- 對於專案的介紹,包括專案功能、作者、目錄結構等,讀者應該能3分鐘內大致理解這個工程是做什麼的。
- 針對新人的QuickStart,讀者按照文件說明應該能在1小時內完成程式碼構建和簡單使用。
- 針對使用者的詳細說明文件,比如介面定義、引數含義、設計等,讀者能通過文件瞭解這些功能(或介面)的使用方法。
有一部分註釋實際是文件,比如之前提到的javadoc。這樣能把原始碼和註釋放在一起,對於讀者更清晰,也能簡化不少文件的維護的工作。
還有一類註釋並不作為文件的一部分,比如函式內部的註釋,這類註釋的職責是說明一些程式碼本身無法表達的作者在編碼時的思考,比如“為什麼這裡沒有做XXX”,或者“這裡要注意XXX問題”。
一般來說我首先會關心註釋的數量:函式內部註釋的數量應該不會有很多,也不會完全沒有,個人的經驗值是滾動幾螢幕看到一兩處左右比較正常。過多的話可能意味著程式碼本身的可讀性有問題,而如果一點都沒有可能意味著有些隱藏的邏輯沒有說明,需要考慮適當的增加一點註釋了。
其次也需要考慮註釋的質量:在程式碼可讀性合格的基礎上,註釋應該提供比程式碼更多的資訊。文件和註釋並不是越多越好,它們可能會導致維護成本增加。關於這部分的討論可以參考簡潔部分的內容。
2.2.4.推薦閱讀
《程式碼整潔之道》
2.3.可釋出的程式碼
新人的程式碼有一個比較典型的特徵,由於缺少維護專案的經驗,寫的程式碼總會有很多考慮不到的地方。比如說測試的時候似乎沒什麼異常,專案釋出之後才發現有很多意料之外的狀況;而出了問題之後不知道從哪下手排查,或者僅能讓系統處於一個並不穩定的狀態,依靠一些巧合勉強執行。
2.3.1.處理異常
新手程式設計師普遍沒有處理異常的意識,但程式碼的實際執行環境中充滿了異常:伺服器會當機,網路會超時,使用者會胡亂操作,不懷好意的人會惡意攻擊你的系統。
我對一段程式碼異常處理能力的第一印象來自於單元測試的覆蓋率。大部分異常難以在開發或者測試環境裡復現,即使有專業的測試團隊也很難在整合測試環境中模擬所有的異常情況。
而單元測試可以比較簡單的模擬各種異常情況,如果一個模組的單元測試覆蓋率連50%都不到,很難想象這些程式碼考慮了異常情況下的處理,即使考慮了,這些異常處理的分支都沒有被驗證過,怎麼指望實際執行環境中出現問題時表現良好呢?
2.3.2.處理併發
我收到的很多簡歷裡都寫著:精通併發程式設計/熟悉多執行緒機制,諸如此類,跟他們聊的時候也說的頭頭是道,什麼鎖啊互斥啊執行緒池啊同步啊訊號量啊一堆一堆的名詞滔滔不絕。而給應聘者一個實際場景,讓應聘者寫一段很簡單的併發程式設計的小程式,能寫好的卻不多。
實際上併發程式設計也確實很難,如果說寫好同步程式碼的難度為5,那麼併發程式設計的難度可以達到100。這並不是危言聳聽,很多看似穩定的程式,在面對併發場景的時候仍然可能出現問題:比如最近我們就碰到了一個linux kernel在呼叫某個系統函式時由於同步問題而出現crash的情況。
而是否高質量的實現併發程式設計的關鍵並不是是否應用了某種同步策略,而是看程式碼中是否保護了共享資源:
- 區域性變數之外的記憶體訪問都有併發風險(比如訪問物件的屬性,訪問靜態變數等)
- 訪問共享資源也會有併發風險(比如快取、資料庫等)。
- 被呼叫方如果不是宣告為執行緒安全的,那麼很有可能存在併發問題(比如java的hashmap)。
- 所有依賴時序的操作,即使每一步操作都是執行緒安全的,還是存在併發問題(比如先刪除一條記錄,然後把記錄數減一)。
前三種情況能夠比較簡單的通過程式碼本身分辨出來,只要簡單培養一下自己對於共享資源呼叫的敏感度就可以了。
但是對於最後一種情況,往往很難簡單的通過看程式碼的方式看出來,甚至出現併發問題的兩處呼叫並不是在同一個程式裡(比如兩個系統同時讀寫一個資料庫,或者併發的呼叫了一個程式的不同模組等)。但是,只要是程式碼裡出現了不加鎖的,訪問共享資源的“先做A,再做B”之類的邏輯,可能就需要提高警惕了。
2.3.3.優化效能
效能是評價程式設計師能力的一個重要指標,很多程式設計師也對程式的效能津津樂道。但程式的效能很難直接通過程式碼看出來,往往要藉助於一些效能測試工具,或者在實際環境中執行才能有結果。
如果僅從程式碼的角度考慮,有兩個評價執行效率的辦法:
- 演算法的時間複雜度,時間複雜度高的程式執行效率必然會低。
- 單步操作耗時,單步耗時高的操作儘量少做,比如訪問資料庫,訪問io等。
而實際工作中,也會見到一些程式設計師過於熱衷優化效率,相對的會帶來程式易讀性的降低、複雜度提高、或者增加工期等等。對於這類情況,簡單的辦法是讓作者說出這段程式的瓶頸在哪裡,為什麼會有這個瓶頸,以及優化帶來的收益。
當然,無論是優化不足還是優化過度,判斷效能指標最好的辦法是用資料說話,而不是單純看程式碼,效能測試這部分內容有些超出這篇文章的範圍,就不詳細展開了。
2.3.4.日誌
日誌代表了程式在出現問題時排查的難易程度,經(jing)驗(chang)豐(cai)富(keng)的程式設計師大概都會遇到過這個場景:排查問題時就少一句日誌,查不到某個變數的值不知道是什麼,導致死活分析不出來問題到底出在哪。
對於日誌的評價標準有三個:
- 日誌是否足夠,所有異常、外部呼叫都需要有日誌,而一條呼叫鏈路上的入口、出口和路徑關鍵點上也需要有日誌。
- 日誌的表達是否清晰,包括是否能讀懂,風格是否統一等。這個的評價標準跟程式碼的可讀性一樣,不重複了。
- 日誌是否包含了足夠的資訊,這裡包括了呼叫的上下文、外部的返回值,用於查詢的關鍵字等,便於分析資訊。
對於線上系統來說,一般可以通過調整日誌級別來控制日誌的數量,所以列印日誌的程式碼只要不對閱讀造成障礙,基本上都是可以接受的。
2.3.5.擴充套件閱讀
《Release It!: Design and Deploy Production-Ready Software》(不要看中文版,翻譯的實在是太爛了)
Numbers Everyone Should Know
2.4.可維護的程式碼
相對於前兩類程式碼來說,可維護的程式碼評價標準更模糊一些,因為它要對應的是未來的情況,一般新人很難想象現在的一些做法會對未來造成什麼影響。不過根據我的經驗,一般來說,只要反覆的提問兩個問題就可以了:
- 他離職了怎麼辦?
- 他沒這麼做怎麼辦?
2.4.1.避免重複
幾乎所有程式設計師都知道要避免拷程式碼,但是拷程式碼這個現象還是不可避免的成為了程式可維護性的殺手。
程式碼重複分為兩種:模組內重複和模組間重複。無論何種重複,都在一定程度上說明了程式設計師的水平有問題,模組內重複的問題更大一些,如果在同一個檔案裡都能出現大片重複的程式碼,那表示他什麼不可思議的程式碼都有可能寫出來。
對於重複的判斷並不需要反覆閱讀程式碼,一般來說現代的IDE都提供了檢查重複程式碼的工具,只需點幾下滑鼠就可以了。
除了程式碼重複之外,很多熱衷於維護程式碼質量的程式設計師新人很容易出現另一類重複:資訊重複。
我見過一些新人喜歡在每行程式碼前面寫一句註釋,比如:
1 2 3 4 5 |
// 成員列表的長度>0並且<200 if(memberList.size() > 0 && memberList.size() < 200) { // 返回當前成員列表 return memberList; } |
看起來似乎很好懂,但是幾年之後,這段程式碼就變成了:
1 2 3 4 5 |
// 成員列表的長度>0並且<200 if(memberList.size() > 0 && memberList.size() < 200 || (tmp.isOpen() && flag)) { // 返回當前成員列表 return memberList; } |
再之後可能會改成這樣:
1 2 3 4 5 6 7 8 9 |
// edit by axb 2015.07.30 // 成員列表的長度>0並且<200 //if(memberList.size() > 0 && memberList.size() < 200 || (tmp.isOpen() && flag)) { // 返回當前成員列表 // return memberList; //} if(tmp.isOpen() && flag) { return memberList; } |
隨著專案的演進,無用的資訊會越積越多,最終甚至讓人無法分辨哪些資訊是有效的,哪些是無效的。
如果在專案中發現好幾個東西都在做同一件事情,比如通過註釋描述程式碼在做什麼,或者依靠註釋替代版本管理的功能,那麼這些程式碼也不能稱為好程式碼。
2.4.2.模組劃分
模組內高內聚與模組間低耦合是大部分設計遵循的標準,通過合理的模組劃分能夠把複雜的功能拆分為更易於維護的更小的功能點。
一般來說可以從程式碼長度上初步評價一個模組劃分的是否合理,一個類的長度大於2000行,或者一個函式的長度大於兩螢幕都是比較危險的訊號。
另一個能夠體現模組劃分水平的地方是依賴。如果一個模組依賴特別多,甚至出現了迴圈依賴,那麼也可以反映出作者對模組的規劃比較差,今後在維護這個工程的時候很有可能出現牽一髮而動全身的情況。
一般來說有不少工具能提供依賴分析,比如IDEA中提供的Dependencies Analysis功能,學會這些工具的使用對於評價程式碼質量會有很大的幫助。
值得一提的是,絕大部分情況下,不恰當的模組劃分也會伴隨著極低的單元測試覆蓋率:複雜模組的單元測試非常難寫的,甚至是不可能完成的任務。所以直接檢視單元測試覆蓋率也是一個比較靠譜的評價方式。
2.4.3.簡潔與抽象
只要提到程式碼質量,必然會提到簡潔、優雅之類的形容詞。簡潔這個詞實際涵蓋了很多東西,程式碼避免重複是簡潔、設計足夠抽象是簡潔,一切對於提高可維護性的嘗試實際都是在試圖做減法。
程式設計經驗不足的程式設計師往往不能意識到簡潔的重要性,樂於搗鼓一些複雜的玩意並樂此不疲。但複雜是程式碼可維護性的天敵,也是程式設計師能力的一道門檻。
跨過門檻的程式設計師應該有能力控制逐漸增長的複雜度,總結和抽象出事物的本質,並體現到自己設計和編碼中。一個程式的生命週期也是在由簡入繁到化繁為簡中不斷迭代的過程。
對於這部分我難以總結出簡單易行的評價標準,它更像是一種思維方式,除了要理解、還需要練習。多看、多想、多交流,很多時候可以簡化的東西會大大超出原先的預計。
2.2.4.推薦閱讀
《重構-改善既有程式碼的設計》
《設計模式-可複用物件導向軟體的基礎》
《Software Architecture Patterns-Understanding Common Architecture Patterns and When to Use Them》
3.結語
這篇文章主要介紹了一些評價程式碼質量優劣的手段,這些手段中,有些比較客觀,有些主觀性更強。之前也說過,對程式碼質量的評價是一件主觀的事情,這篇文章裡雖然列舉了很多評價手段。但是實際上,很多我認為沒有問題的程式碼也會被其他人吐槽,所以這篇文章只能算是初稿,更多內容還需要今後繼續補充和完善。
雖然每個人對於程式碼質量評價的傾向都不一樣,但是總體來說評價程式碼質量的能力可以被比作程式設計師的“品味”,評價的準確度會隨著自身經驗的增加而增長。在這個過程中,需要隨時保持思考、學習和批判的精神。
下篇文章裡,會談一談具體如何提高自己的程式碼質量。