iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
自我挑戰組

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

越早失敗越好 - 快速失敗原則 (Fail Fast)

  • 分享至 

  • xImage
  •  

簡單介紹

想像家裡裝了一台瓦斯警報器:瓦斯一外洩,警報器立刻大響,我們馬上關閥門、開窗戶,問題在源頭就被解決了。反過來說,如果沒有警報器,瓦斯就會默默累積,直到某天有人按下電燈開關——轟的一聲,而且爆炸的位置往往離外洩的源頭很遠,事後想追查起火點都很困難。

程式中的錯誤也是一樣的道理。錯誤越晚爆炸,離「案發現場」就越遠,除錯就越痛苦。

除錯其實很像辦案:錯誤訊息就是第一現場的證據。如果程式在資料出問題的當下立刻停下來、大聲報錯,我們一眼就能看到「兇手」;但如果程式選擇默默吞掉錯誤、用預設值蒙混過關,這筆髒資料就會一路流過計算、儲存、彙總,最後在幾天後的月報表上以「數字全錯」的形式爆發——這時第一現場早就被清理得一乾二淨,我們只能從結果反向大海撈針。

這正是**快速失敗原則(Fail Fast)**想解決的問題。根據 Jim Shore 在 IEEE Software 設計專欄(由 Martin Fowler 主編並收錄於其網站)的說法,快速失敗的核心精神是:

「立即且可見地失敗」(fail immediately and visibly)

值得一提的是,這個原則聽起來很反直覺——讓程式動不動就拋出錯誤,難道不會讓軟體更脆弱嗎?Jim Shore 的答案恰恰相反:快速失敗讓軟體更強健。因為錯誤在第一時間、第一現場就被抓到,Bug 變得更容易發現、更容易修復,真正流入正式環境的問題反而更少。

而在實作層面,快速失敗最常見的武器就是守衛子句(Guard Clause)——在函式的入口處先把所有不合法的情況擋下來、及早返回或拋出錯誤,讓主邏輯保持扁平乾淨。Martin Fowler 在《Refactoring》一書中也收錄了對應的重構手法:以守衛子句取代巢狀條件式(Replace Nested Conditional with Guard Clauses)。

接下來我們透過一個電商訂單系統的例子,看看「默默失敗」會把我們帶進什麼樣的災難。

TypeScript 不好的範例

問題一:用 || 0 默默吞掉異常資料

// 不好的範例:訂單金額異常時默默蒙混過關

interface Order {
  id: string;
  customerName: string;
  amount?: number;       // 金額可能是 undefined(例如上游系統資料缺漏)
  discountRate?: number; // 折扣率,0 ~ 1 之間
}

class OrderService {
  calculateTotal(order: Order): number {
    // 問題一:amount 是 undefined 時,|| 0 默默把它變成 0
    // 資料缺漏這個嚴重問題被無聲無息地掩蓋掉了
    const amount = order.amount || 0;

    // 問題二:折扣率也被默默補 0,就算上游傳來 -0.5 或 3 這種
    // 不合理的數值,這裡也照單全收,繼續往下算
    const discountRate = order.discountRate || 0;

    return amount * (1 - discountRate);
  }
}

class ReportService {
  private orderService = new OrderService();

  generateMonthlyReport(orders: Order[]): void {
    let total = 0;

    for (const order of orders) {
      // 問題三:每一筆錯誤的 0 元訂單都被安靜地加進總額
      // 沒有任何警訊,程式看起來「一切正常」
      total += this.orderService.calculateTotal(order);
    }

    // 問題四:直到月底報表出爐,老闆才發現營收數字對不起來
    // 這時距離資料出錯的當下可能已經過了好幾週
    console.log(`本月營收總計:${total} 元`);
  }
}

// 使用範例——災難就這樣安靜地發生
const orders: Order[] = [
  { id: 'ORD-001', customerName: '小明', amount: 1200, discountRate: 0.1 },
  { id: 'ORD-002', customerName: '小華' },              // amount 缺漏!
  { id: 'ORD-003', customerName: '小美', amount: 800 },
];

new ReportService().generateMonthlyReport(orders);
// 輸出:本月營收總計:1880 元
// ORD-002 的金額憑空消失,但沒有任何錯誤、任何警告

