測試與程式碼品質

[程式碼異味 #1] 為什麼義大利麵程式碼是義大利麵?

義大利麵程式碼指的不是長度或髒亂,而是控制流程。本文從 Dijkstra 1968 年反對 goto 的信開始,整理 goto 消失後義大利麵仍然存在的原因,以及如何用循環複雜度衡量。

閱讀 6 分鐘
[程式碼異味 #1] 為什麼義大利麵程式碼是義大利麵? 封面圖

程式碼一團亂時,開發者會說它像義大利麵。但究竟什麼才是義大利麵?

在預定為下一篇文章的 程式碼異味 #2中,我們會接著探討下一個套件。

因為很長?因為很髒亂?

兩者都不是。義大利麵指的是 控制流程。

想用眼睛追蹤執行順序時,流程像麵條一樣糾結,完全不知道會跳到哪裡,這才是它原本的意思。

將控制流程與職責糾結在同一個檔案中的程式碼分離,其過程可在 將關注點分離套用到 800 行元件的案例延續閱讀。

追溯這個詞的根源,會找到一封於 1968 年發表的信。

Dijkstra 寫的一封信

1968 年 3 月,Edsger Dijkstra 在 ACM(Association for Computing Machinery,美國計算機學會)期刊上發表了一篇短文(原文)。

標題是「Go To Statement Considered Harmful」。內文是一封兩頁的信。

Dijkstra 原本取的標題是「A Case Against the Go To Statement」。現在這個挑釁性的標題,是當時的編輯 Niklaus Wirth 改的。

這種標題格式後來固定成「○○ Considered Harmful」的慣用語,可見編輯的影響相當深遠。

主張的核心是:人會閱讀靜態程式碼,並在腦中描繪動態的執行過程。

但在 goto很多的程式碼中,只知道「目前正在執行這一行」並不足以說明程式狀態,因為不知道是從哪裡跳過來的。

只使用循序執行、條件分支與重複的程式碼則不同。

你可以像描述座標一樣說明執行位置,例如「外層迴圈第三次,內層 if 的 true 分支」。

goto會摧毀這個座標系。

這個主張也有理論依據。早兩年的 1966 年,Corrado Böhm 與 Giuseppe Jacopini 證明只用循序、選擇與重複三種結構,就能表達任何程式(論文)。

也就是說,即使沒有 goto,數學上也保證可行。

goto 畫出的圖

看看當時的程式碼長什麼樣子,就能感受到這件事。

10 IF X > 100 THEN GOTO 70
20 IF X < 0 THEN GOTO 90
30 Y = X * 2
40 IF Y > 50 THEN GOTO 70
50 PRINT Y
60 GOTO 100
70 PRINT "TOO BIG"
80 GOTO 100
90 PRINT "NEGATIVE"
100 END

即使只有十行,也得把流程畫在紙上才能理解。每個 GOTO是一條線,而這些線會上下交叉。

增加到 500 行後會怎樣,不難想像。

1970~80 年代的 BASIC 與早期 Fortran 程式碼確實如此,而看到那種糾結的程式碼後出現的稱呼就是義大利麵。

GOTO 跳躍糾結的流程圖,與只使用循序和分支的結構化流程圖比較
左側可以用座標描述執行位置,右側則不行

goto 消失了,義大利麵卻留下來

奇怪的事情就在這裡發生了。現代程式碼中幾乎沒有 goto。

Swift 根本沒有它。即使是有它的語言,用途也縮小到像是回到資源清理位置的慣用寫法。

然而「義大利麵程式碼」這個詞反而更常被使用。

因為 goto不是原因,而只是症狀之一。

真正的問題是讀者無法預測流程,而現代程式碼還有許多其他方式會造成這種情況。

**巢狀地獄。**if 裡面有 if,裡面有閉包,閉包裡又有 if。

縮排像箭頭一樣越來越深,因此被稱為毀滅金字塔。它不像 goto那樣跳來跳去,但同樣難以追蹤每種條件組合會到達哪一行。

**回呼地獄。**用回呼串接非同步工作時,執行順序會與程式碼順序錯開。

當從上到下閱讀不再等於執行順序時,座標系又崩潰了。

// 無法按照程式碼順序閱讀執行順序
loadUser(id) { user in
    loadProfile(user) { profile in
        loadPosts(profile) { posts in
            DispatchQueue.main.async {
                self.render(posts)   // 錯誤處理應該放在哪一層?
            }
        }
    }
}

**全域狀態。**如果有任何函式都能隨時修改的變數,就必須翻遍整個專案,追蹤它的值為何變成現在這樣。

這也是 singleton 經常受到批評的原因。

**事件湯。**A 發出通知,B 接收後修改狀態,觀察該狀態的 C 又發出通知。

每個片段都短小整潔,但整體流程完全沒有記錄在任何地方。

這是反應式程式碼常見的形式,在某些方面比 goto更難追蹤,因為跳躍目的地沒有寫在程式碼中。

標示循環複雜度獨立路徑的控制流程圖與臨界值量表圖片
複雜度數字不是答案,而是告訴你該看哪裡的訊號

可以用數字衡量嗎

「這段程式碼像義大利麵」是主觀說法。因此 1976 年 Thomas McCabe 提出了循環複雜度(Cyclomatic Complexity)這項指標(論文)。

計算很簡單:把程式碼的控制流程畫成圖,再計算有幾條獨立路徑。

實務上會以分支點數量加 1 來近似。大致可視為每個 if、for、while、case、&&、??各增加 1。

有個慣例是,數值超過 10 就該拆分函式了。McCabe 本人也將這個數字定位為合理上限,而非絕對標準。

實際上,只用一個 switch列出 20 種案例的函式,複雜度可能超過 20,卻仍然容易閱讀。

SwiftLint 的 cyclomatic_complexity規則會測量類似的數值。

不過 SwiftLint 只計算 if、guard、for、while、repeat、case、catch,不計算 &&或??。預設警告標準是 10。

在專案中開啟後,默默膨脹的函式總有一天會被抓出來。

Knuth 的反駁

如果把這個故事收尾成「Dijkstra 贏了」,就只了解了一半。

1974 年,Donald Knuth 以一篇名為「Structured Programming with go to Statements」的長篇論文提出反駁(論文)。

重點是,goto本身並不是邪惡的。在一次跳出巢狀迴圈等情況下,使用goto反而能讓流程更清楚。

為了硬要移除它而建立布林旗標變數,再堆疊條件判斷,反而會產生更糟的程式碼。

現代語言得出的結論接近折衷方案:移除無限制跳躍,但為常見模式提供專用語法。

break、continue、加上標籤的 break label,以及 Swift 的 defer與guard都是如此。

// guard: 從上方排除例外情況,讓主體保持扁平
func process(_ data: Data?) throws -> Packet {
    guard let data else { throw ParseError.empty }
    guard data.count > headerSize else { throw ParseError.tooShort }
    // 從這裡開始,所有條件都已整理完成
    return try decode(data)
}

guard接近於 C 時代的 goto cleanup模式定型為語言功能。也就是固定跳躍目的地並替它命名。

總結

  • 義大利麵程式碼不是髒亂的程式碼,而是無法預測控制流程的程式碼。
  • 起點是 Dijkstra 1968 年的信,核心論據是「應該能用座標描述執行位置」。
  • goto雖然消失了,但深層巢狀、回呼巢狀、全域狀態與事件鏈仍會重新造成相同問題。
  • 可以用循環複雜度大致衡量,而 10 左右是常見的警告線。
  • 如 Knuth 的反駁所示,目標不是goto的根除,而是讓流程可預測。guard與async/await是由語言代為守護這個目標的機制。

下一篇將探討朝完全相反方向損壞的程式碼。流程非常整齊,但只要新增一個值,就得修改七個檔案。

這就是千層麵程式碼。

延伸閱讀

來源與驗證