想像你從抽屜裡拿出一副收了三個月的有線耳機:明明放進去的時候好好的,拿出來卻打了七八個結。你想解開其中一個結,結果拉扯之間,另外三個結纏得更緊了。最後你花在解結的時間,比聽音樂的時間還長。
程式碼也會打結。在 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 談快速失敗時,已經跟這個箭頭打過一次照面了。
接下來我們透過一個電商會員折扣的例子,看看一碗五層巢狀的麵是怎麼煮出來的,再示範怎麼把它解開。
需求聽起來很平常:計算訂單的最終金額,折扣規則跟會員等級、節日檔期、消費金額、是否首購、優惠券都有關係。於是程式碼就這樣「一個條件一層 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 裡面,非會員的優惠券永遠無效
這段程式碼就是一碗標準的義大利麵,我們一條一條把麵挑出來看:
finalPrice = context.amount * 0.7 這行核心邏輯縮排了五層。讀到它時,我們必須在腦中同時維護「是會員、是金卡、是節日、有滿額、是首購」五個條件狀態——每一層巢狀都是一筆記憶體開銷,只是消耗的是讀者的腦,不是機器的記憶體if (context.member !== null) 裡面,導致非會員的優惠券永遠不會生效。這很可能不是刻意的商業規則,而是寫的人在第五層縮排裡迷了路——而且從程式碼表面完全看不出來,要等客訴進來才會東窗事發這時候我們可以發現,義大利麵最可怕的不是「難讀」,而是牽一髮動全身:你想修改其中一條規則,就像想從碗裡抽出其中一根麵條——拉扯之間,其他麵條全部跟著移動,而你完全無法預測哪裡會被扯斷。於是團隊裡開始流傳一句話:「這段程式碼能動,就別碰它。」——恭喜,這碗麵已經正式熟成為技術債了。
解開這碗麵,我們用三招: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
我們把三招逐一拆解:
calculateFinalPrice 的入口用一個 guard clause 把「非會員」這條路徑提前送出門——處理完就離開,不需要讓後面的會員邏輯多包一層 if。這正是 Day 14 快速失敗原則的老朋友:當時我們用守衛子句把「不合法的資料」擋在門口,今天則是用它把「不需要走完全程的路徑」提早送客,兩者的效果相同——主邏輯永遠待在最淺的縮排
continue 是迴圈裡的 early return:優惠券迴圈裡原本包著兩層 if,改用 continue 把「過期券」提前跳過之後,迴圈主體回到了一層縮排。很多人記得函式可以 early return,卻忘了迴圈也有對應的武器month === 11 || month === 12 是實作細節,isEligibleForHolidayDiscount() 才是商業語言。抽成具名函式後,原本複製兩份的節日判斷合而為一,日後檔期改成 10 ~ 12 月,只需要修改 HOLIDAY_MONTHS 一個地方。而這一招,其實就是 Day 20 的 Extract Function——只是 Day 20 我們拆的是「步驟」,今天拆的是「條件」platinum 等級,只需要在型別和兩張表裡各加一行,TypeScript 的 Record<MemberLevel, number> 型別甚至會在我們漏填時直接編譯錯誤applyCoupons 之後,「非會員的優惠券無效」這個原本看不見的 Bug 自然浮出水面並被修正。這不是巧合——把控制流程攤平的過程,本身就是一次對商業規則的總盤點
這時候我們可以發現,修正後的程式碼裡沒有任何一個地方的縮排超過兩層。每個函式都可以獨立閱讀、獨立測試:想驗證節日判斷,測 isEligibleForHolidayDiscount 就好;想驗證折扣率,測 resolveMemberRate 就好——再也不需要在腦中同時堆疊五個條件。
綜合以上所述,我們成功把一碗打結的麵,解成了一盤整齊的麵線。重點可以整理成以下幾招:
GOTO 到現代的深層巢狀 if/for,形式在變,災難不變——每多一層巢狀,讀者就要多記一個「現在在哪個 if 裡」,超過三層就進入箭頭反模式的深水區continue 達到同樣效果,讓主邏輯永遠待在最淺的縮排isEligibleForHolidayDiscount() 這樣的商業語言取代 month === 11 || month === 12 這樣的實作細節,同時消滅複製貼上的重複判斷值得一提的是,這三招其實我們都不陌生: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 時,不妨停下來問一句:「我是在寫程式,還是在煮麵?」