測試與程式碼品質

[程式碼異味 #3] Ravioli 程式碼,沒人知道流程的程式碼

函式全都只有五行、各自也只有一項職責,卻沒人能回答按下按鈕後會發生什麼事,這就是 Ravioli 程式碼。本文整理過度拆分的原因、跳轉次數所反映的成本,以及找回流程的方法。

閱讀 5 分鐘
[程式碼異味 #3] Ravioli 程式碼,沒人知道流程的程式碼 封面圖

程式碼審查時,常會遇到這種情況:打開檔案後,所有函式都在五行以內。

這是上一篇文章 程式碼異味 #2 的延續。

命名也很恰當,每個類別都只有一項職責。沒有什麼好挑剔的。

但當有人問「按下這個按鈕會發生什麼事?」時,卻沒人能立刻回答。

這類程式碼稱為 Ravioli Code(C2 原文)。

不只有負面意義

有趣的是,這個詞不總是用來批評。Ravioli 是由一個個小塊麵皮完整包住內餡的義大利麵。

它正是封裝良好的小型物件,也就是物件導向所追求的樣貌。實際上,也有人把 Ravioli 視為 Spaghetti 程式碼的反面。

問題在數量。

單一小塊都很完美,但盤子裡有 200 個,彼此以什麼順序、如何串接,卻沒有任何地方記載。

具體來說,會是這種樣子。

final class CheckoutCoordinator {
    func start() { validator.validate(cart) }
}

final class CartValidator {
    func validate(_ cart: Cart) { stockChecker.check(cart.items) }
}

final class StockChecker {
    func check(_ items: [Item]) { priceCalculator.calculate(items) }
}

final class PriceCalculator {
    func calculate(_ items: [Item]) { paymentPreparer.prepare(items) }
}
// ... 還有 16 個這樣的類別

每個類別都無可挑剔。命名精確,工作也只有一項。

但沒有任何檔案了解完整的付款流程。

流程只存在於類別之間的呼叫關係中,不是讀程式碼就能知道,而是必須接上除錯器。

為什麼會拆得過細

把「函式越短越好」當成規則時。《Clean Code》甚至主張函式應只有兩、三行,最長也只能四行。

這項建議真正要達成的,其實是讓一個函式只處理一個抽象層級。但當它被換算成行數,目的就消失了。

結果是 30 個五行函式,其中 28 個只在一個地方被呼叫。

名稱無法摘要內容時。、handleUserAction、processData、updateState這類名稱,無法告訴你它們做了什麼。

拆成這種命名的函式後,讀者終究得打開函式本體,而且拆得越多,要打開的地方就越多。

好的抽取應該讓人不必查看本體,結果反而適得其反。

**抽象層級混在一起時。**同一個函式裡,同時出現「驗證訂單」這種政策層級的句子,以及「將索引加一」這種細節層級的句子。讀者的視線就會不斷上下移動。

在這種狀態下貿然拆分,只會產生抽象層級混亂的片段。

**為了預備重用而抽出只使用一次的程式碼時。**這和製造 Lasagna 程式碼是同一種心理,只是方向從垂直改成水平。

鏈狀串接的 Ravioli 類別結構與協調器函式結構比較圖
只要設置一個記錄流程的位置,就能解決大部分問題

成本會以跳轉次數呈現

Ravioli 程式碼的成本可用一句話概括:為了回答一個問題,要打開幾次檔案?

讀程式碼時,人會把流程暫存在短期記憶中。超過三、四個步驟後,這份記憶就會變模糊。

在檔案之間跳轉六次後,一開始想找什麼會變得模糊,只好回到起點。

反覆發生後,直接執行確認反而比閱讀程式碼更快。

副作用也會隨之而來。

  • 加入新功能時找不到既有片段,只好再做一個相似的。重複內容悄悄增加。
  • 找不到修正錯誤的位置,只好在流程末端,也就是畫面端,加入臨時處理。
  • 完整流程沒有寫在程式碼裡,只存在文件或某人的腦中。那個人離開團隊後,流程也會一併消失。

找回流程的方法

**建立一個記錄流程的位置。**這是最有效的措施。

放置一個能一眼看見完整順序的函式,讓它只描述順序。

func checkout(_ cart: Cart) async throws -> Receipt {
    try validate(cart)
    try await reserveStock(cart.items)
    let amount = calculateTotal(cart)
    let payment = try await charge(amount)
    return try await confirm(cart, payment)
}

細節仍留在各自的位置。改變之處只是流程被寫在同一個畫面上。

這類函式也稱為協調器。Ravioli 程式碼缺少的正是這個位置。

**每一層只放一個抽象層級。**上方函式的五行都是同一高度的句子。若混入items.count > 0這類細節條件,抽象層級就會被破壞。

讀完一個函式後,確認句子是否處於同一個視線高度,比遵守拆分標準更有用。

**是否抽取由名稱決定。**標準不是行數,而是這個問題:「為這個片段取的名稱,是否比本體透露更多資訊?」

把if user.age >= 19抽成isAdult(user)能顯示意圖,因此有益。把array.append(item)抽成addItem則毫無收穫。

**相近的東西就放在一起。**會一起變動的程式碼,放在同一檔案、同一資料夾。

若為了遵守一個檔案一個型別的慣例,反而讓總是一起開啟的型別分散,鄰近性比慣例更重要。

**服務單位也適用相同標準。**過度切分微服務的狀態稱為奈米服務。

處理一個請求卻要經過 12 個服務,是 Ravioli 程式碼延伸到網路另一端的形式。此時,跳轉成本還會加上延遲與故障點。

並列比較 Spaghetti、Lasagna、Ravioli 三種程式碼異味結構的圖片
三者在片段層級都拿滿分

Lasagna、Ravioli,以及 Spaghetti

把三者放在一起,可以整理如下。

形式 片段狀態 問題
Spaghetti 龐大且糾結 無法預測執行流程
Lasagna 垂直分層 一次變更就得貫穿多個層次
Ravioli 細小且整潔 片段之間的流程無處可尋

三者的掌握整體的成本都很高,只是方式不同。

若只用每個片段的美感衡量程式碼品質,就看不出 Lasagna 與 Ravioli。因為兩者在片段層級都拿滿分。

總結

  • Ravioli 程式碼是小而整潔的片段過多,導致沒人能說明整體流程的程式碼。
  • 它也曾被用來正面指稱封裝良好的程式碼。問題出在數量。
  • 主要原因是行數規則、模糊的命名,以及混雜的抽象層級。
  • 解法的核心,是設置記錄流程的位置。
  • 抽取標準不是長度,而是名稱。只有名稱能比本體說明更多時才抽出。

下一篇是完全相反的極端:不是片段太多,而是只有一個。

我們來看看一個類別就了解整個 App 的 God Object。

來源與確認標準

  • Ravioli Code — C2 Wiki · 原作者文章 · 確認 2026-08-17 · 依據:小型且封裝的物件過度分割成 Ravioli 程式碼的比喻

延伸閱讀