擺脫 if-else 束縛的清潔編碼之旅:這些技術幫你實現
許多學生在維護舊項目時遇到過復雜的業務邏輯嵌套在深層的if-else語句中。面對這樣的亂象,簡單地增量修改通常只會增加復雜性和降低可讀性。那么,有沒有固定的套路可以整理這些代碼呢?這里分享三種簡單而常見的重構方法。
什么是意大利面條代碼?
所謂“意大利面條代碼”在處理復雜業務過程時很常見。它通常具有以下特點:
- 內容冗長
- 結構混亂
- 嵌套深
我們知道,主流編程語言都有函數或方法來組織代碼。對于意大利面條代碼,我們可以將其視為滿足這些特點的函數。根據語言語義的不同,可以將其分為兩種基本類型:
if…if 類型
這種代碼結構看起來像這樣:
function demo(a, b, c) {
if (f(a, b, c)) {
if (g(a, b, c)) {
// ...
}
// ...
if (h(a, b, c)) {
// ...
}
}
if (j(a, b, c)) {
// ...
}
if (k(a, b, c)) {
// ...
}
}
其流程圖如下:
圖片
通過自上而下嵌套 if 語句,單個函數內的控制流不斷增長。不要以為控制流增長時,復雜性只是線性增加。我們知道,函數處理數據,每個 if 內通常都有數據處理邏輯。所以即使沒有嵌套,如果有 3 個這樣的 if 段,那么根據每個 if 是否執行,會有 2 ^ 3 = 8 種可能的數據狀態。如果有 6 段,則會有 2 ^ 6 = 64 種狀態。因此,隨著項目規模的擴大,調試函數變得指數級困難!在數量級上,這與《人月神話》中分享的經驗一致。
else if…else if 類型
這種代碼控制流也很常見,看起來像這樣:
function demo(a, b, c) {
if (f(a, b, c)) {
if (g(a, b, c)) {
// ...
} else if (h(a, b, c)) {
// ...
}
// ...
} else if (j(a, b, c)) {
// ...
} else if (k(a, b, c)) {
// ...
}
}
其流程圖如下:
圖片
else if 最終只會進入其中一個分支,因此不會像前面提到的那樣出現組合爆炸。然而,在深層嵌套中,復雜性依然很高。假設每層嵌套有 3 個 else if 語句,有 3 層嵌套,則會有 3 ^ 3 = 27 種可能的出口。如果每個出口對應一種處理數據的方式,那么在一個函數內封裝這么多邏輯違背了單一職責原則。而且,這兩種類型可以無縫結合,進一步增加復雜性和降低可讀性。
但為什么在框架和庫如此先進的時代,我們仍然經常遇到這樣的代碼?在我看來,可復用的模塊確實幫助我們減少了模板代碼;然而,不管業務邏輯封裝得多好,開發者仍需要編寫它。即使是簡單的 if-else 語句,也能成倍增加控制流的復雜性。從這個角度看,如果沒有基本的編程技巧,不管多快掌握優秀的框架和庫,你可能仍然會寫出混亂的項目。
重構策略
在上文中,我們討論了兩種面條代碼,并定量展示了它們如何成倍增加控制流的復雜性。然而,在現代編程語言中,這種復雜性實際上是完全可控的。以下是列舉的幾種編程技巧來改善面條代碼的場景。
圖片
基本情況
對于 if…if 類型的面條代碼,可以通過基本函數拆分來解決復雜性增長的問題。下圖中每個綠色框代表一個拆分出的新函數:
由于現代編程語言中放棄了 goto,無論控制流多復雜,函數體內代碼的執行順序總是自上而下的。因此,我們完全可以從上到下逐步將單體大函數拆分為多個小函數而不改變控制流邏輯,然后一個個調用它們。這是經驗豐富的同事常用的技巧,具體代碼實現這里不再詳細闡述。
需要注意的是,這種方法中所謂的不改變控制流邏輯是指不需要改變業務邏輯執行的方式,只是將代碼移出去并包裹一層函數。有些同學可能認為這種方法只是治標不治本——它只是將一段長面條切成幾段短面條,沒有本質區別。
但真的是這樣嗎?通過這種方法,我們可以將具有 64 種狀態的大函數拆分為 6 個只返回 2 種狀態的小函數,以及一個逐一調用它們的主函數。這樣,每個函數的復雜性增長率從指數級降低為線性。
通過這種方式,我們解決了 if…if 類型的面條代碼;那么else if…else if類型的呢?
查找表
對于 “else if…else if” 類型的面條代碼,一種最簡單的重構策略是使用所謂的查找表。它以鍵值對的形式封裝每個 else if 中的邏輯:
const rules = {
x: function (a, b, c) { /* ... */ },
y: function (a, b, c) { /* ... */ },
z: function (a, b, c) { /* ... */ }
};
function demo(a, b, c) {
const action = determineAction(a, b, c);
return rules[action](a, b, c);
}
每個 else if 中的邏輯被重寫為一個獨立的函數,然后我們可以按以下方式拆分過程:
圖片
對于本身支持反射的腳本語言來說,這是一個相對簡單的技巧。然而,對于更復雜的 else if 條件,這種方法會將控制流復雜性重新集中到 determineAction 中,確定該走哪個分支。有沒有更好的方法來處理這個問題呢?
責任鏈模式
在上文中,查找表是通過鍵值對實現的。當每個分支是一個簡單判斷時,如 else if (x === ‘foo’),foo 可以作為重構集合的鍵。然而,如果每個 else if 分支包含復雜的條件判斷并且需要特定的執行順序,我們可以使用責任鏈模式來更好地重構這種邏輯。
對于 else if,需要注意每個分支是自上而下判斷的,最終只會執行其中一個。這意味著我們可以通過存儲一個“判斷規則”的數組來實現這種行為。如果一個規則匹配,則執行該規則對應的分支。我們稱這樣的數組為“責任鏈”,其模式下的執行過程如圖所示:
圖片
在代碼實現中,我們可以通過責任鏈數組定義等價于else if的規則。
const rules = [
{
match: function (a, b, c) { /* ... */ },
action: function (a, b, c) { /* ... */ }
},
{
match: function (a, b, c) { /* ... */ },
action: function (a, b, c) { /* ... */ }
},
{
match: function (a, b, c) { /* ... */ },
action: function (a, b, c) { /* ... */ }
}
// ...
]
rules 中的每項都具有 match 和 action 屬性。此時我們可以將原來的 else if 函數重寫為遍歷責任鏈數組:
function demo (a, b, c) {
for (let i = 0; i < rules.length; i++) {
if (rules[i].match(a, b, c)) {
return rules[i].action(a, b, c)
}
}
}
當每個責任被匹配時,原函數將直接返回,這也完全符合 else if 的語義。這樣,我們實現了將復雜的 else if 邏輯拆分為單獨的部分。
結尾
面條代碼往往出現在無腦的“粗暴、快速、猛烈”風格的開發中。許多 bug 修復是通過粗暴地在這里添加一個 if 并在多處返回語句來完成的,再加上缺乏注釋,這很容易導致代碼可讀性降低和復雜性增加。
然而,解決這個問題其實并不復雜。這些示例之所以簡單,基本上是因為強大的高級編程