問題分析

這段程式碼最可怕的地方在於:它永遠不會報錯。ORD-002 的金額是 undefined,這明明是上游資料出了問題,但 || 0 就像一個過度熱心的清潔工,把案發現場擦得乾乾淨淨。錯誤的資料繼續往下游流動,直到月報表出爐,才有人發現數字對不起來。

這時候我們可以發現,除錯的難度已經完全變了一個量級:報表工程師得先懷疑報表邏輯、再懷疑加總邏輯、再懷疑計算邏輯,最後才追到「原來是三週前某筆訂單的金額就是空的」。錯誤爆發的位置離案發現場越遠,中間的每一層都會變成嫌疑犯。

需要注意的是,|| 0 還藏著一個 TypeScript(JavaScript)特有的型別陷阱:|| 判斷的是「falsy 值」,所以就連合法的 0 元訂單(例如全額折抵的訂單)也會被當成缺漏值處理。這種寫法連「正常資料」和「異常資料」都分不清楚。

問題二:深層巢狀 if 把驗證埋在中間

// 不好的範例:驗證邏輯層層包裹,主邏輯埋在最深處

interface OrderItem {
  productName: string;
  quantity: number;
}

interface SubmitOrder {
  id: string;
  items: OrderItem[];
  amount?: number;
  shippingAddress?: string;
}

class OrderProcessor {
  submitOrder(order: SubmitOrder): string {
    let result = '';

    // 問題一:每多一個驗證條件,就多一層縮排
    // 程式碼像箭頭一樣不斷向右漂移,可讀性急速下降
    if (order.items.length > 0) {
      if (order.amount !== undefined && order.amount > 0) {
        if (order.shippingAddress) {
          // 問題二:真正的主邏輯(訂單成立)被埋在第三層縮排深處
          // 讀程式的人必須在腦中同時記住上面三層條件才能理解這一行
          result = `訂單 ${order.id} 成立,出貨至 ${order.shippingAddress}`;
        } else {
          // 問題三:失敗訊息含糊不清,呼叫端只知道「失敗了」
          // 卻不知道到底是哪個環節出了問題
          result = '訂單建立失敗';
        }
      } else {
        result = '訂單建立失敗'; // 又是一模一樣的模糊訊息
      }
    } else {
      result = '訂單建立失敗'; // 三種完全不同的錯誤,共用同一句話
    }

    return result;
  }
}

問題分析

這種寫法在 Refactoring Guru 的重構教材中有個生動的描述:層層縮排會形成一個「指向痛苦深淵的箭頭」。每加一個驗證條件,箭頭就往右多戳一層,主邏輯被推得越來越深,讀程式的人必須在腦中維護一整疊條件狀態才能理解最核心的那一行在做什麼。

更糟的是錯誤處理的品質:三種完全不同的失敗原因(沒有商品、金額異常、缺少地址)全部共用同一句「訂單建立失敗」。當客服接到使用者回報時,工程師只能重新逐層檢查——因為程式沒有在失敗的當下留下任何有用的線索。

換句話說,這兩個範例犯的是同一個罪:它們都選擇了「安靜」。一個用預設值掩蓋錯誤,一個用模糊訊息稀釋錯誤,而快速失敗原則要求的恰恰相反——出事就要立刻、大聲、指名道姓地喊出來。

修正後範例

解法一:函式入口設置守衛子句,異常資料立刻爆炸

// 修正範例:在函式入口及早驗證,錯誤訊息指名道姓

interface Order {
  id: string;
  customerName: string;
  amount?: number;
  discountRate?: number;
}

class OrderService {
  calculateTotal(order: Order): number {
    // 改動重點 1:守衛子句放在函式入口,資料缺漏的當下立刻拋出錯誤
    // 錯誤訊息明確指出「哪張訂單、哪個欄位、該去哪裡查」
    if (order.amount === undefined) {
      throw new Error(
        `訂單 ${order.id} 的金額為 undefined,請檢查上游訂單資料來源`
      );
    }

    // 改動重點 2:不只擋 undefined,連「不合理的數值」也一併擋下
    if (order.amount < 0) {
      throw new Error(
        `訂單 ${order.id} 的金額為負數(${order.amount}),資料異常`
      );
    }

    // 改動重點 3:discountRate 缺漏是「合法的預設」(沒有折扣),
    // 改用 ?? 明確表達「只在 undefined / null 時補 0」,
    // 合法的 0 折扣不會被誤傷
    const discountRate = order.discountRate ?? 0;

    if (discountRate < 0 || discountRate > 1) {
      throw new Error(
        `訂單 ${order.id} 的折扣率超出合理範圍(${discountRate}),應介於 0 與 1 之間`
      );
    }

    // 守衛子句全數通過後,主邏輯乾淨清爽,不需要任何防備
    return order.amount * (1 - discountRate);
  }
}

