程式碼整潔之道
關於如何寫整潔程式碼的一些總結和思考
最近在KM上看了不少關於Code Review的文章,情不自禁的翻出《程式碼整潔之道》又看了一下,於是在這裡順便做個總結。其實能遵守騰訊程式碼規範寫出來的程式碼質量已經不差了,這裡主要是程式碼規範中容易犯的一些錯和自己的額外總結。
目錄
衡量好壞程式碼的標準
什麼樣的程式碼算整潔的程式碼?好的程式碼?談到程式碼好壞一定少不了這張圖。
WTFs/minute簡而言之就是你程式碼被人“感嘆”的頻率,程式碼必定是有好壞之分的,但在每個人心裡的標準又不一樣,沒法量化一個好壞程式碼的標準,但是如果一段程式碼讓人難以讀懂,亂七八糟,難以擴充套件和維護,讓人完全沒有讀下去的慾望,那肯定不是一份好程式碼。
為什麼要注重程式碼整潔
程式碼就像自己的孩子,作為父母肯定都希望孩子長的好看一點,出去被人誇長的好看,人見人誇,而不是見者WTF!
增加可維護性,降低維護成本
從可讀性來說,程式碼是寫給人看的,團隊不乏人員交替的負責一份程式碼的迭代和維護,如果別人閱讀你的程式碼很難讀懂,那他在程式碼的理解上肯定會有問題,比如某些細節沒理解清楚,就可能會埋下一個bug坑。
從可擴充套件性上來說,如果你只是修改一個簡單的功能,但是要涉及大量的程式碼改動,不僅開發難度加大,測試難度也會加大,甚至到了最後難以擴充套件需要被重構,這無疑給團隊帶來了災難。
對團隊和個人產生積極的影響
首先是對自己的影響,自己寫的程式碼被別人review的時候或者被後人修改的時候,不會被頻繁WTF,不會讓後面的維護者氣沖沖的敲下git blame
其次程式碼可能是會傳染的。比如你要維護一份爛程式碼,很可能你都不想碰,更別說重構了,這樣一直在爛程式碼上堆積if else
等邏輯,無疑會讓程式碼腐爛下去。但如果你程式碼寫的乾淨整潔,遵守規範,容易被人閱讀和維護,別人看到之後或許也會被你傳染,也許他原來不遵守程式碼規範,看到你的程式碼之後恍然大悟,從此開始注重程式碼整潔度和程式碼質量。
如何寫整潔的程式碼
這裡省略一些諸如不要用拼音命名,函式之間要有空行,統一縮排等此類人人都知道且很少會犯的點
規範
遵守團隊規範
無規矩不成方圓,寫程式碼也是,遵守團隊的程式碼規範(
有時候規範不一定是絕對的,比如C++
縮排2空格還是4空格的問題,這並沒有孰好孰壞,只有個人風格問題,但在一個團隊中,最好還是保持風格一致,風格統一的程式碼看起來才不會太亂。如果是C++
則可以定一個統一的clang format
檔案,團隊統一格式化,golang
則使用go fmt
即可(其實這個工具也是為了統一風格不是嗎)。
再比如golang
強制大括號的換行方式不也是為了統一格式在努力嗎?
入鄉隨俗,遵循語言風格
不要把其他語言的風格帶到另一個語言中。比如寫Python,儘量使自己的程式碼更加Pythonic。下面是一些列子:
-
交換兩個數
C/C++中你習慣這樣交換兩個數:
int temp = a; a = b; b = tmp;
Python:
a, b = b, a
-
列表推導
在Python可以這樣獲取[1,10)之間的偶數
[i for i in range(1, 10) if i % 2 == 0]
-
比較
其他語言比較
if a > 10 && a < 20
Python
if 10 < a < 20
還有更多這裡不一一列舉了
目錄結構
目錄結構要有設計
對於專案級別的目錄要有良好的設計,目錄結構設計好,後期專案越來越大的時候才不至於太亂,難以管理。
及時分類
當一個目錄檔案過多,且型別比較雜的時候,要考慮按照型別分多個目錄/包,不要偷懶,這樣才不至於讓一個目錄無限膨脹下去,對程式碼分包,分類也有助於梳理程式碼,使程式碼結構更加整潔。
檔案
檔案不要過大
檔案行數不要過多,任何規範肯定都會有,這裡還是強調一下,golang
不超過800行。一般情況下,單個檔案過大,對閱讀會造成一定的困難,如果格式好一點還好,如果格式亂的話簡直就是噩夢。雖然現在的IDE都具備一鍵摺疊程式碼的功能,但一個檔案內容過多說明你沒有及時對齊進行分類整理。別人維護的時候難以快速定位到關注點。
檔案末尾留一行
-
檔案末尾新增一行時,如果原來檔案末尾沒有換行,版本控制會把最後一行也算作修改(增加了換行符)
比如這裡在原來檔案末尾沒有換行的情況下,新增一行
cal
:# before #!/usr/bin/env bash python cc_auto_check_in.py
# after #!/usr/bin/env bash python cc_auto_check_in.py cal
PS D:\MyProjects\python\cc_auto_check_in> git diff 0158a324da9c991c8cbfa8bffe03736150855a7a .\cc_auto_check_in.sh diff --git a/cc_auto_check_in.sh b/cc_auto_check_in.sh index 2875f19..2ba4a4c 100644 --- a/cc_auto_check_in.sh +++ b/cc_auto_check_in.sh @@ -1,2 +1,3 @@ #!/usr/bin/env bash -python cc_auto_check_in.py \ No newline at end of file +python cc_auto_check_in.py +cal \ No newline at end of file
-
如果文字檔案中的最後一行資料沒有以換行符或回車符/換行符終止,則許多較舊的工具將無法正常工作。他們忽略該行,因為它以^ Z(eof)終止。
-
檔案是流式的,可以被任意的拼接並且拼接後仍然保證完整性。PS:[為什麼C語言檔案末尾不加換行會warning](Jim Wilson - Re: wny does GCC warn about "no newline at end of file"? (gnu.org))
-
游標在最後一行的時候更加舒適
命名
有意義的命名
我們都知道了命名不要用一個字母,不要用拼音,要遵守規範駝峰或者下劃線等等,但常常忽略了一點,很多人喜歡用自創的縮寫來代替原單詞,比如:ListenServerPort
縮寫為LSP
,不知道的還以為是Language Server Protocol
或者老色批
的縮寫呢。不要為了寫短一點而忽略了可讀性,命名長一些沒關係。只有那些非常面熟的再用縮寫。
儘量有意義,不要用1,2,3等
good:
void copyChars(const char *source, char *destination)
bad:
void copyChars(const char *a1, char *a2)
縮寫全大寫
good:
userID
QQ
SQL
bad:
userId
Qq
Sql
避免誤導性命名
命名的時候多想想,不要起名字太隨意了。函式名錶達函式功能,曾經見過用ABC三個單詞排列組合來命名多個函式,完全不知道這n個函式功能有啥區別。
good:
func doSomething()
bad:
// ABC是任意單詞且不代表順序
func doABC()
func doBAC()
func doCAB()
表示式
簡單
比如在go中可以把能省略下劃線的省略:
good:
for key := mapFoo {
}
for index := listFoo {
}
bad:
for key, _ := mapFoo {
}
for index, _ := listFoo {
}
少用奇技淫巧
很多人習慣把乘除2的倍數用位運算代替來提高效能,然而經過編譯器優化最後結果都一樣(如果是20年前這樣做可能還有點用,這雖然算不上奇技淫巧)。這樣只會讓人理解程式碼加多一步。
The poster child of strength reduction is replacing x / 2 with x >> 1 in source code. In 1985, that was a good thing to do; nowadays, you're just making your compiler yawn.
good:
a /= 2
bad:
a >>= 1
函式
儘量短
函式儘量短小,超過40行就要考慮這個函式是不是做了過多的事,20行封頂最佳,通常情況函式過長意味著:
- 可複用性低
- 理解難度高
- 不符合高內聚、低耦合的設計,不易維護,比如函式做了AB兩件事,我本來只需要關心B,但卻需要把A相關的程式碼也閱讀一遍。
只做一件事
如果你的函式名出現了doFooAndBar
此類,說明你可以把Foo
和Bar
這兩件事拆開兩個函數了。
good:
func init() {
initConfig()
initRPC()
}
func initConfig() {
// init config code
}
func initRPC() {
// init RPC code
}
bad:
func initConfigAndRPC() {
// init config code
// init RPC code
}
圈複雜度低
圈複雜度是衡量程式碼複雜程度的一種方法,簡單來說就是一個函式條件語句、迴圈語句越多,圈複雜度越高,越不易被人理解。一般來說,不要高於10。 寫go的同學可以用gocyclo這個工具來計算你的圈複雜度。
善用臨時變數
有些變數只用到一次的,可以用臨時變數代替,少一個變數名可以減少理解成本,也可以使得函式更短。
good:
return getData()
bad:
data := getData()
return data
簡化條件表示式
當if條件過多的時候,可以把某個判斷封裝成函式,這樣別人理解這個條件時,只需要閱讀函式名就基本知道程式碼的含義了,而且也可以降低程式碼的圈複雜度。當然遇到更為複雜的邏輯可以考慮設計模式(工廠,策略等)解決。
還可以根據情況,合理對條件進行拆分和合並。
下面的程式碼演示了健身房打架的一個小例子,需要對人物進行校驗:
good:
func checkOldMan(oldMan Man) bool {
if oldMan.Name == "馬煲鍋" && len(oldMan.Skills) == 2 && oldMan.Skills[0] == "接化發" && oldMan.Skills[1] == "松果糖豆閃電鞭" && oldMan.Age == 69 {
return true
}
return false
}
func checkYoungMan(youngMan Man) bool {
if len(youngMan.Skills) != 1 {
return false
}
if youngMan.Weight != 80 && youngMan.Weight != 90 {
return false
}
if youngManA.Age >= 30 && youngManA.Skills[0] == "泰拳" {
return true
}
return false
}
func FightInGym(oldMan, youngManA, youngManB Man) {
if !checkOldMan(oldMan) || !checkYoungMan(youngManA) || !checkYoungMan(youngManB) {
return
}
sneakAttack(youngManA, oldMan)
sneakAttack(youngManB, oldMan)
}
bad:
func FightInGym(oldMan, youngManA, youngManB Man) {
if oldMan.Name == "馬煲鍋" && len(oldMan.Skills) == 2 && oldMan.Skills[0] == "接化發" && oldMan.Skills[1] == "松果糖豆閃電鞭" && oldMan.Age == 69 && youngManA.Weight == 90 && len(youngManA.Skills) == 1 && youngManA.Skills[0] == "泰拳" && youngManA.Age >= 30 && youngManB.Weight == 80 && len(youngManB.Skills) == 1 && youngManB.Skill[0] == "泰拳" && youngManB.Age >= 30 {
sneakAttack(youngManA, oldMan)
sneakAttack(youngManB, oldMan)
}
}
可以看到程式碼雖然邊長了,但是可讀性增加了,而且把年輕人的校驗和老年人分開,到時候如果要修改偷襲者或者被偷襲者的判斷條件,很容易定位到check函式去修改。checkYoungMan
函式則根據條件特點,進行了條件拆分和合並,並且提前return減少巢狀。
不要過度巢狀
巢狀層數過多(一般超過4層就算多),圈複雜度將變得很高,每巢狀一層,造成理解難度將大大增加,難以維護且更容易出錯。
一個技巧是類似上面例子中提前return
還有就是迴圈中善用continue
和break
good:
for i := 0; i < 10; i++ {
if i % 2 != 0 {
continue
}
fmt.println(i)
// .. more code
}
bad:
for i := 0; i < 10; i++ {
if i % 2 == 0 {
fmt.println(i)
// .. more code
}
}
這裡只展示了一個簡單的例子,如果註釋那部分的程式碼又有巢狀或者比較複雜,則可以降低一層巢狀,增加可讀性。
每個函式呼叫在同一個抽象層級
函式中混雜不同抽象層級,會讓人迷惑。函式呼叫鏈是像樹一樣有層級的,能做到函式短小,功能單一,再對呼叫關係進行梳理,會更容易做到這一點。
比如上面健身房的例子,後續要有兩個操作,小朋友發問和錄製自拍視訊:
good:
func FightInGym(oldMan, youngManA, youngManB Man) {
if !checkOldMan(oldMan) || !checkYoungMan(youngManA) || !checkYoungMan(youngManB) {
return
}
sneakAttack(youngManA, oldMan)
sneakAttack(youngManB, oldMan)
AskByKid()
RecordVedio()
}
bad:
func FightInGym(oldMan, youngManA, youngManB Man) {
if !checkOldMan(oldMan) || !checkYoungMan(youngManA) || !checkYoungMan(youngManB) {
return
}
sneakAttack(youngManA, oldMan)
sneakAttack(youngManB, oldMan)
// 小朋友發問 實現細節...
// 小朋友發問 實現細節...
// 小朋友發問 實現細節...
RecordVedio()
}
上面的例子,很明顯小朋友發問和錄製自拍視訊的功能應該是同一個抽象層級,但這裡卻出現了小朋友發問的細節,就會顯得很突兀,如果這一大段細節程式碼出現,將大大提升理解這段程式碼的難度,而如果封裝成AskByKid()
,我只需要讀一下這個函式名即可,無需關注他的實現細節。
引數
-
引數儘量少(不超過5個)
-
引數過多的時候不要用map傳,考慮用結構體
返回值
- 可以返回元組的語言,返回值的數量不要過多
- 對於golang,error作為最後一個引數
消除重複程式碼
及時把重複程式碼做抽象(其實保證職責單一就很少有重複程式碼了)
安全
對於資源管理的時候,用語言特性保證安全
比如golang的defer
Python的with
類
當你需要把資料和行為進行封裝的時候,或者需要利用多型性質的時候再考慮用面向物件來封裝,有時候面向過程更清爽。
五大原則
五大原則耳朵聽出繭子了,簡單略過。
-
職責單一:保證類的功能單一,不要做過多的事情,及時按職責拆分。
-
介面隔離:小而多的介面,而不是少量通用介面。
-
開閉原則:最擴充套件開放,對修改關閉
-
依賴倒置原則:依賴抽象介面,不依賴具體類
-
里氏替換原則:子型別應該能夠替換它們的基類,反之則不可以
公私分明
不要所有的成員變數和方法都是public的,應當考慮哪些需要public,其餘的private。
註釋
避免無用註釋
不要註釋一眼看程式碼就能看出來的東西,多註釋程式碼之外的東西,比如業務為什麼這樣做。
good:
func isAdult(age int) bool {
// 這個產品是給朝鮮用的,所以成年年齡是17歲,以後考慮做成可配置的,目前只有朝鮮市場
return age >= 17
}
bad:
func isAdult(age int) bool {
// 大於等於17歲
return age >= 17
}
註釋和實現一致
有些時候修改了程式碼沒有修改註釋,容易造成註釋和實現不一致的情況,改程式碼的同時應該修改註釋。
一些註釋交給版本控制
不要註釋無用程式碼,應當刪掉,版本控制記錄了歷史變化,即使想找之前的程式碼也很容易
不要在註釋中寫修改日期,修改人,這個是很早之前沒有版本控制才這樣做。
關鍵資訊
涉及到時間等有單位的變數,註釋單位,比如下面的我根本不知道是毫秒還是秒,當然也可以把單位體現在命名裡。
good:
const expire = 1000 // 過期時間,單位:毫秒
const expireMS = 1000
bad:
const expire = 1000 // 過期時間
錯誤處理
傳遞還是處理
明確你這裡是要處理掉錯誤還是隻需要向上傳遞,有些時候上層不需要知道錯誤詳情,給一個預設值就行的,可以直接在原地處理掉。一般處理操作:打日誌、設定預設值。一般情況可傳遞至最外層處理。
下面的例子不明確是處理還是傳遞,造成日誌冗餘列印
good:
func getSingerAge(singerID int) int {
singerAge, err := getSingerAgeByRPC(singerID)
if err != nil {
log.error("getSingerName fail: %w", err)
// 前端展示未知
return -1
}
return singerAge
}
bad:
func getSingerAge(singerID int) (int, error) {
singerAge, err := getSingerAgeByRPC(singerID)
if err != nil {
log.error("getSingerName fail: %w", err)
// 前端展示未知
return -1, err // 上層很可能會繼續列印一次error日誌,還要加多一次error是否為空的判斷
}
return singerAge, nil
}
加上追蹤資訊
有時候錯誤傳遞層數過多,無法定位到最底層是哪,可以在傳遞的時候加上一些額外的資訊,幫助定位錯誤。
good:
return fmt.Errorf("module xxx: %w", err)
bad:
return err
日誌處理
可搜尋
日誌加一些可搜尋的字串,便於搜尋,如果儲存介質是ES,則考慮ES分詞後是否可快速搜尋。
不亂打日誌
除錯時候亂打的日誌,除錯完刪掉,不要想著提前預埋足夠的日誌列印,關鍵處列印即可。
明確日誌的型別,不要無腦全部error亂打。
防止日誌列印爆炸,注意不要在大的迴圈裡頻繁打日誌。
設計
簡單
考慮最簡單的解決方法,不要過度設計。
合理使用設計模式
不要為了使用設計模式而使用設計模式,只在需要的時候用,問清楚產品需求,未來改動,擴充套件的機率是多大。
嚴格的設計
如果是大型需求,設計儘量嚴格,儘量考慮細節,雖然很多是編碼階段考慮的,也可以提前畫一下簡單的UML圖,程式碼寫之前心中有數,不要做到最後程式碼亂七八糟。
心態
不將就
任何人都不可能一次性寫出來的程式碼是完美的,發現需要優化的時候就及時去做,儘量保證每次開啟程式碼都比上次更好,不要想著能跑就行,不將就。
程式碼評審
作為coder:
- 提交程式碼評審前自己先過一遍
- reviewer提出的點如果自己有不同意見及時交流,不要認為這是在針對你
作為reviewer:
- 針對程式碼,不針對人
- 要求嚴格,對程式碼倉庫的質量進行把關
參考文獻
《程式碼整潔之道》
[[KM]Code Review我都CR些什麼](