在昨天的文章中,我們討論了「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
現在,我們來觀看一個實際的案例。這個範例會處理同一個問題領域:將不同來源的資料轉換成頁面要的 {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");
這種寫法看起來消除了重複,但帶來了新的問題:
根據 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();
這時候我們可以發現,這個設計有幾個優點:
這種設計讓欄位差異交給各自的 mapper 處理,而抽象層只負責「把陣列轉換」這個核心邏輯,避免了抽象洩漏的問題。
ResourceClient 漸進式強化。對於抽象本身並非一個絕對,重構製作新的抽象也是有成本考量,首先不好的抽象比起撰寫重複的程式碼更糟,因為它會讓程式碼更難以維護。今天先介紹到這邊,我們明天見 881~