如何無痛降低 if else 麵條程式碼複雜度

doodlewind發表於2017-10-10

相信不少同學在維護老專案時,都遇到過在深深的 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-before
if-if-before

它通過從上到下巢狀的 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-before
else-if-before

else if 最終只會走入其中的某一個分支,因此並不會出現上面組合爆炸的情形。但是,在深度巢狀時,複雜度同樣不低。假設巢狀 3 層,每層存在 3 個 else if,那麼這時就會出現 3 ^ 3 = 27 個出口。如果每種出口對應一種處理資料的方式,那麼一個函式內封裝這麼多邏輯,也顯然是違背單一職責原則的。並且,上述兩種型別可以無縫組合,進一步增加複雜度,降低可讀性。

但為什麼在這個有了各種先進的框架和類庫的時代,還是經常會出現這樣的程式碼呢?個人的觀點是,複用的模組確實能夠讓我們少寫【模板程式碼】,但業務本身無論再怎麼封裝,也是需要開發者去編寫邏輯的。而即便是簡單的 if else,也能讓控制流的複雜度指數級上升。從這個角度上說,如果沒有基本的程式設計素養,不論速成掌握再優秀的框架與類庫,同樣會把專案寫得一團糟

重構策略

上文中,我們已經討論了麵條程式碼的兩種型別,並量化地論證了它們是如何讓控制流複雜度指數級激增的。然而,在現代的程式語言中,這種複雜度其實是完全可控的。下面分幾種情形,列出改善麵條程式碼的程式設計技巧。

基本情形

對看起來複雜度增長最快的 if...if 型麵條程式碼,通過基本的函式即可將其拆分。下圖中每個綠框代表拆分出的一個新函式:

if-if-after
if-if-after

由於現代程式語言摒棄了 goto,因此不論控制流再複雜,函式體內程式碼的執行順序也都是從上而下的。因此,我們完全有能力在不改變控制流邏輯的前提下,將一個單體的大函式,自上而下拆逐步分為多個小函式,而後逐個呼叫之。這是有經驗的同學經常使用的技巧,具體程式碼實現在此不做贅述了。

需要注意的是,這種做法中所謂的不改變控制流邏輯,意味著改動並不需要更改業務邏輯的執行方式,只是簡單地【把程式碼移出去,然後用函式包一層】而已。有些同學可能會認為這種方式治標不治本,不過是把一大段麵條切成了幾小段,並沒有本質的區別。

然而真的是這樣嗎?通過這種方式,我們能夠把一個有 64 種狀態的大函式,拆分為 6 個只返回 2 種不同狀態的小函式,以及一個逐個呼叫它們的 main 函式。這樣一來,每個函式複雜度的增長速度,就從指數級降低到了線性級

這樣一來,我們就解決了 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-lookup
else-if-lookup

對於先天支援反射的指令碼語言來說,這也算是較為 trivial 的技巧了。但對於更復雜的 else if 條件,這種方式會重新把控制流的複雜度集中到處理【該走哪個分支】問題的 determineAction 中。有沒有更好的處理方式呢?

職責鏈模式

在上文中,查詢表是用鍵值對實現的,對於每個分支都是 else if (x === 'foo') 這樣簡單判斷的情形時,'foo' 就可以作為重構後集合的鍵了。但如果每個 else if 分支都包含了複雜的條件判斷,且其對執行的先後順序有所要求,那麼我們可以用職責鏈模式來更好地重構這樣的邏輯。

else if 而言,注意到每個分支其實是從上到下依次判斷,最後僅走入其中一個的。這就意味著,我們可以通過儲存【判定規則】的陣列,來實現這種行為。如果規則匹配,那麼就執行這條規則對應的分支。我們把這樣的陣列稱為【職責鏈】,這種模式下的執行流程如下圖:

else-if-chain
else-if-chain

在程式碼實現上,我們可以通過一個職責鏈陣列來定義與 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 中的每一項都具有 matchaction 屬性。這時我們可以將原有函式的 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 邏輯的拆分了。

總結

麵條程式碼其實容易出現在不加思考的【糙快猛】式開發中。很多簡單粗暴地【在這裡加個 if,在那裡多個 return】的 bug 修復方式,再加上註釋的匱乏,很容易讓程式碼可讀性越來越差,複雜度越來越高。

但解決這個問題的幾種方案都並不複雜。這些示例之所以簡單,本質上是因為高階程式語言強大的表達能力已經能夠不依賴於各種模式的模板程式碼,為需求提供直接的語義支援,而無需套用各種設計模式的八股文。

當然,你可以用模式來概括一些降低業務邏輯複雜度的技巧,但如果生搬硬套地記憶並使用模式,同樣可能會走進過度設計的歧途。在實現常見業務功能時,掌握好程式語言,梳理好需求,用最簡單的程式碼將其實現,就已經是最優解了。

相關文章