iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
自我挑戰組

程式碼門診:診斷壞味道、開出重構處方系列 第 21 篇

打結的麵條沒人想吃 - 義大利麵條式程式碼 (Spaghetti Code)

  • 分享至 

  • xImage
  •  

簡單介紹

想像你從抽屜裡拿出一副收了三個月的有線耳機:明明放進去的時候好好的,拿出來卻打了七八個結。你想解開其中一個結,結果拉扯之間,另外三個結纏得更緊了。最後你花在解結的時間,比聽音樂的時間還長。

程式碼也會打結。在 Day 20 我們談過過長函式(Long Method),並用 Extract Function(提取函式) 把冗長的結帳流程拆成一個個小步驟。但「長」只是災難的第一階段——當函式不只長,控制流程還層層纏繞、互相糾結時,它就進化成了程式界最經典的反模式:義大利麵條式程式碼(Spaghetti Code)。

根據 Wikipedia 的說法,義大利麵條式程式碼指的是控制流程錯綜複雜、難以理解的原始碼——程式的執行路徑扭來繞去,就像一碗煮熟的義大利麵,彼此纏繞、難分難解。

這個詞的歷史比很多人想像的還悠久。早在 1972 年,IBM 的 Martin Hopkins 就在討論 GOTO 語句時寫道:希望消除 GOTO 之後,「產出的程式不會看起來像一碗義大利麵」。而更早的 1968 年,Dijkstra 那封著名的《Go To Statement Considered Harmful》公開信,正是對這種「想跳哪就跳哪」的控制流程宣戰——後來的結構化程式設計運動,很大程度上就是為了終結這碗麵而生的。

值得一提的是,現代的程式語言早就不流行 GOTO 了,但義大利麵並沒有絕種,它只是換了一種煮法:深層巢狀的 if/for。每往裡面多包一層 if,讀程式的人腦中就要多記一個「我現在在哪個 if 裡面」;包到第五層時,讀者得同時記住五個條件狀態才能理解最裡面那一行在做什麼——這已經不是閱讀,是走迷宮還要背下來時路的每一個轉彎。

根據 Wikibooks 的〈Minimize nesting〉條目的說法,巢狀超過三層的程式碼就會明顯難以閱讀,縮排會在編輯器裡排出一個不斷向右戳的箭頭形狀,也就是所謂的箭頭反模式(Arrow Anti-Pattern)——我們在 Day 14 談快速失敗時,已經跟這個箭頭打過一次照面了。

接下來我們透過一個電商會員折扣的例子,看看一碗五層巢狀的麵是怎麼煮出來的,再示範怎麼把它解開。

TypeScript 不好的範例

需求聽起來很平常:計算訂單的最終金額,折扣規則跟會員等級、節日檔期、消費金額、是否首購、優惠券都有關係。於是程式碼就這樣「一個條件一層 if」地長了出來:

// 不好的範例:五層巢狀的會員折扣計算,縮排一路推到螢幕右邊

interface Member {
  name: string;
  level: 'bronze' | 'silver' | 'gold';
  isFirstPurchase: boolean;
}

interface Coupon {
  code: string;
  discountAmount: number; // 折抵金額
  expiredAt: Date;
}

interface DiscountContext {
  member: Member | null; // 非會員為 null
  amount: number;        // 消費金額
  purchaseDate: Date;
  coupons: Coupon[];     // 使用者持有的優惠券
}

class DiscountService {
  calculateFinalPrice(context: DiscountContext): number {
    let finalPrice = context.amount;

    if (context.member !== null) {                        // 第 1 層:是否為會員
      if (context.member.level === 'gold') {              // 第 2 層:會員等級
        const month = context.purchaseDate.getMonth() + 1;
        if (month === 11 || month === 12) {               // 第 3 層:節日檔期(11、12 是魔術數字!)
          if (context.amount >= 3000) {                   // 第 4 層:是否滿額
            if (context.member.isFirstPurchase) {         // 第 5 層:是否首購
              // 問題一:主要邏輯被埋在第五層縮排的最深處
              // 讀到這一行時,腦中必須同時記住上面五個條件
              finalPrice = context.amount * 0.7;
            } else {
              finalPrice = context.amount * 0.75;
            }
          } else {
            finalPrice = context.amount * 0.85;
          }
        } else {
          if (context.amount >= 3000) {                   // 問題二:滿額判斷第二次出現
            finalPrice = context.amount * 0.85;
          } else {
            finalPrice = context.amount * 0.9;
          }
        }
      } else if (context.member.level === 'silver') {
        // 問題三:節日判斷的邏輯整段複製了一份
        // 若日後檔期改成 10 ~ 12 月,這裡很容易被漏改
        const month = context.purchaseDate.getMonth() + 1;
        if (month === 11 || month === 12) {
          if (context.amount >= 3000) {                   // 滿額判斷第三次出現!
            finalPrice = context.amount * 0.85;
          } else {
            finalPrice = context.amount * 0.9;
          }
        } else {
          finalPrice = context.amount * 0.95;
        }
      } else {
        // 問題四:bronze 會員完全沒有節日與滿額判斷
        // 是刻意的商業規則,還是寫到這裡時忘了?沒有人說得準
        finalPrice = context.amount * 0.98;
      }

      // 優惠券折抵
      for (const coupon of context.coupons) {             // for 迴圈再往裡鑽
        if (coupon.expiredAt.getTime() >= context.purchaseDate.getTime()) {
          if (finalPrice - coupon.discountAmount >= 0) {  // 迴圈裡又包了兩層 if
            finalPrice = finalPrice - coupon.discountAmount;
          }
        }
      }
    }

    return finalPrice;
  }
}

// 使用範例——看起來能動,但你敢改嗎?
const service = new DiscountService();

console.log(
  service.calculateFinalPrice({
    member: null,
    amount: 1000,
    purchaseDate: new Date('2026-07-16'),
    coupons: [
      { code: 'SUMMER50', discountAmount: 50, expiredAt: new Date('2026-08-31') },
    ],
  })
); // 輸出:1000——咦?優惠券沒有生效!
// 因為折抵邏輯被包在「是會員」的 if 裡面,非會員的優惠券永遠無效

問題分析

這段程式碼就是一碗標準的義大利麵,我們一條一條把麵挑出來看:

  1. 主邏輯被推到螢幕最右邊:finalPrice = context.amount * 0.7 這行核心邏輯縮排了五層。讀到它時,我們必須在腦中同時維護「是會員、是金卡、是節日、有滿額、是首購」五個條件狀態——每一層巢狀都是一筆記憶體開銷,只是消耗的是讀者的腦,不是機器的記憶體
  2. 相同邏輯到處複製:「是不是 11、12 月」的判斷寫了兩份,「滿三千」的判斷寫了三份。日後若節日檔期改成 10 ~ 12 月,或滿額門檻調成 5000,我們就得在這碗麵裡把每一份都翻出來改——漏掉任何一處,就是一個潛伏的 Bug。這種「改一個規則要動好幾個地方」的症狀,正是我們之後會談到的**散彈槍手術(Shotgun Surgery)**的近親
  3. 麵條裡藏著吃不到的優惠券:優惠券的折抵邏輯被包在最外層 if (context.member !== null) 裡面,導致非會員的優惠券永遠不會生效。這很可能不是刻意的商業規則,而是寫的人在第五層縮排裡迷了路——而且從程式碼表面完全看不出來,要等客訴進來才會東窗事發
  4. 規則彼此矛盾卻無從驗證:金卡平日滿額打 85 折,銀卡節日滿額也打 85 折——這是刻意設計還是巧合?bronze 會員完全沒有節日折扣——是規則就這樣,還是忘了寫?當規則散落在五層巢狀的各個角落,沒有任何人能一眼驗證它們的一致性

這時候我們可以發現,義大利麵最可怕的不是「難讀」,而是牽一髮動全身:你想修改其中一條規則,就像想從碗裡抽出其中一根麵條——拉扯之間,其他麵條全部跟著移動,而你完全無法預測哪裡會被扯斷。於是團隊裡開始流傳一句話:「這段程式碼能動,就別碰它。」——恭喜,這碗麵已經正式熟成為技術債了。

修正後範例

解開這碗麵,我們用三招:early return 攤平巢狀、條件抽成具名函式、查表法取代分支。

// 修正範例:early return 攤平、具名條件函式、查表法三招合體

type MemberLevel = 'bronze' | 'silver' | 'gold';

interface Member {
  name: string;
  level: MemberLevel;
  isFirstPurchase: boolean;
}

interface Coupon {
  code: string;
  discountAmount: number;
  expiredAt: Date;
}

interface DiscountContext {
  member: Member | null;
  amount: number;
  purchaseDate: Date;
  coupons: Coupon[];
}

// 改動重點 1:查表法——把「等級 × 檔期」的折扣率整理成兩張表
// 原本散落在五層巢狀裡的魔術數字,現在排排站好,一眼就能對照與驗證
const REGULAR_RATE_TABLE: Record<MemberLevel, number> = {
  bronze: 0.98,
  silver: 0.95,
  gold: 0.9,
};

