跳轉到內容

計算機程式設計/編碼風格/最小化巢狀

來自 Wikibooks,開放世界中的開放書籍

深度巢狀的程式碼是 結構化程式設計 的常見特徵。雖然它有一些優點,在該部分中討論,但它經常被認為難以閱讀,並且是一種反模式:“扁平優於巢狀”。[1]

具體來說,巢狀的控制流 - 條件塊(if)或迴圈(for,while) - 在超過三層巢狀時難以理解,[2][3] 並且具有很高的圈複雜度。這被稱為“危險的深度巢狀”[3] 或,在巢狀 if 語句的情況下,被稱為“箭頭反模式”,因為它的形狀如下

 if
   if
     if
       if
         do something
       endif
     endif
   endif
 endif

這有幾個問題

  • 程式碼難以閱讀。
  • 由於多級縮排,上下文難以理解。
  • 清理 發生在垂直距離原始原因很遠的地方:如果資源是在頂部的縮排級別中獲取的(例如,分配記憶體,開啟檔案),則清理發生在相同的縮排級別,但在底部,垂直距離很遠。

除了重構或避免此程式碼外,處理深度巢狀程式碼的一種技術是在編輯器中進行程式碼摺疊 - 這允許您摺疊一個塊,從而產生抽象,並允許您輕鬆地檢視周圍的程式碼,而無需檢視中間程式碼(因此資源獲取和清理都是可見的)。

解決方案

[編輯 | 編輯原始碼]

解決方案包括以下內容。[4]

將塊重構為單獨的函式。

[編輯 | 編輯原始碼]

這在迴圈體中尤其常見。

合併測試

[編輯 | 編輯原始碼]

如果幾個 if 子句只是測試(沒有任何中間程式碼),那麼這些子句可以合併成一個測試。比較

if a:
    if b:
        ...

if a and b:
    ...

使用布林短路行內函數呼叫

[編輯 | 編輯原始碼]

如果 if 子句的唯一主體是執行測試的函式呼叫和賦值,然後是另一個 if 子句,在像 C 這樣的語言中,賦值是表示式(有值)並且布林表示式是短路,這些可以合併

if (a) {
    int b = f();
    if (b) {
        ...
    }
}
if (a && int b = f()) {
    ...
}

輔助變數或函式

[編輯 | 編輯原始碼]

輔助變數 在程式碼中內聯複雜表示式時很有用,特別是布林表示式或匿名函式。[5] 使用輔助表示式既減少了巢狀,因為它不再包含在另一個表示式中,變數名稱也記錄了表示式的含義。對於複雜的布林表示式,另一種選擇是使用一個單獨的函式來呼叫,而不是使用輔助變數。

提前返回

[編輯 | 編輯原始碼]

最重要的解決方案是提前返回,它有幾種形式,特別是守衛子句。[4] 避免巢狀的控制流是非區域性控制的基本原因,特別是:return(值)、raise(異常)、continue 和 break。一種常見的模式是用 if-then 或巢狀的 if ifs 替換 if not/return-continue(如果是一個函式,則返回/引發,如果是一個迴圈體,則繼續/中斷)。

比較

if a:
    ...
    if b:
      ...
      ...

if not a:
    return
...
if not b:
    return
...
...

類似地,比較

for i in l:
    if a:
        ...
        if b:
            ...
            ...

for i in l:
    if not a:
        continue
    ...
    if not b:
        continue
    ...
    ...

這減少了巢狀,使流程更加線性 - 要麼繼續執行程式碼塊,要麼返回/繼續。

這種模式被稱為“守衛子句”,當檢查出現在程式碼開頭並檢查先決條件時。但是,它更廣泛地用於在工作完成或計算出值後立即完成處理並返回:[3]

“當返回增強可讀性時使用它:在某些例程中,一旦您知道答案,您就希望立即將其返回給呼叫例程。”

但是,提前返回可能會令人困惑並容易出錯,特別是由於 清理 問題,並且違反了 結構化程式設計 的核心原則,即每個例程只有一個出口點。[3]

“最小化每個例程中的返回次數:當在底部閱讀例程時,您不知道它在上面某個地方返回的可能性,這會讓您難以理解例程。因此,謹慎使用返回 - 只有在它們提高可讀性時才使用。”

在沒有清理的情況下 - 當函式只是計算值或產生副作用時 - 提前返回的問題較少。在有清理的情況下,一些語言提供了即使有返回也能簡化清理的功能(例如“finally”子句、Unix 或 Python 中的“atexit”或 Go 中的“defer”)。另一種選擇是在清理子句之後保留一個在結尾的出口點,而不是提前返回,而是跳到(goto)清理子句。

在複雜巢狀的情況下 - 巢狀的 if/then/else 語句或給定級別上的多個 if 語句 - 邏輯通常只是多個互斥條件,這可以透過依次測試每個條件來處理,如果相關則執行程式碼,然後返回或使用 elif,允許一個扁平的結構,並使完整的條件清晰。

比較

if a:
    if b:
        f()
    else:
        g()
else:
    if b:
        h()
    else:
        i()

if a and b:
    f()
    return
if a and not b:
    g()
    return
if not a and b:
    h()
    return
if not a and not b:
    i()
    return

if a and b:
    f()
elif a and not b:
    g()
elif not a and b:
    h()
elif not a and not b:
    i()

替代控制結構

[編輯 | 編輯原始碼]

提前返回具有許多其他控制結構的風格變體,特別是在消除 else 語句方面。

在 return 後省略 else[6]

比較

if a:
    return ...
else:
    return ...

if a:
    return ...
return ...

比較

if a:
    return ...
elif b:
    return ...
else:
    return ...

if a:
    return ...
if b:
    return ...
