概述
美國童子軍有一條簡單的軍規:讓營地比你來時更幹淨。當梳理代碼時,堅守此軍規:每次 review 代碼,讓代碼比你發現它時更整潔。
一位大神說過:“衡量代碼品質的唯一有效标準:WTF/min”,并配了一個形象的圖:
通過别人在 review 代碼過程中,每分鐘 “爆粗” 的次數來衡量這個代碼好的程度。
代碼整潔的必要性
好的代碼就是為了更美好的生活! Clean Code == Good Code == Good Life!
為了把自己和他人從 糟糕的代碼維護生活 中解脫出來,必由之路 就是寫 整潔的代碼。于個人來說,代碼是否整潔影響心情;于公司來說,代碼是否整潔,影響經營生存(因為代碼寫的爛而倒閉的公司還少嗎?)。
一念天堂,一念地獄。
壞味道的代碼
開始閱讀之前,大家可以快速思考一下,大家腦海裡的 好代碼 和 壞代碼 都是怎麼樣的“形容”呢?
如果看到這一段代碼,如何評價呢?
if (a && d || b && c && !d || (!a || !b) && c) {
// ...
} else {
// ...
}
上面這段代碼,盡管是特意為舉例而寫的,要是真實遇到這種代碼,想必大家都 “一言難盡” 吧!大家多多少少都有一些 壞味道的代碼 的 “印象”,壞味道的代碼總有一些共性:
那壞味道的代碼是怎樣形成的呢?
- 上一個寫這段代碼的程式員經驗、水準不足,或寫代碼時不夠用心;
- 業務方提出的奇葩需求導緻寫了很多 hack 代碼;
- 某一個子產品業務太複雜,需求變更的次數太多,經手的程式員太多。
當代碼的壞味道已經 “彌漫” 到處都是了,這時我們應該了解一下 重構。接下來,通過了解 圈複雜度 去衡量我們寫的代碼。
圈複雜度
圈複雜度 可以用來衡量一個子產品 判定結構 的 複雜程度,數量上表現為 獨立現行路徑條數,也可了解為覆寫 所有執行路徑 使用的 最少測試用例數。
圈複雜度(Cyclomatic complexity,簡寫CC)也稱為 條件複雜度,是一種 代碼複雜度 的 衡量标準。由托馬斯·J·麥凱布(Thomas J. McCabe, Sr.)于1976年提出,用來表示程式的複雜度。
1. 判定方法
圈複雜度可以通過程式控制流圖計算,公式為:
V(G) = e + 2 - n
- e : 控制流圖中邊的數量
- n : 控制流圖中節點的數量
有一個簡單的計算方法:圈複雜度 實際上就是等于 判定節點的數量 再加上 1。
2. 衡量标準
代碼複雜度低,代碼不一定好,但代碼複雜度高,代碼一定不好。
圈複雜度 | 代碼狀況 | 可測性 | 維護成本 |
1 - 10 | 清晰、結構化 | 高 | 低 |
10 - 20 | 複雜 | 中 | 中 |
20 - 30 | 非常複雜 | 低 | 高 |
>30 | 不可讀 | 不可測 | 非常高 |
3. 降低代碼的圈複雜度
3.1. 抽象配置
通過 抽象配置 将複雜的邏輯判斷進行簡化。
- 優化前
if (type === '掃描') {
scan(args)
} else if (type === '删除') {
delete(args)
} else if (type === '設定') {
set(args)
} else {
other(args)
}
- 優化後
const ACTION_TYPE = {
'掃描': scan,
'删除': delete,'
'設定': set
}
ACTION_TYPE[type](args)
3.2. 方法拆分
将代碼中的邏輯 拆分 成單獨的方法,有利于降低代碼複雜度和降低維護成本。當一個函數的代碼很長,讀起來很費力的時候,就應該思考能否提煉成 多個函數。
- 優化前
function example(val) {
if (val > MAX_VAL) {
val = MAX_VAL
}
for (let i = 0; i < val; i++) {
doSomething(i)
}
}
- 優化後
function setMaxVal(val) {
return val > MAX_VAL ? MAX_VAL : val
}
function getCircleArea(val) {
for (let i = 0; i < val; i++) {
doSomething(i)
}
}
function example(val) {
return getCircleArea(setMaxVal(val))
}
3.3. 簡單條件分支優先處理
對于複雜的條件判斷進行優化,盡量保證 簡單條件分支優先處理,這樣可以 減少嵌套、保證 程式結構清晰。
- 優化前
function checkAuth(user){
if (user.auth) {
if (user.name === 'admin') {
doSomethingByAdmin(user)
} else if (user.name === 'root') {
doSomethingByRoot(user)
}
}
}
- 優化後
function checkAuth(user){
if (!user.auth) {
return
}
if (user.name === 'admin') {
doSomethingByAdmin(user)
} else if (user.name === 'root') {
doSomethingByRoot(user)
}
}
3.4. 合并條件簡化條件判斷
- 優化前
if (fruit === 'apple') {
return true
} else if (fruit === 'cherry') {
return true
} else if (fruit === 'peach') {
return true
} else {
return true
}
- 優化後
const redFruits = ['apple', 'cherry', 'peach']
if (redFruits.includes(fruit) {
return true
}
3.5. 提取條件簡化條件判斷
對 晦澀難懂 的條件進行 提取并語義化。
- 優化前
if ((age < 20 && gender === '女') || (age > 60 && gender === '男')) {
// ...
} else {
// ...
}
- 優化後
function isYoungGirl(age, gender) {
return (age < 20 && gender === '女'
}
function isOldMan(age, gender) {
return age > 60 && gender === '男'
}
if (isYoungGirl(age, gender) || isOldMan(age, gender)) {
// ...
} else {
// ...
}
重構
重構一詞有名詞和動詞上的了解。
- 名詞:
對軟體内部結構的一種調整,目的是在不改變軟體可觀察行為的前提下,提高其可了解性,降低其修改成本。
- 動詞:
使用一系列重構手法,在不改變軟體可觀察行為的前提下,調整其結構。
1. 為何重構
如果遇到以下的情況,可能就要思考是否需要重構了:
- 重複的代碼太多
- 代碼的結構混亂
- 程式沒有拓展性
- 對象結構強耦合
- 部分子產品性能低
為何重構,不外乎以下幾點:
- 重構改進軟體設計
- 重構使軟體更容易了解
- 重構幫助找到BUG
- 重構提高程式設計速度
重構的類型
- 對現有項目進行代碼級别的重構;
- 對現有的業務進行軟體架構的更新和系統的更新。
本文讨論的内容隻涉及第一點,僅限代碼級别的重構。
2. 重構時機
第一次做某件事時隻管去做;第二次做類似的事會産生反感,但無論如何還是可以去做;第三次再做類似的事,你就應該重構。
- 添加功能:當添加新功能時,如果發現某段代碼改起來特别困難,拓展功能特别不靈活,就要重構這部分代碼使添加新特性和功能變得更容易;
- 修補錯誤:在你改 BUG 或查找定位問題時,發現自己以前寫的代碼或者别人的代碼設計上有缺陷(如擴充性不靈活),或健壯性考慮得不夠周全(如漏掉一些該處理的異常),導緻程式頻繁出現問題,那麼此時就是一個比較好的重構時機;
- 代碼檢視:團隊進行 Code Review 的時候,也是一個進行重構的合适時機。
代碼整潔之道
代碼應當 易于了解,代碼的寫法應當使别人了解它所需的時間最小化。
代碼風格
關鍵思想:一緻的風格比 “正确” 的風格更重要。
原則:
- 使用一緻的 代碼布局 和 命名
- 讓相似的代碼看上去 相似
- 把相關的代碼行 分組,形成 代碼塊
注釋
注釋的目的是盡量幫助讀者了解到和作者一樣多的資訊。是以注釋應當有很高的 資訊/空間率。
1. 好注釋
- 特殊标記注釋:如 TODO、FIXME 等有特殊含義的标記
- 檔案注釋:部分規約會約定在檔案頭部書寫固定格式的注釋,如注明作者、協定等資訊
- 文檔類注釋:部分規約會約定 API、類、函數等使用文檔類注釋
- 遵循統一的風格規範,如一定的空格、空行,以保證注釋自身的可讀性
2. 壞注釋
- 自言自語,自己感覺要加注釋的地方就寫上注釋
- 多餘的注釋:本身代碼已經能表達意思就不要加注釋
- 誤導性注釋(随着代碼的疊代,注釋總有一天會由于過于陳舊而導緻産生誤導)
- 日志式注釋:日志本身可以展現出具體語意,不需要多餘的注釋
- 能用函數或者變量名稱表達語意的就不要用注釋
- 注釋掉的代碼應該删除,避免誤導和混淆
有意義的命名
良好的命名是一種以 低代價 取得代碼 高可讀性 的途徑。
1. 選擇專業名詞
單詞 | 更多選擇 |
send | deliver, despatch, announce, distribute, route |
find | search, extract, locate, recover |
start | launch, create, begin, open |
make | create, set up, build, generate, compose, add, new |
2. 避免像tmp和retval這樣泛泛的名字
- retval 這個名字沒有包含明确的資訊
- tmp 隻應用于短期存在且臨時性為其主要存在因素的變量
3. 用具體的名字代替抽象的名字
在給變量、函數或者其他元素命名時,要把它描述得更具體,而不是讓人不明是以。
4. 為名字附帶更多資訊
如果關于一個 變量 有什麼重要的含義需要讓讀者知道,那麼是值得把額外的 “詞” 添加到名字中。
5. 名字的長度
- 在小的作用域裡可以使用短的名字
- 為作用域大的名字采用更長的名字
- 丢掉沒用的詞
6. 不會被誤解的名字
- 用 min 和 max 來表示極限
- 用 first 和 last 來表示包含的範圍
- 用 begin 和 end 來表示排除範圍
- 給布爾值命名:is、has、can、should
7. 語義相反的詞彙要成對出現
正 | 反 |
add | remove |
create | destory |
insert | delete |
get | set |
increment | decrement |
show | hide |
start | stop |
8. 其他命名小建議
- 計算限定符作為字首或字尾(Avg、Sum、Total、Min、Max)
- 變量名要能準确地表示事物的含義
- 用動名詞命名函數名
- 變量名的縮寫,盡量避免不常見的縮寫
簡化條件表達式
1. 分解條件表達式
有一個複雜的條件(if-elseif-else)語句,從 if、elseif、else 三個段落中分别提煉出 獨立函數。根據每個小塊代碼的用途,為分解而得到的 新函數 命名。對于 條件邏輯,可以 突出條件邏輯,更清楚地表明每個分支的作用和原因。
2. 合并條件表達式
将這些一系列 相關聯 的條件表達式 合并 為一個,并将這個條件表達式提煉成為一個 獨立的方法。
- 确定這些條件語句都沒有副作用;
- 使用适當的邏輯操作符,将一系列相關條件表達式合并為一個;
- 對合并後的條件表達式實施進行方法抽取。
3. 合并重複的條件片段
在條件表達式的每個分支上有着一段 重複的代碼,将這段重複代碼搬移到條件表達式之外。
4. 以衛語句取代嵌套條件表達式
函數中的條件邏輯使人難以看清正常的執行路徑。使用 衛語句 表現所有特殊情況。
如果某個條件極其罕見,就應該單獨檢查該條件,并在該條件為真時立刻從函數中傳回。這樣的單獨檢查常常被稱為 “衛語句”(guard clauses)。
常常可以将 條件表達式反轉,進而實以 衛語句 取代 嵌套條件表達式,寫成更加 “線性” 的代碼來避免 深嵌套。
變量與可讀性
1. 内聯臨時變量
如果有一個臨時變量,隻是被簡單表達式 指派一次,而将所有對該變量的引用動作,替換為對它指派的那個表達式自身。
2. 以查詢取代臨時變量
以一個臨時變量儲存某一表達式的運算結果,将這個表達式提煉到一個獨立函數中。将這個臨時變量的所有引用點替換為對新函數的調用。此後,新函數就可被其他函數使用。
3. 總結變量
接上條,如果該表達式比較複雜,建議通過一個總結變量名來代替一大塊代碼,這個名字會更容易管理和思考。
4. 引入解釋性變量
将複雜表達式(或其中一部分)的結果放進一個 臨時變量,以此 變量名稱 來解釋表達式用途。
在條件邏輯中,引入解釋性變量特别有價值:可以将每個 條件子句 提煉出來,以一個良好命名的 臨時變量 來解釋對應條件子句的 意義。使用這項重構的另一種情況是,在較長算法中,可以運用 臨時變量 來解釋每一步運算的意義。
好處:
- 把巨大的表達式拆分成小段
- 通過用簡單的名字描述子表達式來讓代碼文檔化
- 幫助讀者識别代碼中的主要概念
5. 分解臨時變量
程式有某個 臨時變量 被指派 超過一次,它既不是循環變量,也不是用于收集計算結果。針對每次指派,創造一個獨立、對應的臨時變量。
臨時變量有各種不同用途:
- 循環變量
- 結果收集變量(通過整個函數的運算,将構成的某個值收集起來)
如果臨時變量承擔多個責任,它就應該被替換(分解)為 多個臨時變量,每個變量隻承擔一個責任。
6. 以字面常量取代 Magic Number
有一個字面值,帶有特别含義。創造一個 常量,根據其意義為它 命名,并将上述的字面數值替換為這個常量。
7. 減少控制流變量
let done = false;
while (condition && !done) {
if (matchCondtion()) {
done = true;
continue;
}
}
像 done 這樣的變量,稱為 “控制流變量”。它們唯一的目的就是控制程式的執行,沒有包含任何程式的資料。控制流變量通常可以通過更好地運用 結構化程式設計而消除。
while (condition) {
if (matchCondtion()) {
break;
}
}
如果有 多個嵌套循環,一個簡單的 break 不夠用,通常解決方案包括把代碼挪到一個 新函數。
重新組織函數
一個函數盡量隻做一件事情,這是程式 高内聚,低耦合 的基石。
1. 提煉函數
當一個過長的函數或者一段需要注釋才能讓人了解用途的代碼,可以将這段代碼放進一個 獨立函數。
- 函數的粒度小,被 複用 的機會就很大;
- 函數的粒度小,覆寫 也會更容易些。
一個函數過長才合适?長度 不是問題,關鍵在于 函數名稱 和 函數本體 之間的 語義距離。
2. 代碼塊與縮進
函數的縮進層級不應該多于 一層 或 兩層,對于 超過兩層 的代碼可以根據 重載 或函數的 具體語意 抽取的的函數。
3. 分離查詢函數和修改函數
某個函數既 傳回對象狀态值,又 修改對象狀态。建立兩個不同的函數,其中一個 負責查詢,另一個 負責修改。
4. 函數參數優化
函數參數格式盡量避免超過 3 個。參數過多(類型相近)會導緻代碼 容錯性降低,導緻參數個數順序傳錯等問題。如果函數的參數太多,可以考慮将參數進行 分組 和 歸類,封裝成 單獨的對象。
5. 從函數中提前傳回
可以通過馬上處理 “特殊情況”,可以通過 衛語句 處理,從函數中 提前傳回。
6. 重複代碼抽取公共函數
應該避免純粹的 copy-paste,将程式中的 重複代碼 抽取成公共的函數,這樣的好處是避免 修改、删除 代碼時出現遺忘或誤判。
- 兩個方法的 共性 提取到新方法中,新方法分解到另外的類裡,進而提升其可見性
- 模闆方法模式是消除重複的通用技巧
7. 拆分複雜的函數
如果有很難讀的代碼,嘗試把它所做的 所有任務列出來。其中 一些任務 可以很容易地變成 單獨的函數(或類)。其他的可以簡單地成為一個函數中的邏輯 “段落”。
- 檢查函數的 命名 是否 名副其實,梳理函數的思路,試圖将頂層函數拆分成 多個子任務
- 将和任務相關的 代碼段、變量生命 進行 聚類歸攏,根據依賴調整 代碼順序
- 将 各個子任務 抽取成 單獨的函數,減少 頂層函數 的複雜性
- 對于 邏輯仍然複雜 的 子任務,可以進一步細化,并利用以上原則(結合重載)繼續剝離抽取
- 對于 代碼複雜性 和 内聚性 本身比較高,代碼可能 複用 的代碼,抽取成單獨的 類檔案
- 對于單獨抽取 類檔案 或者 方法 後仍然複雜的代碼,可以考慮引入 設計模式 進行 橫向擴充 或 曲線救國。