iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
自我挑戰組

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

埋在程式庫裡的殭屍 - 死程式碼 (Dead Code)

  • 分享至 

  • xImage
  •  

簡單介紹

殭屍片裡最麻煩的,往往不是殭屍「做了什麼」——它們什麼都不做,只是站在那裡。但每次經過,我們都得放慢腳步、保持距離、提防它突然動起來。

程式庫裡也住著這種殭屍,它的名字叫做死程式碼(Dead Code)。常見的型態有三種:

  1. 被註解掉的程式碼:改版時「先留著以防萬一」的舊邏輯,一躺就是半年
  2. 永遠走不到的分支:被 if (false) 或早已固定的旗標保護起來的實驗程式碼
  3. 從未被呼叫的函式:「以後可能用得到」的 legacyExport(),實際上沒有任何呼叫端

這三種殭屍有一個共同點:它們永遠不會被執行,卻天天被閱讀。CPU 不會為它們多花一奈秒,但每一位打開檔案的工程師都得為它們付出真實的腦力——停下來讀、猜測「這段是不是還有用?」、在搜尋結果裡把它們一一排除、在重構時小心翼翼地繞過它們。

根據 Refactoring Guru 的說法,死程式碼指的是不再被使用的變數、參數、欄位、方法或類別,通常是因為需求改變或修正錯誤之後沒有人回頭清理。它被歸類在「可有可無的東西(Dispensables)」這一組**程式碼異味(Code Smell)**之中——意思是:它的存在沒有任何價值,移除之後程式碼反而更乾淨、更好維護。

而 Marcel Jerzyk 的程式碼異味目錄則把範圍講得更具體:註解掉的程式碼、走不到的條件分支、return 之後的程式碼、永遠不會觸發的例外處理,全部都算死程式碼,並直言那種「以防萬一」的保留,只是在讓程式庫不斷發胖。

殭屍是怎麼誕生的?

值得一提的是,幾乎沒有人是「故意」製造死程式碼的。根據 Refactoring Guru 的說法,死程式碼的誕生通常有幾種典型路徑:

  • 需求改變了:某個功能被新版本取代,但舊實作沒有被清理乾淨
  • 修正錯誤之後:Bug 修好了,繞路用的臨時邏輯卻被遺忘在原地
  • 條件式演化的副作用:複雜的條件判斷經過多次修改後,某些分支在不知不覺間變得永遠走不到
  • 對刪除的恐懼:「這段先註解起來,說不定以後要 rollback」——然後就再也沒有人回來看它一眼

其中最後一種最值得警惕,因為它源自一種心理因素:捨不得。程式碼是我們花時間寫出來的,刪掉它感覺像是丟掉自己的心血。但留著它的代價,是讓後面每一位讀者都替我們的捨不得買單。

換句話說,死程式碼是一種只有成本、沒有收益的資產——不對,它根本不是資產,它是負債。接下來我們透過一個電商計價服務的例子,看看這三隻殭屍實際上長什麼樣子。

TypeScript 不好的範例

一個被殭屍佔領的計價服務

// 不好的範例:計價服務裡到處都是「不會執行、卻天天被閱讀」的殭屍

interface OrderItem {
  productName: string;
  unitPrice: number;
  quantity: number;
}

interface Order {
  id: string;
  memberLevel: 'standard' | 'vip';
  items: OrderItem[];
}