class ReportService {
  private orderService = new OrderService();

  generateMonthlyReport(orders: Order[]): void {
    let total = 0;

    for (const order of orders) {
      total += this.orderService.calculateTotal(order);
    }

    console.log(`本月營收總計:${total} 元`);
  }
}

// 使用範例——錯誤在第一現場就被抓到
const orders: Order[] = [
  { id: 'ORD-001', customerName: '小明', amount: 1200, discountRate: 0.1 },
  { id: 'ORD-002', customerName: '小華' },              // amount 缺漏!
  { id: 'ORD-003', customerName: '小美', amount: 800 },
];

new ReportService().generateMonthlyReport(orders);
// 立刻拋出:
// Error: 訂單 ORD-002 的金額為 undefined,請檢查上游訂單資料來源
// 不用等到月底報表,處理到這筆資料的當下就知道是誰、是哪個欄位出了問題

這裡最關鍵的差異在於發現問題的時間點:原本要等到月底報表出爐才會被質疑的錯誤,現在在處理 ORD-002 的那一刻就爆炸了,而且錯誤訊息直接告訴我們訂單編號和缺漏的欄位。辦案不需要推理,因為兇手當場被逮個正著。

值得一提的是,改動重點 3 示範了快速失敗的一個重要判斷:不是所有的 undefined 都該拋出錯誤。amount 缺漏是「異常的缺席」——訂單不可能沒有金額;但 discountRate 缺漏是「合法的缺席」——沒有折扣本來就是常態。快速失敗要求我們把這兩種情況明確區分開來,而不是用一個 || 0 把它們混為一談。

解法二:以守衛子句取代巢狀條件,讓主邏輯扁平化

// 修正範例:守衛子句逐一把關,主邏輯浮出水面

interface OrderItem {
  productName: string;
  quantity: number;
}

interface SubmitOrder {
  id: string;
  items: OrderItem[];
  amount?: number;
  shippingAddress?: string;
}

class OrderProcessor {
  submitOrder(order: SubmitOrder): string {
    // 改動重點 1:每個守衛子句只檢查一件事,不合法就「及早返回」(early return)
    // ——在這裡是直接拋出帶明確訊息的錯誤,讓呼叫端第一時間知道原因
    if (order.items.length === 0) {
      throw new Error(`訂單 ${order.id} 沒有任何商品項目,無法成立`);
    }

    // 改動重點 2:三種失敗原因各自擁有專屬的錯誤訊息,不再共用模糊的「失敗」
    if (order.amount === undefined || order.amount <= 0) {
      throw new Error(`訂單 ${order.id} 的金額異常(${order.amount}),無法成立`);
    }

    if (!order.shippingAddress) {
      throw new Error(`訂單 ${order.id} 缺少收件地址,無法成立`);
    }

    // 改動重點 3:主邏輯回到零縮排的位置,一眼就能看到
    // 執行到這一行時,所有前置條件都已保證成立,不需要在腦中堆疊任何 if
    return `訂單 ${order.id} 成立,出貨至 ${order.shippingAddress}`;
  }
}

// 使用範例——每種錯誤都有自己的名字
const processor = new OrderProcessor();

console.log(
  processor.submitOrder({
    id: 'ORD-101',
    items: [{ productName: '機械鍵盤', quantity: 1 }],
    amount: 2990,
    shippingAddress: '台北市信義區松智路 1 號',
  })
); // 訂單 ORD-101 成立,出貨至 台北市信義區松智路 1 號

processor.submitOrder({
  id: 'ORD-102',
  items: [{ productName: '滑鼠', quantity: 2 }],
  amount: 1580,
  // 忘了填收件地址
});
// 立刻拋出:Error: 訂單 ORD-102 缺少收件地址,無法成立

