代碼整潔之道


關於如何寫整潔代碼的一些總結和思考

最近在KM上看了不少關於Code Review的文章,情不自禁的翻出《代碼整潔之道》又看了一下,於是在這里順便做個總結。其實能遵守騰訊代碼規范寫出來的代碼質量已經不差了,這里主要是代碼規范中容易犯的一些錯和自己的額外總結。

目錄

整潔代碼.png

衡量好壞代碼的標准

什么樣的代碼算整潔的代碼?好的代碼?談到代碼好壞一定少不了這張圖。

wtfm.jpg (500×471) (osnews.com)

WTFs/minute簡而言之就是你代碼被人“感嘆”的頻率,代碼必定是有好壞之分的,但在每個人心里的標准又不一樣,沒法量化一個好壞代碼的標准,但是如果一段代碼讓人難以讀懂,亂七八糟,難以擴展和維護,讓人完全沒有讀下去的欲望,那肯定不是一份好代碼。

為什么要注重代碼整潔

代碼就像自己的孩子,作為父母肯定都希望孩子長的好看一點,出去被人誇長的好看,人見人誇,而不是見者WTF!

這寫的是啥

增加可維護性,降低維護成本

從可讀性來說,代碼是寫給人看的,團隊不乏人員交替的負責一份代碼的迭代和維護,如果別人閱讀你的代碼很難讀懂,那他在代碼的理解上肯定會有問題,比如某些細節沒理解清楚,就可能會埋下一個bug坑。

從可擴展性上來說,如果你只是修改一個簡單的功能,但是要涉及大量的代碼改動,不僅開發難度加大,測試難度也會加大,甚至到了最后難以擴展需要被重構,這無疑給團隊帶來了災難。

對團隊和個人產生積極的影響

首先是對自己的影響,自己寫的代碼被別人review的時候或者被后人修改的時候,不會被頻繁WTF,不會讓后面的維護者氣沖沖的敲下git blame並口里大喊着:“這人不講碼德呀!誰寫的!”乃至在后面晉升職級時候的代碼評審也會有好的幫助。

不講碼德.png

其次代碼可能是會傳染的。比如你要維護一份爛代碼,很可能你都不想碰,更別說重構了,這樣一直在爛代碼上堆積if else等邏輯,無疑會讓代碼腐爛下去。但如果你代碼寫的干凈整潔,遵守規范,容易被人閱讀和維護,別人看到之后或許也會被你傳染,也許他原來不遵守代碼規范,看到你的代碼之后恍然大悟,從此開始注重代碼整潔度和代碼質量。

如何寫整潔的代碼

這里省略一些諸如不要用拼音命名,函數之間要有空行,統一縮進等此類人人都知道且很少會犯的點

規范

遵守團隊規范

無規矩不成方圓,寫代碼也是,遵守團隊的代碼規范(騰訊代碼規范)是作為程序員的基本素養。這些規范都是經驗豐富的頂級大佬總結出來的,能成為公司標准必然是經過深思熟慮的,有時候我們應該舍棄一些個人風格,保持團隊統一。

規矩.png

有時候規范不一定是絕對的,比如C++縮進2空格還是4空格的問題,這並沒有孰好孰壞,只有個人風格問題,但在一個團隊中,最好還是保持風格一致,風格統一的代碼看起來才不會太亂。如果是C++則可以定一個統一的clang format文件,團隊統一格式化,golang則使用go fmt即可(其實這個工具也是為了統一風格不是嗎)。

再比如golang強制大括號的換行方式不也是為了統一格式在努力嗎?

入鄉隨俗,遵循語言風格

不要把其他語言的風格帶到另一個語言中。比如寫Python,盡量使自己的代碼更加Pythonic。下面是一些列子:

  1. 交換兩個數

    C/C++中你習慣這樣交換兩個數:

    int temp = a;
    a = b;
    b = tmp;
    

    Python:

    a, b = b, a
    
  2. 列表推導

    在Python可以這樣獲取[1,10)之間的偶數

    [i for i in range(1, 10) if i % 2 == 0]
    
  3. 比較

    其他語言比較

    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年前這樣做可能還有點用,這雖然算不上奇技淫巧)。這樣只會讓人理解代碼加多一步。

redis源碼注釋中的這篇文章也有提到這點:

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行封頂最佳,通常情況函數過長意味着:

  1. 可復用性低
  2. 理解難度高
  3. 不符合高內聚、低耦合的設計,不易維護,比如函數做了AB兩件事,我本來只需要關心B,但卻需要把A相關的代碼也閱讀一遍。

只做一件事

如果你的函數名出現了doFooAndBar此類,說明你可以把FooBar這兩件事拆開兩個函數了。

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

還有就是循環中善用continuebreak

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些什么](


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM