今天我們要談一個最陰險的程式碼臭味:順序依賴 (Temporal Coupling)。
這東西就是你為下一個接手你程式碼的倒霉鬼所設下的陷阱。
你設計的介面強迫使用者必須像跳房子一樣,按照 A -> B -> C
的順序呼叫函式,只要順序錯了或少了一步,程式就在執行期給你一巴掌。
你等於是在說:「嘿,用我的程式碼之前,請務必熟讀說明書,並把它刻在腦子裡,因為只要你弄錯一步,一切都會爆炸,但你的 Linter 或 IDE 不會告訴你。」
好的設計,根本不應該存在「錯誤的順序」。
原則很簡單:讓介面直接索取它需要的全部資料,一次性完成任務。
這個 BadReportGenerator
,問題出在哪裡?
它沒有一個代表「一份完整的報告」的資料結構。
程式碼有三個獨立的狀態變數:header
、content
和 footer
。
你呼叫 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;
}
}
這個設計有什麼問題?
它依賴文件或記憶: 你必須告訴大家正確的順序。
錯誤發生在執行期: 最糟糕的一種錯誤,它意味著你的測試案例得涵蓋所有錯誤的呼叫順序。
狀態管理複雜: 為了防止別人用錯,你得在內部用一堆 if
判斷來檢查狀態,把類別搞得一團亂。
高認知負擔: 工程師必須時刻記住「我現在在哪個步驟了?」
這是一個簡單的問題,所以應該用簡單的方法解決。
如果你能用一個簡單的函式完成,那就用它。不要發明一個 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);
這個方案將「分散的資料」在函式的第一行就合併成「一個完整的報告」。
沒有中間狀態,沒有多餘的判斷檢查。
讓錯誤的用法無法存在。
如果真的因為一些特殊的原因,必須分步驟構建,那麼 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);
}
}
現在用法乾淨、安全:
// 唯一合法的呼叫方式,像一條流暢的生產線
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 設計,就是讓錯誤的用法根本無法編譯通過,讓你的程式碼變得無法錯誤。
順序依賴是最隱蔽的程式碼臭味,一旦聞到這個味道,立刻動手把它剷除乾淨。