天天看點

代碼整潔之道,好的代碼就是為了更美好的生活!

作者:是啊超ya

概述

美國童子軍有一條簡單的軍規:讓營地比你來時更幹淨。當梳理代碼時,堅守此軍規:每次 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. 拆分複雜的函數

如果有很難讀的代碼,嘗試把它所做的 所有任務列出來。其中 一些任務 可以很容易地變成 單獨的函數(或類)。其他的可以簡單地成為一個函數中的邏輯 “段落”。

  • 檢查函數的 命名 是否 名副其實,梳理函數的思路,試圖将頂層函數拆分成 多個子任務
  • 将和任務相關的 代碼段、變量生命 進行 聚類歸攏,根據依賴調整 代碼順序
  • 将 各個子任務 抽取成 單獨的函數,減少 頂層函數 的複雜性
  • 對于 邏輯仍然複雜 的 子任務,可以進一步細化,并利用以上原則(結合重載)繼續剝離抽取
  • 對于 代碼複雜性 和 内聚性 本身比較高,代碼可能 複用 的代碼,抽取成單獨的 類檔案
  • 對于單獨抽取 類檔案 或者 方法 後仍然複雜的代碼,可以考慮引入 設計模式 進行 橫向擴充 或 曲線救國。

繼續閱讀