const HOLIDAY_RATE_TABLE: Record<MemberLevel, number> = {
  bronze: 0.95, // 查表法逼我們把每一格填滿——原本「忘了寫」的 bronze 節日規則無所遁形
  silver: 0.9,
  gold: 0.8,
};

// 改動重點 2:魔術數字有了名字,商業規則從「藏在條件裡」變成「寫在程式碼上」
const HOLIDAY_MONTHS = [11, 12];
const SPENDING_THRESHOLD = 3000;
const BONUS_RATE = 0.05;  // 滿額與首購的加碼折扣
const MINIMUM_RATE = 0.6; // 折扣率下限,避免加碼疊加後折過頭

// 改動重點 3:條件判斷抽成具名函式,讀程式像讀句子
function isEligibleForHolidayDiscount(purchaseDate: Date): boolean {
  const month = purchaseDate.getMonth() + 1;
  return HOLIDAY_MONTHS.includes(month);
}

function hasReachedSpendingThreshold(amount: number): boolean {
  return amount >= SPENDING_THRESHOLD;
}

function isValidCoupon(coupon: Coupon, purchaseDate: Date): boolean {
  return coupon.expiredAt.getTime() >= purchaseDate.getTime();
}

class DiscountService {
  calculateFinalPrice(context: DiscountContext): number {
    // 改動重點 4:early return——非會員沒有等級折扣,
    // 直接進入優惠券計算,不必陪著會員邏輯繞五層巢狀
    if (context.member === null) {
      return this.applyCoupons(context.amount, context.coupons, context.purchaseDate);
    }

    const rate = this.resolveMemberRate(context.member, context);
    const memberPrice = context.amount * rate;

    return this.applyCoupons(memberPrice, context.coupons, context.purchaseDate);
  }

  // 改動重點 5:折扣率的決策獨立成小函式,每個條件都只有一層縮排
  private resolveMemberRate(member: Member, context: DiscountContext): number {
    const table = isEligibleForHolidayDiscount(context.purchaseDate)
      ? HOLIDAY_RATE_TABLE
      : REGULAR_RATE_TABLE;

    let rate = table[member.level];

    if (hasReachedSpendingThreshold(context.amount)) {
      rate -= BONUS_RATE; // 滿額加碼
    }

    if (member.isFirstPurchase) {
      rate -= BONUS_RATE; // 首購加碼
    }

    return Math.max(rate, MINIMUM_RATE);
  }

  // 改動重點 6:優惠券折抵獨立成函式,continue 就是迴圈裡的 early return
  private applyCoupons(price: number, coupons: Coupon[], purchaseDate: Date): number {
    let finalPrice = price;

    for (const coupon of coupons) {
      if (!isValidCoupon(coupon, purchaseDate)) {
        continue; // 過期的優惠券直接跳過,不再往內包一層 if
      }
      finalPrice = Math.max(finalPrice - coupon.discountAmount, 0);
    }

    return finalPrice;
  }
}

// 使用範例
const service = new DiscountService();

console.log(
  service.calculateFinalPrice({
    member: { name: '小明', level: 'gold', isFirstPurchase: true },
    amount: 5000,
    purchaseDate: new Date('2026-11-11'),
    coupons: [
      { code: 'WELCOME100', discountAmount: 100, expiredAt: new Date('2026-12-31') },
    ],
  })
); // 5000 × (0.8 - 0.05 - 0.05) - 100 = 3400

console.log(
  service.calculateFinalPrice({
    member: null, // 非會員的優惠券也能正常折抵——原本藏在麵條裡的 Bug 順勢修好了
    amount: 1000,
    purchaseDate: new Date('2026-07-16'),
    coupons: [
      { code: 'SUMMER50', discountAmount: 50, expiredAt: new Date('2026-08-31') },
    ],
  })
); // 1000 - 50 = 950

改進重點說明

