iT邦幫忙

2025 iThome 鐵人賽

DAY 29
1
Software Development

消除你程式碼的臭味系列 第 29

Day 29-順序依賴:停止設計那種需要記住呼叫順序的 API

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250903/20124462P1N8QjGguI.png

消除你程式碼的臭味 Day 29-順序依賴:停止設計那種需要記住呼叫順序的 API

今天我們要談一個最陰險的程式碼臭味:順序依賴 (Temporal Coupling)

這東西就是你為下一個接手你程式碼的倒霉鬼所設下的陷阱。

你設計的介面強迫使用者必須像跳房子一樣,按照 A -> B -> C 的順序呼叫函式,只要順序錯了或少了一步,程式就在執行期給你一巴掌。

你等於是在說:「嘿,用我的程式碼之前,請務必熟讀說明書,並把它刻在腦子裡,因為只要你弄錯一步,一切都會爆炸,但你的 Linter 或 IDE 不會告訴你。」

好的設計,根本不應該存在「錯誤的順序」。
原則很簡單:讓介面直接索取它需要的全部資料,一次性完成任務。

經典範例:充滿陷阱的報告產生器

這個 BadReportGenerator,問題出在哪裡?
它沒有一個代表「一份完整的報告」的資料結構。

程式碼有三個獨立的狀態變數:headercontentfooter
你呼叫 build 時,你被迫在執行時檢查它們是否都存在。

還要必須先 setHeader,再 addContent,然後 setFooter,最後 build

// 🔴臭味:一個強迫你記住步驟的類別
class BadReportGenerator {
  constructor() {
    this.header = null;
    this.content = [];
    this.footer = null;
  }

  // 規定:必須第一個呼叫
  setHeader(header) {
    this.header = `== ${header} ==\n`;
  }

  // 規定:必須在 setHeader 之後呼叫
  addContent(content) {
    if (!this.header) {
      throw new Error("錯誤:請先設定標頭!");
    }
    this.content.push(`${content}\n`);
  }

  // 規定:必須在 addContent 之後、build 之前呼叫
  setFooter(footer) {
    this.footer = `-- ${footer} --\n`;
  }

  // 規定:必須最後呼叫
  build() {
    if (!this.header || !this.footer || this.content.length === 0) {
      throw new Error("錯誤:缺少標頭、內容或頁腳!");
    }
    let finalReport = this.header;
    finalReport += this.content.join('');
    finalReport += this.footer;
    return finalReport;
  }
}

這個設計有什麼問題?

  1. 它依賴文件或記憶: 你必須告訴大家正確的順序。

  2. 錯誤發生在執行期: 最糟糕的一種錯誤,它意味著你的測試案例得涵蓋所有錯誤的呼叫順序。

  3. 狀態管理複雜: 為了防止別人用錯,你得在內部用一堆 if 判斷來檢查狀態,把類別搞得一團亂。

  4. 高認知負擔: 工程師必須時刻記住「我現在在哪個步驟了?」
    https://ithelp.ithome.com.tw/upload/images/20251001/201244626ZtiNzonXq.png

方案一:單一函式封裝 (簡單粗暴,也是最好的)

這是一個簡單的問題,所以應該用簡單的方法解決。

如果你能用一個簡單的函式完成,那就用它。不要發明一個 class 來假裝在做什麼大事。

// 🟢好品味:讓函式一次性拿到所有需要的資料,拼起來
class GoodReportGenerator {
  static create(header, content, footer) {
    // 沒有 if,沒有特殊情況,因為資料從一開始就是完整的
    const headerText = `== ${header} ==\n`;
    const footerText = `-- ${footer} --\n`;
    const contentText = content.map(line => `${line}\n`).join('');
    return headerText + contentText + footerText;
  }
}

// 用法:簡單直接,無需記憶任何順序
const report = GoodReportGenerator.create(
  "年度財務報告",
  ["第一季:收入穩定", "第二季:利潤顯著增長"],
  "由會計部簽署"
);
console.log(report);

