iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
自我挑戰組

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

程式碼裡的神祕數字 - 魔術數字 (Magic Numbers)

  • 分享至 

  • xImage
  •  

簡單介紹

打開冰箱,聞到一股怪味。食物可能還沒壞,冰箱也還在正常運轉,但那個味道明確地告訴我們:「該檢查了。」有經驗的人不會等到食物長霉才動手,聞到味道的當下就會翻一翻、清一清。

程式碼也會發出這種味道。

什麼是程式碼異味?

從這一篇開始,我們的系列文章要進入一個新的段落——程式碼異味(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)

這個定義有兩個關鍵詞值得展開:

  1. 表面(surface):異味必須是「一眼就能看到」的東西。一個超過幾十行的長函式、一串連續的 if-else、一個到處被複製貼上的片段——不需要深奧的分析工具,掃過去就能發現
  2. 通常(usually):異味不等於錯誤。它是線索,不是判決書。聞到味道代表「值得停下來看一眼」,看完之後也許會發現這裡確實該重構,也可能判斷目前這樣其實沒問題

換句話說,異味是一種廉價的偵測機制:發現成本極低,但背後往往牽連著深層的設計問題。這正是它強大的地方——我們不需要成為架構大師,也能靠鼻子找到程式碼裡最可疑的角落。

童子軍法則:讓營地比你來時更乾淨

那麼,聞到味道之後該做什麼?這裡要引入一個貫穿整個異味系列的心態——童子軍法則(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 年代的程式設計手冊中就已經出現。一條活了超過六十年的守則,至今仍然天天被違反——這也正說明了它有多容易被忽略。

接下來我們透過一個電商訂單系統的例子,看看這些神祕數字如何把程式碼變成一本無字天書。

TypeScript 不好的範例

// 不好的範例:電商訂單系統,處處是解不開的謎團

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 與字面值聯合型別這兩件更強的武器。

解法一:具名常數與 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 直接絕種。

改進重點說明

  1. 可讀性:OrderStatus.ReadyToShip、FREE_SHIPPING_THRESHOLD 讓程式碼讀起來像業務規則說明書,理解成本從「翻文件、問前人」降到「讀名字」
  2. 改動點集中:稅率、免運門檻、運費金額各自只有一份宣告;業務規則變更時只需修改一行,不再需要全專案搜尋加人工判斷
  3. 語意消歧:「狀態的 3」與「三天的 3」在命名之後徹底分道揚鑣,全域搜尋取代誤傷無辜的風險歸零
  4. 型別安全:status 從來者不拒的 number 收窄為 OrderStatus,非法值與打錯字從執行期的隱形炸彈變成編譯期的紅色波浪線
  5. 讓算式自己說話:24 * 60 * 60 * 1000 保留了數字的推導過程,可讀性與正確性一次到位,且完全沒有執行期成本

需要注意的是,不是每一個數字都需要名字。Refactoring Guru 特別提醒:像迴圈裡的 i = 0、array.length - 1、除以 2 取平均這類意義不證自明的數值,硬取名字反而是畫蛇添足(const ZERO = 0 這種常數只會讓同事嘆氣)。判斷的準則很簡單:這個數字背後有沒有「業務知識」? 有——命名;沒有——放過它。原則是工具,不是枷鎖。

總結

綜合以上所述,我們成功替營地清掉了第一種垃圾。回顧一下這一篇的重點:

  1. 程式碼異味是 Kent Beck 在協助 Martin Fowler 撰寫《Refactoring》時創造的詞,指「表面上的跡象,通常對應到系統深處更根本的問題」——容易發現,是它最大的價值
  2. 童子軍法則是面對異味的正確心態:不需要大張旗鼓的重構專案,只需要每次觸碰程式碼時,順手讓它比來時更乾淨一點點
  3. 魔術數字是最容易上手的第一種異味:沒有名字的數值讓程式讀不懂、改不動、型別不設防
  4. 修復手法:具名常數收斂改動點、enum 與字面值聯合型別提供語意與編譯期保護、保留算式讓數字自我解釋

最後,也是整個異味系列最重要的一個觀念:異味不是 Bug。滿是魔術數字的程式今天照樣正常運作、照樣通過測試、照樣如期上線——這正是異味最容易被輕忽的原因。但它是**「未來 Bug 的溫床」**:三個月後改錯一個 0.05 的帳務事故、半年後打錯一個狀態碼的沉默失效,種子全都是今天埋下的。重構異味不是在修理現在的程式,而是在拆除未來的地雷。

值得一提的是,當你開始替數字命名,可能會發現某個常數被十幾個檔案共用、某次規則變更要動遍半個專案——恭喜你,你的鼻子已經聞到另一種味道的線索了,那就是我們之後會談到的散彈槍手術(Shotgun Surgery)。異味之間往往互相糾纏,掃營地的路還長。

下一篇(Day 19),我們要處理另一種安靜的髒東西:那些被註解掉的舊邏輯、永遠不會為真的條件分支、再也沒有人呼叫的函式——死程式碼(Dead Code)。它們不吵不鬧,卻默默地拖累每一個讀程式的人。我們下篇繼續掃營地!

參考資料

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

尚未有邦友留言

立即登入留言