我們把三招逐一拆解:

  1. early return 攤平巢狀:calculateFinalPrice 的入口用一個 guard clause 把「非會員」這條路徑提前送出門——處理完就離開,不需要讓後面的會員邏輯多包一層 if。這正是 Day 14 快速失敗原則的老朋友:當時我們用守衛子句把「不合法的資料」擋在門口,今天則是用它把「不需要走完全程的路徑」提早送客,兩者的效果相同——主邏輯永遠待在最淺的縮排
  2. continue 是迴圈裡的 early return:優惠券迴圈裡原本包著兩層 if,改用 continue 把「過期券」提前跳過之後,迴圈主體回到了一層縮排。很多人記得函式可以 early return,卻忘了迴圈也有對應的武器
  3. 條件抽成具名函式:month === 11 || month === 12 是實作細節,isEligibleForHolidayDiscount() 才是商業語言。抽成具名函式後,原本複製兩份的節日判斷合而為一,日後檔期改成 10 ~ 12 月,只需要修改 HOLIDAY_MONTHS 一個地方。而這一招,其實就是 Day 20 的 Extract Function——只是 Day 20 我們拆的是「步驟」,今天拆的是「條件」
  4. 查表法取代分支:原本「等級 × 檔期」要用兩層 if/else 展開成六個分支,現在收斂成兩張一目瞭然的表。查表法還有一個隱藏福利:表格逼我們把每一格都填滿——原本 bronze 會員「忘了寫」的節日規則、金卡與銀卡折扣率的一致性,全部攤在陽光下接受檢驗。而且日後要新增 platinum 等級,只需要在型別和兩張表裡各加一行,TypeScript 的 Record<MemberLevel, number> 型別甚至會在我們漏填時直接編譯錯誤
  5. 修好了藏在麵條裡的 Bug:優惠券折抵從巢狀裡解放出來、獨立成 applyCoupons 之後,「非會員的優惠券無效」這個原本看不見的 Bug 自然浮出水面並被修正。這不是巧合——把控制流程攤平的過程,本身就是一次對商業規則的總盤點

這時候我們可以發現,修正後的程式碼裡沒有任何一個地方的縮排超過兩層。每個函式都可以獨立閱讀、獨立測試:想驗證節日判斷,測 isEligibleForHolidayDiscount 就好;想驗證折扣率,測 resolveMemberRate 就好——再也不需要在腦中同時堆疊五個條件。

總結

綜合以上所述,我們成功把一碗打結的麵,解成了一盤整齊的麵線。重點可以整理成以下幾招:

  1. 義大利麵條式程式碼的本質是「控制流程打結」:從遠古的 GOTO 到現代的深層巢狀 if/for,形式在變,災難不變——每多一層巢狀,讀者就要多記一個「現在在哪個 if 裡」,超過三層就進入箭頭反模式的深水區
  2. early return 攤平巢狀:不合法的、不需要走完全程的路徑,在入口處就提前返回;迴圈裡則用 continue 達到同樣效果,讓主邏輯永遠待在最淺的縮排
  3. 條件抽成具名函式:讓 isEligibleForHolidayDiscount() 這樣的商業語言取代 month === 11 || month === 12 這樣的實作細節,同時消滅複製貼上的重複判斷
  4. 查表法取代分支:當分支的本質是「查詢對應規則」時,把規則整理成資料表,不但攤平了 if/else,還逼我們把每一格規則填滿、接受一致性檢驗

值得一提的是,這三招其實我們都不陌生:early return 就是 Day 14 快速失敗原則裡守衛子句(guard clause)的推廣應用——Martin Fowler 在《Refactoring》中的「以守衛子句取代巢狀條件式」正是為此而生;條件抽成具名函式則是 Day 20 過長函式裡 Extract Function 的延伸——當時拆解的是結帳流程的步驟,今天拆解的是纏在一起的條件。換句話說,guard clause 負責攤平、Extract Function 負責拆解、查表法負責收斂——三招合體,就能解開大部分的麵條。

另外一個有趣的冷知識:義大利麵家族其實還有親戚。根據 Wikipedia 的說法,分層分得太多太僵化的程式碼叫做千層麵程式碼(Lasagna Code),而拆得過度碎片化、滿地都是小函式的程式碼則叫做義大利餃程式碼(Ravioli Code)。這也提醒了我們:攤平與拆解都不是做得越多越好——如果一段邏輯的分支彼此之間有複雜的依賴關係,硬塞進表格反而會讓人看不懂;如果 early return 撒得毫無章法,函式的出口多到難以追蹤,那只是把「巢狀迷宮」換成了「出口迷宮」。原則是讓控制流程單向、線性地流動,讀者從上往下讀一遍,就能明白發生了什麼事。

下次當你發現自己正要寫下第三層 if 時,不妨停下來問一句:「我是在寫程式,還是在煮麵?」

參考資料

上一篇
一個函式不該做一百件事 - 過長函式 (Long Method)
系列文
程式碼門診:診斷壞味道、開出重構處方 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言