還記得我們這個系列最開始的三天嗎?Day 1 我們談了 DRY 原則(Don't Repeat Yourself),強調「每一項知識都必須在系統中具有單一、明確、權威的表示方式」,鼓勵我們把重複的邏輯抽出來;Day 2 馬上出現了唱反調的 WET 原則(Write Everything Twice),提醒我們「過早的抽象化比重複程式碼更危險」;Day 3 的 Rule of Three 則給了一個折衷的時機判斷:第一次直接寫、第二次皺著眉頭重複、第三次才重構。
這三天看起來像是一場沒有結論的辯論——到底該不該消除重複?什麼時候該抽象?今天要介紹的 AHA Programming(Avoid Hasty Abstractions,避免倉促的抽象),正是為這場辯論畫下句點的完整心法。
AHA 這個詞由知名前端工程師 Kent C. Dodds 提出(發音就是恍然大悟的「啊哈!」),我們在 Day 2 其實已經偷偷預告過它。根據 Kent C. Dodds 在他部落格文章中的說法,AHA 的核心信念建立在 Sandi Metz 的一句名言之上:
"Prefer duplication over the wrong abstraction."
(寧可重複,也不要錯誤的抽象。)
Sandi Metz 在她 2016 年的文章〈The Wrong Abstraction〉中更直接地說:「重複的成本,遠比錯誤抽象的成本便宜得多」(duplication is far cheaper than the wrong abstraction)。
換句話說,AHA 並不是叫我們永遠不要抽象,而是提醒我們:重複的成本是看得見的、線性的;錯誤抽象的成本卻是隱形的、會隨著時間複利成長的。Kent C. Dodds 給出的另一個關鍵指引是 "Optimize for change first"(優先為「變化」做最佳化)——因為需求永遠會變,過早定案的抽象往往會變成一座塞滿條件判斷的違章建築。
值得一提的是,AHA 也不是 DRY 與 WET 之間的妥協方案。正如 DEV Community 上的文章所強調的,AHA 是一句格言(aphorism)而非教條(dogma)——它不給你硬性規則,而是要你在每一次「手癢想抽象」的瞬間,先停下來想一想:這兩段程式碼真的服務同一個目的嗎?這個目的未來會繼續相同嗎?
接下來我們用一個非常常見的業務場景——「顯示使用者名稱」——來看看一個倉促的抽象是如何一步步走向失控的。
想像我們正在開發一個會員平台,一開始只有兩個地方需要顯示使用者的名稱:
interface User {
firstName: string;
lastName: string;
nickname?: string;
email: string;
gender: 'male' | 'female' | 'other';
}
// 場景一:Email 通知的收件人稱呼
function formatEmailRecipientName(user: User): string {
return `${user.lastName}${user.firstName}`;
}
// 場景二:個人資料頁的顯示名稱
function formatProfileDisplayName(user: User): string {
return `${user.lastName}${user.firstName}`;
}
兩個函式一模一樣!熟讀 DRY 的我們立刻皺起眉頭:「這不就是重複嗎?合併!」
於是 getDisplayName(user, opts) 誕生了。剛開始它看起來人畜無害,但隨著需求一波波湧入——Email 要加「先生/小姐」稱謂、個人資料頁要優先顯示暱稱、管理後台要附上 email、聊天室要截斷長名稱、排行榜要遮罩名稱——每一個新需求都往同一個函式裡塞一個參數與一段 if:
// 不好的範例:過早抽象後,需求分岔導致參數暴增
interface DisplayNameOptions {
withHonorific?: boolean; // Email 場景加的:要附上「先生/小姐」
preferNickname?: boolean; // 個人資料頁加的:優先顯示暱稱
withEmail?: boolean; // 管理後台加的:要附上 email
maxLength?: number; // 聊天室加的:超過長度要截斷
masked?: boolean; // 排行榜加的:名稱要遮罩
}
function getDisplayName(user: User, opts: DisplayNameOptions = {}): string {
// 問題一:每個新需求都往這裡塞一個 if,函式無限膨脹
let name = `${user.lastName}${user.firstName}`;
if (opts.preferNickname && user.nickname) {
name = user.nickname;
}
if (opts.masked) {
// 問題二:「遮罩」與「暱稱」同時開啟時的行為,當初設計時根本沒想過
// 現在的實作是遮罩暱稱,但排行榜團隊其實想遮罩本名——沒人說得準
name = name.length >= 2
? `${name[0]}${'*'.repeat(name.length - 1)}`
: name;
}
if (opts.withHonorific) {
// 問題三:稱謂只有 Email 用得到,
// 但所有呼叫端都被迫「知道」這個參數的存在
const honorific =
user.gender === 'male' ? '先生' :
user.gender === 'female' ? '小姐' : '';
name = `${name} ${honorific}`.trim();
}
if (opts.withEmail) {
name = `${name}(${user.email})`;
}
if (opts.maxLength !== undefined && name.length > opts.maxLength) {
// 問題四:截斷應該發生在「附加 email 之前」還是「之後」?
// 調整任何一個 if 的順序,都可能默默弄壞另一個場景
name = `${name.slice(0, opts.maxLength)}…`;
}
return name;
}
// 呼叫端:滿手的布林旗標,沒有人能一眼看出實際會輸出什麼
declare const user: User; // 假設已從 API 取得使用者資料
const emailName = getDisplayName(user, { withHonorific: true });
const profileName = getDisplayName(user, { preferNickname: true });
const adminName = getDisplayName(user, { withEmail: true, maxLength: 20 });
const rankName = getDisplayName(user, { masked: true, preferNickname: true });
這時候我們可以發現,這正是 Sandi Metz 在〈The Wrong Abstraction〉中描述的經典劇本:
opts 從 0 個欄位膨脹到 5 個,if 滿天飛masked + withHonorific 會輸出「王** 先生」,這合理嗎?)換句話說,這個函式已經不是「一份知識的單一權威表示」,而是五份不同的知識被硬塞進同一個軀殼。它表面上符合 DRY,實際上違反了 DRY 的初衷——因為這五個場景根本不是同一項知識。
根據 Sandi Metz 的建議,當你發現自己身處錯誤的抽象時,最快的前進方式是往回走(the fastest way forward is back):把抽象的程式碼內聯(inline)回每一個呼叫端,然後替每個呼叫端刪掉它用不到的分支。
// 修正範例:退回各自單純的函式
// 改動重點 1:Email 通知場景——全名加稱謂,永遠不使用暱稱
// 整個函式只剩這個場景自己的規則,一眼就能看懂
function formatEmailRecipientName(user: User): string {
const fullName = `${user.lastName}${user.firstName}`;
if (user.gender === 'male') return `${fullName} 先生`;
if (user.gender === 'female') return `${fullName} 小姐`;
return fullName;
}
// 改動重點 2:個人資料頁場景——優先顯示暱稱,沒有暱稱才退回全名
// 不再需要理解 masked、withEmail 這些與自己無關的參數
function formatProfileDisplayName(user: User): string {
return user.nickname ?? `${user.lastName}${user.firstName}`;
}
是的,${user.lastName}${user.firstName} 這段組合全名的邏輯重複出現了兩次。我們選擇先擁抱這份重複——因為它的成本清楚可見,而且兩個函式各自單純到不需要任何註解就能理解。
過了兩個迭代,聊天室功能上線了,第三個場景自然浮現:
// 第三個場景:聊天室訊息列表——優先暱稱、超過 10 個字就截斷
function formatChatDisplayName(user: User): string {
const name = user.nickname ?? `${user.lastName}${user.firstName}`;
return name.length > 10 ? `${name.slice(0, 10)}…` : name;
}
這時候我們可以發現,有了三個真實的使用場景(正好呼應 Day 3 的 Rule of Three),真正穩定的共同結構終於清晰可見:
// 修正範例:共同結構明朗後,抽出小而專注的抽象
// 改動重點 3:只抽出「三個場景中規則完全一致」的核心邏輯
// 注意:這兩個函式沒有任何 options 參數,也沒有任何 if 分岔
function composeFullName(user: User): string {
return `${user.lastName}${user.firstName}`;
}
function preferNickname(user: User): string {
return user.nickname ?? composeFullName(user);
}
// 改動重點 4:各場景保留自己的「情境決策」,只共用真正穩定的部分
function formatEmailRecipientName(user: User): string {
const honorific =
user.gender === 'male' ? ' 先生' :
user.gender === 'female' ? ' 小姐' : '';
return `${composeFullName(user)}${honorific}`;
}
function formatProfileDisplayName(user: User): string {
return preferNickname(user);
}
function formatChatDisplayName(user: User): string {
const name = preferNickname(user);
return name.length > 10 ? `${name.slice(0, 10)}…` : name;
}
// 使用範例——每個呼叫端語意清楚,不需要解讀任何旗標
const user: User = {
firstName: '小明',
lastName: '王',
nickname: '打程式的小明',
email: 'ming@example.com',
gender: 'male',
};
console.log(formatEmailRecipientName(user)); // 王小明 先生
console.log(formatProfileDisplayName(user)); // 打程式的小明
console.log(formatChatDisplayName(user)); // 打程式的小明(未超過 10 字,不截斷)
composeFullName 與 preferNickname 之所以是好抽象,是因為它們封裝的規則在所有場景中完全一致、沒有任何參數分岔;而稱謂、截斷、遮罩這些多變的情境決策,則留在各自的函式裡自由演化formatChatDisplayName 一個函式,完全不必擔心影響 Email 或排行榜——這才是 DRY 原則當初承諾我們的世界DisplayNameOptions 這種「什麼都可能、什麼都不確定」的選項物件消失了,每個函式的簽名都精準表達它的用途,TypeScript 的型別系統重新發揮了文件的作用值得一提的是,修正後的程式碼裡,「重複」並沒有被趕盡殺絕——三個 format 函式的結構仍然相似。但這種相似是表面的巧合,不是同一項知識,我們刻意讓它們保持獨立,正是為了讓未來的變化各走各的路。
綜合以上所述,我們成功避免了「為了消除兩次重複,換來一輩子維護錯誤抽象」的悲劇。走到今天,Day 1 到 Day 3 那場關於「抽象化時機」的辯論,終於可以收束成一條完整的心法:
需要注意的是,AHA 不是教條,它不會告訴你「一定要等三次」或「絕對不能先抽象」。如果你對問題領域已經有深刻的理解(例如你正在寫第五個類似的系統),在設計初期就規劃好抽象完全合理。AHA 反對的從來不是抽象本身,而是倉促——在還沒看清全貌之前,就急著把巧合的相似固定成永久的結構。
另外還有一個容易被忽略的成本觀點:每一個新抽象都有學習成本,而且這個成本是由每一位未來的讀者持續支付的。一段直白的重複程式碼,新同事三十秒就能看懂;一個佈滿旗標的萬用函式,可能要花三十分鐘追蹤所有分支。當我們評估「要不要抽象」時,別忘了把這筆帳也算進去。
下次當你看到兩段相似的程式碼、手指已經懸在「Extract Function」上的時候,不妨先停下來問自己一句:「我看清全貌了嗎?還是我只是急著消除眼前的重複?」如果答案是後者——先擁抱重複,等待那聲真正的「啊哈!」。