打開冰箱,聞到一股怪味。食物可能還沒壞,冰箱也還在正常運轉,但那個味道明確地告訴我們:「該檢查了。」有經驗的人不會等到食物長霉才動手,聞到味道的當下就會翻一翻、清一清。
程式碼也會發出這種味道。
從這一篇開始,我們的系列文章要進入一個新的段落——程式碼異味(Code Smell)與反模式。在正式聞第一種味道之前,先來說說這個詞的由來。
根據 Martin Fowler 在他的 bliki 中的說法,「程式碼異味」這個詞是 Kent Beck 在協助他撰寫《Refactoring: Improving the Design of Existing Code》一書時所創造的。當時兩人在整理「什麼樣的程式碼該重構」的判斷準則,他們發現:與其給出一套精確但難以執行的度量標準,不如描述一系列「聞起來不太對勁」的表面特徵。Fowler 給異味下的定義是:
「表面上的一種跡象,通常對應到系統深處更根本的問題」(a surface indication that usually corresponds to a deeper problem in the system)
這個定義有兩個關鍵詞值得展開:
換句話說,異味是一種廉價的偵測機制:發現成本極低,但背後往往牽連著深層的設計問題。這正是它強大的地方——我們不需要成為架構大師,也能靠鼻子找到程式碼裡最可疑的角落。
那麼,聞到味道之後該做什麼?這裡要引入一個貫穿整個異味系列的心態——童子軍法則(The Boy Scout Rule)。
美國童子軍有一條著名的守則:「讓營地比你來的時候更乾淨。」(Always leave the campground cleaner than you found it.)Robert C. Martin(Uncle Bob)把這條守則帶進了軟體開發的世界:每一次打開一個檔案,離開的時候就讓它比打開時好一點點——替一個變數取個更清楚的名字、拆掉一個過長的函式、或是像本篇要談的,替一個神祕數字命名。
值得一提的是,童子軍法則的重點是「一點點」。它不要求我們發起一場轟轟烈烈的重構運動,不需要開票、不需要排程、不需要主管核准;它只要求每一次的順手之勞。如果團隊裡每個人都遵守這條法則,系統就不會隨著時間爛下去,反而會在日常開發中一天比一天乾淨。
接下來的十篇文章,我們會一起認識各種「髒」:藏在程式碼裡的神祕數字、永遠不會被執行的死程式碼、什麼都管的上帝物件、一改就要全專案陪葬的散彈槍手術(Shotgun Surgery)……每認識一種異味,我們的鼻子就更靈一分,掃營地的功夫也更純熟一分。
異味系列的第一站,我們從最容易發現、也最容易修復的一種開始——魔術數字(Magic Numbers)。
根據 Refactoring Guru 的說法,魔術數字是**「出現在原始碼中、但沒有明顯意義的數值」**。它們就這樣裸露地躺在程式碼裡:3、0.05、86400000——寫下它們的人當下心裡清楚這些數字代表什麼,但三個月後的自己、以及所有後來的維護者,面對的就是一團謎。
事實上,「不要使用未命名的數值常數」是程式設計領域最古老的守則之一,根據 Wikipedia 的記載,這條規範早在 1960 年代的程式設計手冊中就已經出現。一條活了超過六十年的守則,至今仍然天天被違反——這也正說明了它有多容易被忽略。
接下來我們透過一個電商訂單系統的例子,看看這些神祕數字如何把程式碼變成一本無字天書。
// 不好的範例:電商訂單系統,處處是解不開的謎團
interface Order {
id: string;
customerName: string;
status: number; // 問題:status 只是一個 number,任何整數都塞得進來
price: number;
createdAt: Date;
}
class OrderService {
processOrder(order: Order): void {
// 謎團一:3 是什麼狀態?「已付款」?「備貨完成」?「已取消」?
// 不翻資料庫文件(或抓到當初寫這行的人),沒有人知道
if (order.status === 3) {
console.log(`訂單 ${order.id} 開始出貨`);
}
// 謎團二:這裡又冒出一個 5——它和上面的 3 屬於同一套編號嗎?
// 中間的 4 去哪了?是不存在,還是在別的檔案裡?
if (order.status === 5) {
console.log(`訂單 ${order.id} 已取消,跳過出貨`);
}
}
calculateTotal(order: Order): number {
// 謎團三:0.05 是營業稅?平台手續費?還是刷卡回饋?
const tax = order.price * 0.05;
// 謎團四:1000 和 60 是什麼?看起來像「滿千免運,未滿收 60 元」,
// 但這只是猜測——程式碼本身什麼都沒說
const shippingFee = order.price >= 1000 ? 0 : 60;
return order.price + tax + shippingFee;
}
scheduleReviewReminder(order: Order, remind: () => void): void {
// 謎團五:86400000 到底是多久?
// 心算一下:86400000 毫秒 = 86400 秒 = 1440 分鐘 = 24 小時……
// 讀個程式還得隨身攜帶計算機
setTimeout(remind, 86400000);
}
}
// 另一個服務「碰巧」也需要同一個稅率
class InvoiceService {
printInvoice(order: Order): void {
// 謎團六:又一個 0.05!它和 OrderService 裡的 0.05 是同一件事嗎?
// 如果哪天稅率調整,工程師得全專案搜尋 0.05,
// 還得逐一判斷每個 0.05 是不是「稅率」——漏掉一個就是帳務災難
const tax = order.price * 0.05;
console.log(`商品金額:${order.price} 元,稅額:${tax} 元`);
}
}
這段程式碼「今天」跑起來完全正常,訂單照出、發票照印。但它的問題會在「明天」爆發,而且會從三個方向同時襲來。
第一,讀不懂。 if (order.status === 3) 這行程式碼對讀者的資訊量是零——3 不攜帶任何語意。維護者要嘛去翻資料庫文件、要嘛去翻古老的需求規格、要嘛去問(可能已經離職的)原作者。每一個魔術數字,都是一筆強迫後人償還的理解債——更糟的是,這筆債每次有人讀到這行都要重繳一次。
第二,改不動。 假設稅率從 5% 調整為 10%,我們得在整個專案裡搜尋 0.05,然後逐一判斷:這個 0.05 是稅率,還是某個折扣率?是手續費比例,還是碰巧同值的另一個業務規則?需要注意的是,「同一個數值、不同的意義」是魔術數字最陰險的陷阱——上面範例裡的 3 是訂單狀態,但別的檔案裡可能還有一個代表「重試三次」的 3、一個代表「三天後過期」的 3。全域搜尋取代的那一刻,就是 Bug 誕生的那一刻。
第三,型別不設防。 status: number 意味著 TypeScript 對這個欄位完全幫不上忙:order.status = 42 合法、order.status = -1 合法、手滑打成 order.status === 33 也照樣編譯通過——條件永遠不成立,程式安安靜靜地跳過出貨邏輯,沒有任何錯誤訊息。如果你讀過我們前面談「快速失敗」的那一篇,就會認出這正是最可怕的失敗方式:默默地失敗。
這時候我們可以發現,魔術數字完美符合異味的定義:它極容易發現(掃一眼就看到裸露的數字),而它背後對應的深層問題——語意缺失、改動點分散、型別失守——每一個都足以在未來釀成事故。
修復魔術數字的手法,在 Martin Fowler 的重構名錄中叫做 Replace Magic Literal(也稱為 Replace Magic Number with Symbolic Constant):把神祕數值換成一個有名字的常數,讓名字替數字說話。在 TypeScript 中,我們還有 enum 與字面值聯合型別這兩件更強的武器。
// 修正範例:讓每個數字都有名字、有型別
// 改動重點 1:用 enum 為訂單狀態命名,數字從此有了身分
enum OrderStatus {
Pending = 1, // 待付款
Paid = 2, // 已付款
ReadyToShip = 3, // 備貨完成
Shipped = 4, // 已出貨
Cancelled = 5, // 已取消
}
// 改動重點 2:業務規則抽成具名常數,全專案只有這一份
const TAX_RATE = 0.05; // 營業稅率
const FREE_SHIPPING_THRESHOLD = 1000; // 免運門檻(元)
const STANDARD_SHIPPING_FEE = 60; // 標準運費(元)
// 改動重點 3:用算式取代神祕的 86400000,讓數字自己解釋自己
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
interface Order {
id: string;
customerName: string;
status: OrderStatus; // 改動重點 4:型別從 number 收窄為 OrderStatus
price: number;
createdAt: Date;
}
class OrderService {
processOrder(order: Order): void {
// 讀起來就像在讀業務規則:「備貨完成的訂單,開始出貨」
if (order.status === OrderStatus.ReadyToShip) {
console.log(`訂單 ${order.id} 開始出貨`);
}
// 「已取消的訂單,跳過出貨」——不需要任何文件輔助
if (order.status === OrderStatus.Cancelled) {
console.log(`訂單 ${order.id} 已取消,跳過出貨`);
}
}
calculateTotal(order: Order): number {
const tax = order.price * TAX_RATE;
// 「滿額免運,未滿收標準運費」——名字就是說明書
const shippingFee =
order.price >= FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING_FEE;
return order.price + tax + shippingFee;
}
scheduleReviewReminder(order: Order, remind: () => void): void {
// 一眼看懂:一天後提醒買家留下評價
setTimeout(remind, ONE_DAY_IN_MS);
}
}
class InvoiceService {
printInvoice(order: Order): void {
// 改動重點 5:與 OrderService 共用同一個 TAX_RATE
// 哪天稅率調整,只需要修改常數宣告的那一行,全專案同步生效
const tax = order.price * TAX_RATE;
console.log(`商品金額:${order.price} 元,稅額:${tax} 元`);
}
}
先看最直觀的收穫:if (order.status === OrderStatus.ReadyToShip) 這行程式碼不需要任何註解、任何文件,讀者當場就能理解業務意圖。好的命名是最便宜的文件,而且永遠不會和程式碼脫節。
再看 ONE_DAY_IN_MS 的寫法——我們刻意保留 24 * 60 * 60 * 1000 這個算式,而不是直接寫 86400000。這是一個小巧但實用的技巧:算式本身就在講解這個數字的推導過程(24 小時 × 60 分 × 60 秒 × 1000 毫秒),讀者不需要心算,編譯器會在編譯期把它算好,執行期沒有任何額外成本。
最關鍵的改變在於 TAX_RATE:原本散落在 OrderService 和 InvoiceService 的兩個 0.05,現在收斂成唯一一份宣告。稅率調整時,改動點從「全專案搜尋、逐一人工判斷」變成「修改一行」。這也呼應了我們在系列開頭談過的 DRY 原則——重複的不只是程式碼,重複的業務知識更危險,而魔術數字正是業務知識重複的最小單位。
如果狀態值不需要對應資料庫裡的整數編號,TypeScript 還提供了另一個更輕量的選擇——字面值聯合型別(Literal Union Type):
// 修正範例(另一種選擇):字面值聯合型別
// 改動重點 1:直接用有語意的字串字面值組成型別
type OrderStatus =
| 'pending' // 待付款
| 'paid' // 已付款
| 'readyToShip' // 備貨完成
| 'shipped' // 已出貨
| 'cancelled'; // 已取消
interface Order {
id: string;
status: OrderStatus;
price: number;
}
function processOrder(order: Order): void {
// 改動重點 2:狀態值本身就可讀,寫進 log 或資料庫也一目了然
if (order.status === 'readyToShip') {
console.log(`訂單 ${order.id} 開始出貨`);
}
// 改動重點 3:手滑打錯字會直接編譯失敗,而不是默默永遠不成立
// if (order.status === 'readyToShop') {
// ^^^ 編譯錯誤:型別 '"readyToShop"' 與 OrderStatus 沒有重疊
// }
}
// 改動重點 4:搭配 switch 與 never,漏掉任何一種狀態都逃不過編譯器的眼睛
function getStatusLabel(status: OrderStatus): string {
switch (status) {
case 'pending':
return '待付款';
case 'paid':
return '已付款';
case 'readyToShip':
return '備貨完成';
case 'shipped':
return '已出貨';
case 'cancelled':
return '已取消';
default: {
// 若日後新增了狀態卻忘記在這裡處理,這行會編譯失敗
const exhaustiveCheck: never = status;
throw new Error(`未處理的訂單狀態:${exhaustiveCheck}`);
}
}
}
還記得不好的範例裡,order.status === 33 那個安靜的災難嗎?在字面值聯合型別的世界裡,這種錯誤活不過編譯期。比較 'readyToShop'(打錯字)與 OrderStatus,TypeScript 立刻指出兩者沒有交集;而 switch 搭配 never 的窮盡檢查,更是讓「新增狀態卻漏改判斷」這種經典 Bug 直接絕種。
OrderStatus.ReadyToShip、FREE_SHIPPING_THRESHOLD 讓程式碼讀起來像業務規則說明書,理解成本從「翻文件、問前人」降到「讀名字」status 從來者不拒的 number 收窄為 OrderStatus,非法值與打錯字從執行期的隱形炸彈變成編譯期的紅色波浪線24 * 60 * 60 * 1000 保留了數字的推導過程,可讀性與正確性一次到位,且完全沒有執行期成本需要注意的是,不是每一個數字都需要名字。Refactoring Guru 特別提醒:像迴圈裡的 i = 0、array.length - 1、除以 2 取平均這類意義不證自明的數值,硬取名字反而是畫蛇添足(const ZERO = 0 這種常數只會讓同事嘆氣)。判斷的準則很簡單:這個數字背後有沒有「業務知識」? 有——命名;沒有——放過它。原則是工具,不是枷鎖。
綜合以上所述,我們成功替營地清掉了第一種垃圾。回顧一下這一篇的重點:
enum 與字面值聯合型別提供語意與編譯期保護、保留算式讓數字自我解釋最後,也是整個異味系列最重要的一個觀念:異味不是 Bug。滿是魔術數字的程式今天照樣正常運作、照樣通過測試、照樣如期上線——這正是異味最容易被輕忽的原因。但它是**「未來 Bug 的溫床」**:三個月後改錯一個 0.05 的帳務事故、半年後打錯一個狀態碼的沉默失效,種子全都是今天埋下的。重構異味不是在修理現在的程式,而是在拆除未來的地雷。
值得一提的是,當你開始替數字命名,可能會發現某個常數被十幾個檔案共用、某次規則變更要動遍半個專案——恭喜你,你的鼻子已經聞到另一種味道的線索了,那就是我們之後會談到的散彈槍手術(Shotgun Surgery)。異味之間往往互相糾纏,掃營地的路還長。
下一篇(Day 19),我們要處理另一種安靜的髒東西:那些被註解掉的舊邏輯、永遠不會為真的條件分支、再也沒有人呼叫的函式——死程式碼(Dead Code)。它們不吵不鬧,卻默默地拖累每一個讀程式的人。我們下篇繼續掃營地!