class PricingService {
  calculateTotal(order: Order): number {
    const subtotal = order.items.reduce(
      (sum, item) => sum + item.unitPrice * item.quantity,
      0
    );

    // 問題一:被註解掉的舊計價邏輯,從去年改版後就躺在這裡超過半年
    // 每個讀到這裡的人都會停下來想:「這段是不是還有用?刪掉會不會出事?」
    //
    // const memberDiscount = order.memberLevel === 'vip' ? 0.85 : 0.95;
    // let shippingFee = 80;
    // if (subtotal >= 1000) {
    //   shippingFee = 0;
    // }
    // return Math.round(subtotal * memberDiscount) + shippingFee;

    // 問題二:用 if (false) 保護的實驗分支
    // 當初 A/B 測試「深夜動態折扣」的程式碼,實驗三個月前就結束了
    // 這個區塊永遠不會執行,但每次搜尋、每次重構都會被它干擾
    if (false) {
      const hour = new Date().getHours();
      const dynamicRate = hour >= 22 || hour < 6 ? 0.88 : 0.95;
      return Math.round(subtotal * dynamicRate);
    }

    // 唯一真正「活著」的邏輯,被上下兩坨殭屍夾在中間
    const discountRate = order.memberLevel === 'vip' ? 0.9 : 1;
    return Math.round(subtotal * discountRate);
  }

  // 問題三:從未被任何地方呼叫的 legacyExport()
  // 舊報表系統退役之後,整個專案已經沒有任何呼叫端
  // 但因為「說不定哪天財務部又會要」,所以一直留著
  legacyExport(orders: Order[]): string {
    const header = '訂單編號,總金額';
    const rows = orders.map(
      (order) => `${order.id},${this.calculateTotal(order)}`
    );
    return [header, ...rows].join('\n');
  }
}

問題分析

這個類別真正在工作的邏輯只有短短幾行,卻被三隻殭屍撐成了一大坨。我們逐一驗屍。

問題一:註解掉的舊計價邏輯——最會說謊的殭屍

被註解掉的程式碼是三隻殭屍裡欺騙性最強的一隻。Kent C. Dodds 在他的文章中描述得很傳神:每當工程師讀到一段被註解掉的程式碼,往往會停下手邊的工作去讀它——「這搞不好很重要」——工作節奏就這樣被打斷了。

更糟的是,註解掉的程式碼不會被編譯、不會被測試、不會被 lint。半年來 Order 的欄位可能改過名、折扣規則可能翻新過三輪,但這段註解永遠停留在它被封印的那一天。哪天真的有人把它解除註解,等著他的不是「找回舊功能」,而是一堆編譯錯誤和早已過時的業務規則。它看起來像備份,實際上是陷阱。

問題二:if (false) 的實驗分支——假死的殭屍

這隻殭屍比註解更陰險,因為它看起來還活著:語法正確、型別檢查照跑、IDE 的重新命名重構也會忠實地更新它。於是它持續產生維護成本——改動 Order 介面時要照顧它、全域搜尋 dynamicRate 時會找到它——卻永遠不產生任何行為。

值得一提的是,讀者的認知負擔在這裡是最重的:看到 if (false) 的人必須先確認「這個條件真的永遠是 false 嗎?會不會哪天被改成旗標?」才能放心忽略整個區塊。一段需要讀者「先證明它不重要、才能忽略它」的程式碼,本身就是最不划算的存在。

問題三:沒有呼叫端的 legacyExport()——「以後可能用得到」的殭屍

這隻殭屍的存在理由是一句熟悉的咒語:「說不定哪天會用到。」但代價是實實在在的:每次搜尋 calculateTotal 的使用處,它都會出現在結果裡;每次修改 Order 介面,它都可能跟著報錯、逼我們順手維護一段沒有人使用的程式碼;新進同事讀到它,還會誤以為系統中存在一個「匯出報表」的功能,花時間追查它到底從哪裡被觸發——答案是:哪裡都沒有。

額外的傷害:殭屍會繁殖

這時候我們可以發現,三隻殭屍傷害我們的方式一模一樣:它們不消耗機器的資源,它們消耗的是人的注意力。而在軟體開發裡,人的注意力永遠比 CPU 稀有得多。

更麻煩的是,死程式碼還有繁殖能力——這就是著名的「破窗效應」:當一個檔案裡已經躺著三段註解掉的程式碼,第四位工程師改版時把舊邏輯也註解起來留在原地,沒有人會覺得奇怪;反過來說,在一個一塵不染的檔案裡留下第一段註解掉的程式碼,心理門檻就高得多。換句話說,每一隻沒被清掉的殭屍,都在默默降低下一隻殭屍進門的難度。

