殭屍片裡最麻煩的,往往不是殭屍「做了什麼」——它們什麼都不做,只是站在那裡。但每次經過,我們都得放慢腳步、保持距離、提防它突然動起來。
程式庫裡也住著這種殭屍,它的名字叫做死程式碼(Dead Code)。常見的型態有三種:
if (false) 或早已固定的旗標保護起來的實驗程式碼legacyExport(),實際上沒有任何呼叫端這三種殭屍有一個共同點:它們永遠不會被執行,卻天天被閱讀。CPU 不會為它們多花一奈秒,但每一位打開檔案的工程師都得為它們付出真實的腦力——停下來讀、猜測「這段是不是還有用?」、在搜尋結果裡把它們一一排除、在重構時小心翼翼地繞過它們。
根據 Refactoring Guru 的說法,死程式碼指的是不再被使用的變數、參數、欄位、方法或類別,通常是因為需求改變或修正錯誤之後沒有人回頭清理。它被歸類在「可有可無的東西(Dispensables)」這一組**程式碼異味(Code Smell)**之中——意思是:它的存在沒有任何價值,移除之後程式碼反而更乾淨、更好維護。
而 Marcel Jerzyk 的程式碼異味目錄則把範圍講得更具體:註解掉的程式碼、走不到的條件分支、return 之後的程式碼、永遠不會觸發的例外處理,全部都算死程式碼,並直言那種「以防萬一」的保留,只是在讓程式庫不斷發胖。
值得一提的是,幾乎沒有人是「故意」製造死程式碼的。根據 Refactoring Guru 的說法,死程式碼的誕生通常有幾種典型路徑:
其中最後一種最值得警惕,因為它源自一種心理因素:捨不得。程式碼是我們花時間寫出來的,刪掉它感覺像是丟掉自己的心血。但留著它的代價,是讓後面每一位讀者都替我們的捨不得買單。
換句話說,死程式碼是一種只有成本、沒有收益的資產——不對,它根本不是資產,它是負債。接下來我們透過一個電商計價服務的例子,看看這三隻殭屍實際上長什麼樣子。
// 不好的範例:計價服務裡到處都是「不會執行、卻天天被閱讀」的殭屍
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 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 這類工具定期掃描整個專案,把沒有呼叫端的匯出一網打盡。
if (false) 分支整段刪除:實驗結束的程式碼就該退場;留著它,等於強迫每位讀者先「證明它不重要」才能忽略它tsconfig 的檢查選項、ESLint 規則與 Knip 等掃描工具,能讓殭屍在混進程式庫之前就被攔下綜合以上所述,我們成功避免了「程式碼不執行、腦力照樣被消耗」的隱形浪費。死程式碼的重點可以整理成以下幾點:
git log -S 一下就有需要注意的是,「刪除」之前還是要先確認程式碼真的死透了。如果你維護的是對外發布的函式庫,某個函式在專案內沒有呼叫端,不代表外部使用者沒有依賴它;如果專案中存在動態呼叫(例如透過字串組出函式名稱),靜態掃描工具也可能誤判。刪除前用 IDE 的「尋找所有參考」確認、刪除後讓完整的測試跑過一輪,才是負責任的除殭流程。
而如果回頭看我們在 Day 5 談過的 YAGNI 原則(You Aren't Gonna Need It),會發現這兩篇文章其實是同一個故事的上下集:YAGNI 站在入口,阻止我們為了「未來可能需要」而寫下現在用不到的程式碼;死程式碼異味則站在出口,提醒我們把「曾經需要、如今不再」的程式碼清理出去。一個管住手,一個管住庫存——而它們的結論完全一致:不需要的程式碼,最好的歸宿是不存在。
下次當你想把一段舊邏輯註解起來「先留著以防萬一」時,不妨停下來問一句:「我是在保存價值,還是在製造一隻未來每個人都得繞路的殭屍?」按下刪除鍵吧——git 會記得它,而你的同事會感謝你。