return ...
使用 switch

在具有 switch 語句的語言中,這可以替換多路條件。

switch (x) {
case a:    
    return ...
case b:
    return ...
default:
    return ...
}
使用 elseif

一些語言有 elseif 語句(elsif,elif)來減少 else 子句中 if 子句的巢狀,其功能類似於 switch:比較

if a:
    return ...
else:
    if b:
        return ...
    else:
        return ...

if a:
    return ...
elif b:
    return ...
else:
    return ...

if a:
    return ...
elif b:
    return ...
return ...

巢狀迴圈

[編輯 | 編輯原始碼]

巢狀迴圈對於多維資料是自然的,但是對於一維資料的順序處理,巢狀迴圈通常是不自然的,可以被更扁平的結構取代。

順序迴圈

[編輯 | 編輯原始碼]

當透過首先對某些資料執行一項操作,然後切換到不同的狀態並處理其餘資料來處理一系列資料時,會出現一個更微妙的問題。可以透過巢狀迴圈來完成此操作,但更自然的是中斷迴圈,然後在單獨的迴圈中繼續。

在許多語言(如 C)中,這是透過在兩個迴圈之間共享輔助索引變數來完成的。

比較

for (int i = 0; i < n; i++) {
    foo(a[i]);
    if (...) {
        for (int j = i; j < n; j++) {
            bar(a[j])
        }
        break;
    }
}

int i = 0;
for (; i < n; i++) {
    foo(a[i]);
    if (...)
        break;
}
for (; i < n; i++) {
    bar(a[i])
}

這更清晰地顯示了順序流,並避免了巢狀。

在像 Python 這樣的實現迭代器的語言中,這可以在沒有輔助變數的情況下完成,因為索引狀態包含在迭代器中。

l = iter(a)
for x in l:
    foo(x)
    if ...:
        break
for x in l:
    bar(x)

迴圈之間的切換

[編輯 | 編輯原始碼]

一個更復雜的例子是,當你想在兩種處理資料的方式之間來回切換時,比如遍歷整個字串與在單詞中遍歷。一般來說,最優雅的解決方案是透過相互遞迴的協程(帶尾呼叫)來實現,在共享迭代器(或索引變數)上執行,不過在沒有協程的語言中,這可以透過狀態機來實現,或者有時透過相互遞迴的子程式。

在更簡單的案例中,如果存在主迴圈和輔助迴圈(比如遍歷字串,輔助操作其單詞),則存在自然巢狀結構。在這種情況下,只需將輔助迴圈分解為單獨的函式就足以消除巢狀。比較

for (int i = 0; i < n; i++) {
    foo(a[i]);
    while (...) {
        ...
    }
}

for (int i = 0; i < n; i++) {
    foo(a[i]);
    other(a, &i, n);
}

void other(char *a, int *i, int n) {
    ...
}

模組複雜性

[編輯 | 編輯原始碼]

雖然單個函式中深度巢狀的程式碼不可取,但擁有獨立的模組、函式和巢狀函式是模組化的一種重要形式,特別是由於限制了範圍。一般來說,最好讓模組中的所有函式都彼此相關(為了凝聚力),這有利於高水平的分解和獨立模組。也就是說,這可能會增加模組的複雜性,特別是在極端情況下,比如 Java,它限制每個檔案只能有一個公共頂層類。

巢狀函式

[編輯 | 編輯原始碼]

一些語言,比如 Haskell、Kotlin、Pascal、Python 或 Scala,允許將輔助函式宣告為**巢狀函式**。輔助函式在另一個外部值或函式的體中宣告。然後,輔助函式的範圍被限制在外部函式的體中。巢狀函式的一些優點如下

  • 透過在外部函式內宣告,內部函式的體可以引用其封閉範圍內的任何方法/函式/變數宣告。這減少了在函式中宣告引數以及來回傳遞這些引數的必要性。
  • 因此,內部函式隱藏在外部函式的實現中,無需將其暴露在外部。

比較

def foo():
    def foo_helper():
        ...

    ...
    foo_helper()
    ...

到(注意顯式引數傳遞)

def foo():
    ...
    _foo_helper(x)
    ...
 
def _foo_helper(x):
    ...

然而,程式設計師可能也更喜歡宣告一個可以巢狀在內部函式外部和主範圍內的函式,即使這意味著在宣告和呼叫中新增額外的引數。他/她可能會選擇這樣做的原因如下

  • 巢狀函式最有用的是可以訪問封閉狀態,而不是因為它們自己的範圍受到限制。如果巢狀函式不依賴或使用封閉範圍內的大多數變數或函式,最好將其宣告為頂層私有函式。
  • 輔助函式可能太長,因此將其作為一個獨立的私有頂層函式可能更清楚。
  • 內部函式可能實際上包含一個特別棘手的演算法過程,開發人員可能更喜歡單獨進行單元測試。
  • 如果外部函式可以訪問可變資料或狀態,他/她可能希望明確說明在何處以及何時可以修改它們。將輔助函式移出這些變數的範圍可以清楚地表明它不會修改這些變數,並且還可以減少可以修改這些變數的程式碼的長度。

參考資料

[編輯 | 編輯原始碼]
  1. Python 之禪
  2. 諾姆·喬姆斯基和傑拉爾德·溫伯格(1986)
  3. a b c d 程式碼大全史蒂夫·麥康奈爾
  4. a b 扁平化箭頭程式碼”,編碼恐怖:程式設計與人因學,傑夫·阿特伍德,2006 年 1 月 10 日
  5. 減少程式碼巢狀”,埃裡克·弗洛倫扎諾的部落格,2012 年 1 月 2 日
  6. 重構,馬丁·福勒
華夏公益教科書