我們可以粗略估算一下這個檔案的閱讀成本:假設團隊有五位工程師,每人每週會打開 PricingService 兩次,每次被三隻殭屍拖慢三十秒——一年下來就是超過二十個小時的純粹浪費,而且這還沒算上「誤解殭屍還活著」所導致的錯誤決策。

修正後範例

解法:全部刪除,一行不留

治療死程式碼的重構手法,是所有重構裡最簡單、也最痛快的一種——刪除它(Remove It)。

// 修正範例:三隻殭屍全數刪除,只留下唯一活著的邏輯

interface OrderItem {
  productName: string;
  unitPrice: number;
  quantity: number;
}

interface Order {
  id: string;
  memberLevel: 'standard' | 'vip';
  items: OrderItem[];
}

class PricingService {
  // 改動重點 1:註解掉的舊計價邏輯整段刪除——它早已完整地活在 git 歷史裡
  // 改動重點 2:if (false) 實驗分支整段刪除——實驗結束,程式碼就該功成身退
  // 改動重點 3:沒有呼叫端的 legacyExport() 整個刪除——真需要時再從 git 找回來
  calculateTotal(order: Order): number {
    const subtotal = order.items.reduce(
      (sum, item) => sum + item.unitPrice * item.quantity,
      0
    );

    const discountRate = order.memberLevel === 'vip' ? 0.9 : 1;
    return Math.round(subtotal * discountRate);
  }
}

// 使用範例——刪除前後行為完全相同,這是一次「零風險、純收益」的重構
const service = new PricingService();
const order: Order = {
  id: 'ORD-001',
  memberLevel: 'vip',
  items: [
    { productName: '機械鍵盤', unitPrice: 2990, quantity: 1 },
    { productName: '滑鼠墊', unitPrice: 350, quantity: 2 },
  ],
};

console.log(service.calculateTotal(order)); // 3321(與刪除殭屍之前一模一樣)

整個類別從五十多行縮減到十幾行,而且行為完全沒有改變——因為被刪掉的每一行本來就不會執行。讀者打開這個檔案,一眼就能看懂計價規則是什麼,不需要再繞過任何殭屍。

git 歷史就是你的回收桶

「可是刪掉之後,哪天要用怎麼辦?」——這是死程式碼最常見的辯護詞,也是最站不住腳的一個。因為我們早就有一套完整的備份系統,它的名字叫 git。

git 歷史就是你的回收桶:任何被提交過的程式碼,刪除之後都躺在歷史紀錄裡,隨時可以找回來。

# 查看某個檔案的完整變更歷史,找出「刪除殭屍」的那次提交
git log --oneline -- src/pricing-service.ts

# 用 pickaxe 搜尋:找出「legacyExport 這個字串出現或消失」的所有提交
git log -S "legacyExport" --oneline

# 找到提交之後,直接檢視當時的完整檔案內容,複製回來即可
git show <commit-hash>:src/pricing-service.ts

換句話說,把舊程式碼註解起來留在檔案裡,等於不信任版本控制系統——我們手動做了一份 git 早就自動做好的備份,還把這份備份放在所有人每天都要閱讀的地方。Kent C. Dodds 也在文章中指出:透過 git 往回追幾個月、甚至幾年前的某段程式碼,其實非常容易。

值得一提的是,實務上有一個讓「找回來」更輕鬆的小技巧:把刪除死程式碼做成一次獨立的提交,並在提交訊息寫清楚刪了什麼(例如 refactor: 移除已退役的 legacyExport 與舊計價邏輯)。這樣未來要考古時,一條 git log 就能直接定位。

讓工具幫我們巡邏殭屍

人眼抓殭屍難免有遺漏,好消息是 TypeScript 生態系有現成的自動化巡邏隊:

