iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
自我挑戰組

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

擁抱重複,直到看清全貌 - AHA Programming (Avoid Hasty Abstractions)

  • 分享至 

  • xImage
  •  

簡單介紹

還記得我們這個系列最開始的三天嗎?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)——它不給你硬性規則,而是要你在每一次「手癢想抽象」的瞬間,先停下來想一想:這兩段程式碼真的服務同一個目的嗎?這個目的未來會繼續相同嗎?

接下來我們用一個非常常見的業務場景——「顯示使用者名稱」——來看看一個倉促的抽象是如何一步步走向失控的。

TypeScript 不好的範例

第一幕:兩段「長得很像」的程式碼

想像我們正在開發一個會員平台,一開始只有兩個地方需要顯示使用者的名稱:

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〉中描述的經典劇本:

  1. 抽象與需求開始分岔:當初合併時兩個場景「碰巧」相同,但 Email 與個人資料頁的需求本質上就會往不同方向演化——一個是正式文書,一個是社群展示
  2. 參數與條件無限增生:每個新場景都不敢動既有邏輯(怕弄壞別人),只敢「再加一個參數」,於是 opts 從 0 個欄位膨脹到 5 個,if 滿天飛
  3. 旗標之間的組合爆炸:5 個選項理論上有 2⁵ = 32 種組合,其中絕大多數組合的行為從來沒有人定義過、更沒有人測試過(例如 masked + withHonorific 會輸出「王** 先生」,這合理嗎?)
  4. 沉沒成本謬誤:正因為這個函式「看起來很通用」、大家都在用,反而沒有人敢重構它——Sandi Metz 指出,開發者會因為捨不得已投入的成本,選擇繼續在錯誤的抽象上加蓋
  5. 修改的恐懼:想幫聊天室調整截斷規則?你得先讀懂全部 5 個 if 的交互作用,並向其他四個團隊確認不會影響他們——原本 DRY 想帶來的「改一處即可」,在錯誤的抽象裡完全變調成「改一處、壞四處」

換句話說,這個函式已經不是「一份知識的單一權威表示」,而是五份不同的知識被硬塞進同一個軀殼。它表面上符合 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 字,不截斷)

改進重點說明

  1. 先往回走,再往前走:依照 Sandi Metz 的處方,把錯誤的抽象攤平回呼叫端、刪除各自用不到的分支,讓每個場景恢復單純——重構的第一步有時候是「反抽象」
  2. 用真實場景取代想像:我們不再猜測「未來可能需要什麼參數」,而是等到第三個場景實際出現,讓共同結構自己浮現——這正是 Kent C. Dodds 說的「等你對使用情境有足夠信心時再抽象」
  3. 抽象的邊界劃在「穩定」與「多變」之間:composeFullName 與 preferNickname 之所以是好抽象,是因為它們封裝的規則在所有場景中完全一致、沒有任何參數分岔;而稱謂、截斷、遮罩這些多變的情境決策,則留在各自的函式裡自由演化
  4. 修改恢復了局部性:現在聊天室想把截斷長度改成 15 個字,只需要動 formatChatDisplayName 一個函式,完全不必擔心影響 Email 或排行榜——這才是 DRY 原則當初承諾我們的世界
  5. 型別更誠實:DisplayNameOptions 這種「什麼都可能、什麼都不確定」的選項物件消失了,每個函式的簽名都精準表達它的用途,TypeScript 的型別系統重新發揮了文件的作用

值得一提的是,修正後的程式碼裡,「重複」並沒有被趕盡殺絕——三個 format 函式的結構仍然相似。但這種相似是表面的巧合,不是同一項知識,我們刻意讓它們保持獨立,正是為了讓未來的變化各走各的路。

總結

綜合以上所述,我們成功避免了「為了消除兩次重複,換來一輩子維護錯誤抽象」的悲劇。走到今天,Day 1 到 Day 3 那場關於「抽象化時機」的辯論,終於可以收束成一條完整的心法:

  1. Day 1(DRY)給了我們目標:每一項知識都應該有單一、權威的表示——但前提是,那真的是「同一項」知識
  2. Day 2(WET)給了我們警惕:長得像的程式碼不一定是同一項知識,過早的抽象化比重複更危險
  3. Day 3(Rule of Three)給了我們時機:第三次重複出現時,你手上才有足夠的樣本判斷共同結構
  4. Day 16(AHA)給了我們完整的心法:先擁抱重複,優先為「變化」做最佳化;等使用場景足夠、共同結構明朗之後,再抽出小而專注的抽象——而當你發現自己已經身處錯誤的抽象時,勇敢地往回走

需要注意的是,AHA 不是教條,它不會告訴你「一定要等三次」或「絕對不能先抽象」。如果你對問題領域已經有深刻的理解(例如你正在寫第五個類似的系統),在設計初期就規劃好抽象完全合理。AHA 反對的從來不是抽象本身,而是倉促——在還沒看清全貌之前,就急著把巧合的相似固定成永久的結構。

另外還有一個容易被忽略的成本觀點:每一個新抽象都有學習成本,而且這個成本是由每一位未來的讀者持續支付的。一段直白的重複程式碼,新同事三十秒就能看懂;一個佈滿旗標的萬用函式,可能要花三十分鐘追蹤所有分支。當我們評估「要不要抽象」時,別忘了把這筆帳也算進去。

下次當你看到兩段相似的程式碼、手指已經懸在「Extract Function」上的時候,不妨先停下來問自己一句:「我看清全貌了嗎?還是我只是急著消除眼前的重複?」如果答案是後者——先擁抱重複,等待那聲真正的「啊哈!」。

參考資料

上一篇
程式碼不該有驚喜 - 最小驚訝原則 (Principle of Least Astonishment)
下一篇
蕭規曹隨的智慧 - 慣例優於設定 (Convention over Configuration)
系列文
程式碼門診:診斷壞味道、開出重構處方 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言