改進重點說明

  1. 驗證集中在函式入口:所有守衛子句排在最前面,形成一道「安檢門」——不合法的資料連主邏輯的門都進不去,錯誤在進入系統深處之前就被攔截
  2. 及早返回讓主邏輯扁平化:原本埋在三層縮排深處的主邏輯回到了零縮排的位置;讀到主邏輯那一行時,我們可以確定所有前置條件都已成立,認知負擔大幅降低
  3. 錯誤訊息指名道姓:每個錯誤都帶著訂單編號、欄位名稱與實際數值,除錯時不需要重演辦案過程,證據全部留在第一現場
  4. 區分「合法的缺席」與「異常的缺席」:discountRate ?? 0 明確表達「沒填折扣率等於沒有折扣」這個業務規則,而 amount 缺漏則毫不留情地拋出錯誤——快速失敗不是無腦拋錯,而是精準地定義什麼是「不可能發生的狀態」
  5. ?? 取代 || 避開 falsy 陷阱:|| 會把合法的 0 一併吞掉,?? 只處理 undefined 與 null,讓「全額折抵的 0 元訂單」不再被誤判為資料缺漏

這時候我們可以發現,守衛子句其實同時完成了兩件事:對機器而言,它是快速失敗的煞車系統;對人類而言,它是函式的「前置條件說明書」——光是讀完入口那幾行守衛子句,我們就知道這個函式期待什麼樣的資料。

總結

綜合以上所述,我們成功避免了「錯誤默默流向下游、在報表上集體爆發」的災難。快速失敗原則的重點可以整理成以下幾點:

  1. 立即且可見地失敗:錯誤發生的當下就拋出,而不是用預設值、空字串或模糊訊息掩蓋過去——爆炸點離案發現場越近,除錯成本越低
  2. 守衛子句是實作的第一武器:把驗證集中在函式入口、及早返回,讓主邏輯保持扁平;這同時也是 Martin Fowler《Refactoring》中「以守衛子句取代巢狀條件式」的重構手法
  3. 錯誤訊息要指名道姓:「訂單 ORD-002 的金額為 undefined」遠勝過「訂單建立失敗」;好的錯誤訊息本身就是完成一半的除錯
  4. 精準區分合法與異常:不是所有缺漏都該拋錯——業務上合法的預設值就大方地用 ?? 補上,業務上不可能的狀態則毫不留情地爆炸

需要注意的是,快速失敗並不是要我們對「使用者」擺出臭臉。Jim Shore 在文章中特別提醒:快速失敗針對的是程式設計上的錯誤(例如內部狀態不一致、上游資料違反約定),這類問題該讓工程師立刻知道;至於系統邊界上「預期中的錯誤」——使用者填錯表單、外部 API 暫時斷線——則應該優雅地處理並給出友善的回饋。換句話說,對內嚴格、對外溫柔:內部的不變條件被違反時大聲爆炸,對終端使用者則提供清楚的引導而不是一整頁的例外堆疊。

值得一提的是,快速失敗的最大天敵是「吞例外」——用空的 try-catch 把錯誤接住後什麼都不做,或是只印一行 log 就當作沒事。這種寫法等於把警報器的電池拔掉:程式看起來平靜無波,實際上瓦斯正在悄悄累積。如果暫時無法妥善處理某個例外,寧可讓它往上拋,也不要讓它無聲消失。

而如果回頭看我們在 Day 12 談過的「把邏輯歸位」,會發現守衛子句也是同一種精神的延伸:驗證規則就該待在最接近資料的地方,在資料進入系統的第一道門就把關,而不是讓每個下游的使用端各自防禦、各自猜測。

快速失敗不是悲觀,而是把問題釘死在案發現場。下次當你想順手寫下 || 0 讓程式「先跑起來再說」時,不妨停下來問一句:「我是在解決問題,還是在幫問題湮滅證據?」

參考資料

上一篇
一個蘿蔔一個坑 - 關注點分離 (Separation of Concerns)
下一篇
程式碼不該有驚喜 - 最小驚訝原則 (Principle of Least Astonishment)
系列文
程式碼門診:診斷壞味道、開出重構處方 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言