// tsconfig.json——讓編譯器直接把部分殭屍標記成錯誤
{
  "compilerOptions": {
    "noUnusedLocals": true,        // 未使用的區域變數直接報錯
    "noUnusedParameters": true,    // 未使用的參數直接報錯
    "allowUnreachableCode": false  // 永遠執行不到的程式碼直接報錯
  }
}

搭配 ESLint 的 no-unreachable 與 no-constant-condition 規則,像 if (false) 這種「假死殭屍」在寫下的當下就會被攔截。至於 legacyExport() 這種「整個專案都沒有人呼叫」的匯出函式,則可以用 Knip 或 ts-prune 這類工具定期掃描整個專案,把沒有呼叫端的匯出一網打盡。

改進重點說明

  1. 註解掉的程式碼整段刪除:它不會被編譯、測試與 lint,只會隨著時間變成過期的謊言;真正可靠的歷史紀錄在 git 裡,不在註解裡
  2. if (false) 分支整段刪除:實驗結束的程式碼就該退場;留著它,等於強迫每位讀者先「證明它不重要」才能忽略它
  3. 沒有呼叫端的函式整個刪除:「以後可能用得到」的正確處理方式是——現在刪掉,以後真的用到時再從 git 歷史找回來,或依照當時的最新需求重寫(通常後者品質更好)
  4. 刪除死程式碼是行為不變的重構:被刪掉的程式碼本來就不會執行,所以這是風險最低、報酬最高的一種重構,不需要猶豫
  5. 用工具建立防線:tsconfig 的檢查選項、ESLint 規則與 Knip 等掃描工具,能讓殭屍在混進程式庫之前就被攔下

總結

綜合以上所述,我們成功避免了「程式碼不執行、腦力照樣被消耗」的隱形浪費。死程式碼的重點可以整理成以下幾點:

  1. 死程式碼的成本是認知成本:它不佔用 CPU,佔用的是每一位讀者的注意力——停下來閱讀、猜測、排除、繞路,日積月累是一筆可觀的支出
  2. 三種常見型態都該刪:註解掉的舊邏輯、永遠走不到的分支、沒有呼叫端的函式,治療方式只有一種——刪除
  3. git 歷史就是你的回收桶:捨不得刪,本質上是不信任版本控制;被提交過的程式碼永遠找得回來,git log -S 一下就有
  4. 讓工具自動巡邏:編譯器選項、ESLint 規則與未使用程式碼掃描工具,能把殭屍攔截在進門之前

需要注意的是,「刪除」之前還是要先確認程式碼真的死透了。如果你維護的是對外發布的函式庫,某個函式在專案內沒有呼叫端,不代表外部使用者沒有依賴它;如果專案中存在動態呼叫(例如透過字串組出函式名稱),靜態掃描工具也可能誤判。刪除前用 IDE 的「尋找所有參考」確認、刪除後讓完整的測試跑過一輪,才是負責任的除殭流程。

而如果回頭看我們在 Day 5 談過的 YAGNI 原則(You Aren't Gonna Need It),會發現這兩篇文章其實是同一個故事的上下集:YAGNI 站在入口,阻止我們為了「未來可能需要」而寫下現在用不到的程式碼;死程式碼異味則站在出口,提醒我們把「曾經需要、如今不再」的程式碼清理出去。一個管住手,一個管住庫存——而它們的結論完全一致:不需要的程式碼,最好的歸宿是不存在。

下次當你想把一段舊邏輯註解起來「先留著以防萬一」時,不妨停下來問一句:「我是在保存價值,還是在製造一隻未來每個人都得繞路的殭屍?」按下刪除鍵吧——git 會記得它,而你的同事會感謝你。

參考資料

上一篇
程式碼裡的神祕數字 - 魔術數字 (Magic Numbers)
下一篇
一個函式不該做一百件事 - 過長函式 (Long Method)
系列文
程式碼門診:診斷壞味道、開出重構處方 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言