iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
自我挑戰組

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

Rule of Three - 什麼時候該開始重構?

  • 分享至 

  • xImage
  •  

簡單介紹

在昨天的文章中,我們討論了「Write Everything Twice」的概念,強調要先寫兩次具體實作再考慮抽象化。但是接下來我們會面臨一個問題:什麼時候才是重構的最佳時機?

根據維基百科描述如下

Rule of three ("Three strikes and you refactor") is a code refactoring rule of thumb to decide when similar pieces of code should be refactored to avoid duplication. It states that two instances of similar code do not require refactoring, but when similar code is used three times, it should be extracted into a new procedure. The rule was popularised by Martin Fowler in Refactoring and attributed to Don Roberts.

我根據維基百科的描述找到 Refactoring 一書在第二章節開頭附近找到原本的內容如下

The Rule of Three
Here’s a guideline Don Roberts gave me: The first time you do something,
you just do it. The second time you do something similar, you wince at the
duplication, but you do the duplicate thing anyway. The third time you do
something similar, you refactor.
Or for those who like baseball: Three strikes, then you refactor.

作者比喻成棒球翻譯如下

三法則
以下是 Don Roberts 給我的一個指引:

  • 第一次你做某件事時,就直接做就好。
  • 第二次做類似的事時,你可能會因為重複而皺眉,但還是會把它重複寫出來。
  • 第三次再做類似的事時,你就該進行重構了。

如果你喜歡棒球的說法:三振,就該重構。

至於書中提到 Don Roberts,的觀點有興趣可以找尋他的論文 Evolving Frameworks: A Pattern Language for Developing Object-Oriented Frameworks’ (1996) 的 Three Examples

TypeScript 不好的範例

現在,我們來觀看一個實際的案例。這個範例會處理同一個問題領域:將不同來源的資料轉換成頁面要的 {id,title,summary} 格式

我們依序來看三種寫法

第一種寫法:重複的程式碼(不要學)

想像我們有三種不同的資料來源(文章、影片、商品),都需要轉換成相同的格式:

// 假資料
const articleDTO = [{id:"a1", title:"文章標題", body:"文章內容..."}];
const videoDTO   = [{id:"v1", name:"影片標題", desc:"影片描述..."}];
const productDTO = [{id:"p1", label:"商品名稱", spec:"商品規格..."}];

// 文章處理函式 - 重複出現的轉換邏輯
function loadArticles() {
  return articleDTO.map(article => ({
    id: article.id,
    title: article.title,
    summary: article.body.slice(0, 30) // 擷取前 30 字當摘要
  }));
}

// 影片處理函式 - 幾乎相同的邏輯又寫一次
function loadVideos() {
  return videoDTO.map(video => ({
    id: video.id,
    title: video.name, // 注意:這裡用 name 欄位
    summary: video.desc.slice(0, 30)
  }));
}

// 商品處理函式 - 再寫一次類似的邏輯
function loadProducts() {
  return productDTO.map(product => ({
    id: product.id,
    title: product.label, // 注意:這裡用 label 欄位
    summary: product.spec.slice(0, 30)
  }));
}

這個寫法的問題很明顯:三個函式都在做幾乎相同的事情,只是欄位名稱略有不同。這違反了 DRY 原則,造成程式碼重複。

第二種寫法:過早且不良的抽象化

有些開發者看到上面的重複,會立刻想要抽象化,結果可能寫出這樣的程式碼如下

type AnyDTO = Record<string, unknown>;
type ViewItem = {id: string; title: string; summary: string};

// 用「魔術字串」來處理欄位差異
function loadListBad(source: AnyDTO[], idKey: string, titleKey: string, textKey: string): ViewItem[] {
  return source.map((item: any) => ({
    id: String(item[idKey]),
    title: String(item[titleKey]),
    summary: String(item[textKey]).slice(0, 30)
  }));
}

// 呼叫時需要傳入各種字串 key
const itemsA = loadListBad(articleDTO, "id", "title", "body");
const itemsV = loadListBad(videoDTO, "id", "name", "desc");
const itemsP = loadListBad(productDTO, "id", "label", "spec");

這種寫法看起來消除了重複,但帶來了新的問題:

  • 讀程式碼的人看不出問題領域的語意,只看到一堆魔術字串key
  • 任何一個 key 拼錯就會出錯,而且編譯時檢查不出來
  • 失去了型別安全的保護

修正後範例

根據 Rule of Three 原則,我們應該等到確實做了三次類似的事情後,才進行抽象化。而且抽象化時要保持語意清晰型別安全

// 抽象化的核心:資料來源 + 對應器
type Mapper<DTO, View> = (dto: DTO) => View;

class ResourceClient<DTO, View> {
  constructor(
    private readonly data: DTO[], 
    private readonly mapper: Mapper<DTO, View>
  ) {}
  
  list(): View[] { 
    return this.data.map(this.mapper); 
  }
}

// 各自定義明確的型別
type ArticleDTO = {id: string; title: string; body: string};
type VideoDTO   = {id: string; name: string; desc: string};
type ProductDTO = {id: string; label: string; spec: string};

type ViewItem = {id: string; title: string; summary: string};

// 每種資料來源都有自己的 mapper 函式
const mapArticle = (article: ArticleDTO): ViewItem => ({
  id: article.id,
  title: article.title,
  summary: article.body.slice(0, 30)
});

const mapVideo = (video: VideoDTO): ViewItem => ({
  id: video.id,
  title: video.name, // 語意清楚:影片用 name 欄位
  summary: video.desc.slice(0, 30)
});

const mapProduct = (product: ProductDTO): ViewItem => ({
  id: product.id,
  title: product.label, // 語意清楚:商品用 label 欄位
  summary: product.spec.slice(0, 30)
});

// 建立各自的客戶端
const articleClient = new ResourceClient<ArticleDTO, ViewItem>(articleDTO, mapArticle);
const videoClient   = new ResourceClient<VideoDTO, ViewItem>(videoDTO, mapVideo);
const productClient = new ResourceClient<ProductDTO, ViewItem>(productDTO, mapProduct);

// 使用方式簡潔明瞭
const articles = articleClient.list();
const videos   = videoClient.list();
const products = productClient.list();

為什麼這樣的抽象化是好的?

這時候我們可以發現,這個設計有幾個優點:

  1. 在第三次重複後才抽象化:確保共通點真的存在(都是「把來源陣列轉換成 ViewItem 陣列」)
  2. 保留型別安全:每種資料來源都有明確的型別定義
  3. 語意清晰:透過 mapper 函式,可以清楚看出每種資料的欄位對應關係
  4. 容易擴充:要加入新的資料來源時,只需要定義新的型別和 mapper 即可

這種設計讓欄位差異交給各自的 mapper 處理,而抽象層只負責「把陣列轉換」這個核心邏輯,避免了抽象洩漏的問題。

  • 好擴充:之後要加分頁、快取、錯誤處理,可在 ResourceClient 漸進式強化。

總結

對於抽象本身並非一個絕對,重構製作新的抽象也是有成本考量,首先不好的抽象比起撰寫重複的程式碼更糟,因為它會讓程式碼更難以維護。今天先介紹到這邊,我們明天見 881~

參考資料

上一篇
Write Everything Twice - 為什麼重複寫程式碼反而是好事?
下一篇
Keep It Simple, Stupid 原則
系列文
程式碼門診:診斷壞味道、開出重構處方7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言