這個方案將「分散的資料」在函式的第一行就合併成「一個完整的報告」。
沒有中間狀態,沒有多餘的判斷檢查。
讓錯誤的用法無法存在。
https://ithelp.ithome.com.tw/upload/images/20251001/20124462acoGL4xOhd.png

方案二:Builder 模式 (Pipe)

如果真的因為一些特殊的原因,必須分步驟構建,那麼 Builder 模式是一種「稍微可以」的選擇。

用物件導向打造的防呆流程。

主要是:讓每個步驟都回傳一個新的、只具備下一步合法操作的物件

這利用了類型系統,用物件的「存在」來保證資料的「完整」。

// 🟢好品味:透過類型系統,用物件的「存在」來保證資料的「完整」
class FinalizedBuilder {
  constructor(data) {
    this.reportData = data;
  }
  
  // 步驟三:這是唯一能做的事
  build() {
    return this.reportData;
  }
}

// 這是唯一能新增內容與設定頁腳的物件
class ContentBuilder {
  constructor(header) {
    this.header = header;
    this.content = [];
  }

  addContent(line) {
    this.content.push(`${line}\n`);
    return this; // 允許鏈式呼叫,回傳自己
  }

  // 步驟二:返回一個只能 build 的最終物件
  withFooter(footer) {
    const footerText = `-- ${footer} --\n`;
    const finalData = this.header + this.content.join('') + footerText;
    // 返回一個只能 build 的最終物件
    return new FinalizedBuilder(finalData);
  }
}

class ReportBuilder {
  // 靜態工廠函式作為 API 的入口
  static createWithHeader(header) {
    // 步驟一:返回一個只能加內容或頁腳的物件
    const headerText = `== ${header} ==\n`;
    return new ContentBuilder(headerText);
  }
}

https://ithelp.ithome.com.tw/upload/images/20251001/20124462xtdcbZ2LyW.png

現在用法乾淨、安全:

// 唯一合法的呼叫方式,像一條流暢的生產線
const report = ReportBuilder.createWithHeader("年度財務報告")
                      .addContent("第一季:收入穩定")
                      .addContent("第二季:利潤顯著增長") // 鏈式呼叫
                      .withFooter("由會計部簽署")
                      .build();

console.log(report);

// 嘗試所有錯誤的用法,你的 IDE 或直譯器會立刻報錯!
// ReportBuilder.addContent("..."); // TypeError 
// ReportBuilder.createWithHeader("...").build(); // TypeError 
// ReportBuilder.createWithHeader("...").addContent("...").build(); // TypeError

不需要 if 來檢查 build 是否可以被呼叫,因為只有成功呼叫了 withFooter 之後,才會拿到一個 FinalizedBuilder 物件,而只有這個物件才有 build 方法。

這就是用結構性錯誤 (TypeError) 取代執行期錯誤 (Error)

值得一講的是,在 JavaScript 中,這個 TypeError 是在程式碼「執行到」錯誤呼叫的那一行時才會發生。

如果我們將這個模式應用在 TypeScript 這類靜態型別語言中,它的威力會更加強大。
IDE 和編譯器在「編譯階段」就能直接發現錯誤的呼叫鏈,並用紅色底線提示你,連執行的機會都沒有,真正實現了「讓錯誤的用法無法通過編譯」的終極目標。

好的 API 設計,就是讓錯誤的用法根本無法編譯通過,讓你的程式碼變得無法錯誤

今日重點

  • 讓介面一次性索取所有需要的資料,消除順序依賴。
  • 用結構性錯誤 (TypeError) 取代執行期錯誤 (Error)。
  • 優先使用單一函式封裝,避免不必要的 Builder 模式。
  • 讓物件的「存在」保證資料的「完整」。

順序依賴是最隱蔽的程式碼臭味,一旦聞到這個味道,立刻動手把它剷除乾淨。


上一篇
Day 28- 重構:看見味道就動手
下一篇
Day 30- 總結:從好品味到好架構的終極實踐清單
系列文
消除你程式碼的臭味30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言