測試與程式碼品質

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

義大利麵程式碼指的不是長度或髒亂,而是控制流程。本文從 1968 年 Dijkstra 反對 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 只計算ifguardforwhilerepeatcasecatch,不計算&&??。預設警告標準是 10。

在專案中啟用後,那些默默變大的函式總有一天會被抓出來。

Knuth 的反駁

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

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

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

如果為了強行移除它而建立 Boolean 旗標變數,再堆上條件式,結果會變成更糟的程式碼。

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

breakcontinue、加上標籤的break label,以及 Swift 的deferguard都是例子。

// 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模式正式成為語言功能。也就是固定跳躍目的地,並替它命名。

總結

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

下一篇來看反方向壞掉的程式碼。流程非常整齊,但只要新增一個值,就得修改七個檔案。

這就是千層麵程式碼。

延伸閱讀

來源與驗證