1. 程式人生 > 實用技巧 >如何優化【if else】的麵條程式碼,我們一起來解決程式碼複雜度問題!

如何優化【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 層,每層存在 3 個else if,那麼這時就會出現 3 ^ 3 = 27 個出口。

如果每種出口對應一種處理資料的方式,那麼一個函式內封裝這麼多邏輯,也顯然是違背單一職責原則的。並且,上述兩種型別可以無縫組合,進一步增加複雜度,降低可讀性。

但為什麼在這個有了各種先進的框架和類庫的時代,還是經常會出現這樣的程式碼呢?

個人的觀點是,複用的模組確實能夠讓我們少寫【模板程式碼】,但業務本身無論再怎麼封裝,也是需要開發者去編寫邏輯的。而即便是簡單的if else,也能讓控制流的複雜度指數級上升。

從這個角度上說,如果沒有基本的程式設計素養,不論速成掌握再優秀的框架與類庫,同樣會把專案寫得一團糟。

重構策略

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

基本情形

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


由於現代程式語言摒棄了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中的邏輯都被改寫為一個獨立的函式,這時我們就能夠將流程按照如下所示的方式拆分了:


對於先天支援反射的指令碼語言來說,這也算是較為trivial的技巧了。但對於更復雜的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邏輯的拆分了。

總結

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

在實現常見業務功能時,掌握好程式語言,梳理好需求,用最簡單的程式碼將其實現,就已經是最優解了。

不管你是轉行也好,初學也罷,進階也可——值得關注進入】的程式設計學習進階俱樂部

涉及到:C語言、C++、windows程式設計、網路程式設計、QT介面開發、Linux程式設計、遊戲程式設計、黑客等等......


一個活躍、高格調、高層次的程式設計師程式設計學習殿堂;程式設計入門只是順帶,思維的提高才有價值!