iT邦幫忙

2026 iThome 鐵人賽

DAY 3
1
Software Development

諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道系列 第 3

Day 03 -「點石成金」改變軟體的四種理由

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260803/20182564pP81H514Zi.png

彌達斯是佛律吉亞的國王。有一天,佛律吉亞人發現了一位步履蹣跚、渾身酒氣的老人,那位老人正是酒神戴奧尼索斯的養父—西勒諾斯。

彌達斯認出了他,不但好好招待了他十天,到了第十一天,還親自將他平安送回戴奧尼索斯身邊。

酒神非常感激,決定實現彌達斯的一個願望,彌達斯幾乎沒有猶豫便說:

我希望碰到的每一樣東西,都能變成黃金

戴奧尼索斯答應了他。

一開始,一切都很美好,彌達斯碰了一根橡木枝,枝條立刻泛起金光,碰了一塊石頭,石頭也變成了黃金,就連土塊、麥穗和蘋果也不例外。

直到侍從端來晚餐。

他伸手拿起麵包,麵包立刻在指間變硬,入口只剩下金屬的重量,酒才碰到嘴唇,也變成了黃金。

彌達斯以為自己只是多獲得了一種能力,卻沒有先想清楚

哪些東西不該跟著改變?

我們修改 Legacy Code 時,也很容易踩進同一個坑,我們只看見這次想得到什麼,卻沒有先弄清楚,原本有哪些行為絕對不能跟著改變。


四種改動理由

Michael Feathers 在《Working Effectively with Legacy Code》中,將改動軟體的主要理由分成四種:

  1. 加入功能:讓系統多出以前沒有的行為。例如原本只有一般折扣,現在要新增 VIP 折扣。
  2. 修正 bug:讓系統原本就應該具備的行為恢復正確。例如買滿 100 件應該享有折扣,系統卻沒有套用。
  3. 改善設計:在不改變外部行為的前提下,讓 code 更容易理解與修改。例如抽出重複邏輯、改善混亂的命名,或拆開過大的 method。
  4. 最佳化資源使用:在不改變外部行為的前提下,讓系統執行得更快、占用更少記憶體,或減少存取資料庫的次數。

這四件事看起來都叫作「改 code」,本質卻不相同,真正危險的地方在於:

我們嘴上說自己在做其中一件事,實際上卻不小心做了另一件事

最常見的情況,就是以為自己只是在重構,實際上卻悄悄改變了系統行為。


當我們以為在重構,其實是在改行為

看到一段舊 code 時,我們通常很快就能察覺哪裡不對勁:變數名稱很奇怪、method 太長、條件判斷一層包著一層。

看久了,心裡難免開始發癢:

這裡稍微整理一下,之後修改應該會容易很多吧?那就先改個變數名稱,這最簡單,成本也最低。

Legacy Code 最可怕的地方,不只是它很醜,而是它往往沒有足夠的安全網,當這套系統還在養活公司,也養活我們時,最穩妥的策略通常不是看到哪裡醜就改哪裡。

每當「順手整理一下」的念頭出現時,可以先停下來提醒一下自己:

沒有需求,就先不要急著動。
會妨礙這次需求修改或 bug 修復的地方,才是眼前最需要處理的地方。

這不是叫大家永遠不要重構,而是要先弄清楚:

我現在做的改動,究竟是為了完成哪一種目的?

因此,在缺乏測試保護時,真正危險的往往不是「我不會寫程式」,而是「這只是一個小改動,應該不會出事」的心態。


一段很常見的折扣程式碼

來看一段在系統中很常見的 code:

public class InventoryRecord
{
    public decimal CalculateTotalPrice(
        int quantity,
        decimal unitPrice,
        bool isVip)
    {
        decimal total = unitPrice * quantity;

        // 大量購買折扣
        if (quantity >= 100)
        {
            total *= 0.95m;
        }

        // VIP 折抵 50 元
        if (isVip)
        {
            total -= 50m;
        }

        return total;
    }
}

帶有一點潔癖的小張看了,心想:「這個 method 有魔法數字,也可以寫得更簡潔,我就順手整理一下吧。」

於是他改成這樣:

public class InventoryRecord
{
    private const decimal BulkDiscountRate = 0.95m;
    private const decimal VipDiscount = 50m;
    private const int BulkThreshold = 100;

    public decimal CalculateTotalPrice(
        int quantity,
        decimal unitPrice,
        bool isVip)
    {
        var baseTotal = unitPrice * quantity;

        var afterBulkDiscount = quantity > BulkThreshold
            ? baseTotal * BulkDiscountRate
            : baseTotal;

        return isVip
            ? afterBulkDiscount - VipDiscount
            : afterBulkDiscount;
    }
}

看起來好多了?程式碼更短、魔法數字也不見了。

直到 PM 又來找小張:

「客戶反映兩個問題。」
「第一,為什麼你改完以後,剛好買 100 件反而沒有折扣?」
「第二,週年慶要新增八折優惠。」

小張打開程式碼一看,傻住了。

原始程式碼用的是 quantity >= 100,重構後卻不小心變成了 quantity > BulkThreshold,小張原本以為自己只是在「改善設計」,實際上卻改變了行為

  • 原本:剛好 100 件就有折扣。
  • 改完:必須超過 100 件才有折扣。

如果沒有測試,這個差異就這樣輕易地溜了過去。

更麻煩的是,當大家回頭追問「原本到底應該怎麼算」時,PM 不一定記得,客戶也不一定記得。最後,有人翻 Git、有人翻需求單、有人憑印象回答,小張只好默默補上一段註解:

// 2026/05/31 老闆說買滿 100 件就要有折扣

我甚至看過有人直接在註解裡開罵,罵得還很難聽 XD

但註解並不是可靠的安全網,它可能過期、可能被忽略,也可能在下次修改時跟著被改掉,更重要的是,它不會在行為被破壞時告訴我們。


新需求來了:週年慶優惠

修好滿 100 件的問題後,小張接著加入週年慶優惠:

public class InventoryRecord
{
    private const decimal BulkDiscountRate = 0.95m;
    private const decimal VipDiscount = 50m;
    private const decimal AnniversaryDiscountRate = 0.8m;
    private const int BulkThreshold = 100;

    public decimal CalculateTotalPrice(
        int quantity,
        decimal unitPrice,
        bool isVip,
        bool isAnniversarySale)
    {
        var baseTotal = unitPrice * quantity;

        var afterBulkDiscount = quantity >= BulkThreshold
            ? baseTotal * BulkDiscountRate
            : baseTotal;

        if (isVip)
        {
            return afterBulkDiscount - VipDiscount;
        }

        if (isAnniversarySale)
        {
            return afterBulkDiscount * AnniversaryDiscountRate;
        }

        return afterBulkDiscount;
    }
}

可行嗎?乍看之下可行:一般客戶在週年慶打八折,VIP 客戶折抵 50 元,滿 100 件也恢復了 95 折。

小張盯著螢幕確認五遍,說了一句「看起來是對的」,然後就 push 上去了……

問題其實藏在這裡:

if (isVip) return afterBulkDiscount - VipDiscount;
if (isAnniversarySale) return afterBulkDiscount * AnniversaryDiscountRate;

如果 VIP 客戶在週年慶購物,他能不能享有週年慶折扣?

按照小張的 code,答案是不能,只要 isVip == true,第一個 if 就會直接 return,後面的週年慶判斷根本不會執行。

問題是,這真的是錯的嗎?我們其實還不知道。

PM 當初只說「週年慶打八折」,沒有人討論 VIP 與週年慶同時成立時應該怎麼計算,但這不代表情境不存在,VIP 客戶當然也可能在週年慶買東西,因此開發前至少應該先確認:

  • VIP 折抵與週年慶折扣能不能同時使用?
  • 如果可以,計算順序是什麼?
  • 滿 100 件的 95 折又能不能一起疊加?

小張不是不聰明,他只是沒有在動手前,逼自己把這些組合與邊界情境想清楚。


如果小張先寫了測試

讓我們把鏡頭倒回第一次重構之前,如果小張先把目前已確認、必須保留的行為寫成測試,事情可能就會完全不同:

[Theory]
[InlineData(99, 100, false, 9900)]
[InlineData(100, 100, false, 9500)]
[InlineData(101, 100, false, 9595)]
[InlineData(100, 100, true, 9450)]
public void CalculateTotalPrice_依大量購買與VIP規則計算總價(
    int quantity,
    int unitPrice,
    bool isVip,
    decimal expected)
{
    var record = new InventoryRecord();

    var result = record.CalculateTotalPrice(
        quantity,
        unitPrice,
        isVip);

    Assert.Equal(expected, result);
}

先簡單說明一下這段 code。

xUnit 是 .NET 常見的測試框架之一,另外兩個常聽到的名字是 NUnitMSTest,這個系列後續的範例都會使用 xUnit

xUnit 裡,標記 [Fact] 的 method 代表一個測試案例,[Theory] 則讓同一段測試邏輯搭配多組資料執行,每一行 [InlineData] 都是一組測試資料,裡面的值會依序傳入 method 的參數。

所以上面雖然只有一個測試 method,測試執行實際上會跑四個案例:

  • (99, 100, false, 9900):99 件,尚未達到門檻,總價 9900 元。
  • (100, 100, false, 9500):剛好 100 件,套用 95 折,總價 9500 元。
  • (101, 100, false, 9595):超過門檻,總價 9595 元。
  • (100, 100, true, 9450):滿 100 件且為 VIP,9500 元再折抵 50 元,總價 9450 元。

這個測試的結構其實很單純:

  1. 建立 InventoryRecord
  2. 呼叫 CalculateTotalPrice
  3. Assert.Equal 比對實際結果與預期結果。

當小張把 >= 改成 > 時,quantity == 100 的兩組案例就會立刻亮紅燈。

這不代表測試可以自動判斷商業規則正不正確,它保護的只是我們已經確認過、寫進案例裡的行為,至少從此以後,團隊不必只依靠記憶、Git commit 訊息,或可能隨時過期的時間戳記註解。

測試帶給我們最重要的價值,就是:

把「已確認應該怎麼運作」變成可以反覆執行的程式碼


先問一下老大

再回頭看週年慶需求。

如果小張先加入 isAnniversarySale 參數,卻沒有急著決定折扣分支,而是先留下一個等待規則確認的測試草稿,事情可能會變成這樣:

[Fact(Skip = "等待 PM 確認 VIP 與週年慶折扣的組合規則")]
public void CalculateTotalPrice_VIP在週年慶_應依確認後的組合規則計算()
{
    var record = new InventoryRecord();

    var result = record.CalculateTotalPrice(
        quantity: 1,
        unitPrice: 1000m,
        isVip: true,
        isAnniversarySale: true);

    // 必須先確認規則,才能寫下正確的 expected。
}

Skip 會告訴測試框架暫時不要執行這個案例,它在這裡只是一個短期、醒目的待辦事項,規則確認後,應該立刻移除 Skip,並補上明確的驗證。

光是為了填入正確的 expected,小張就會被迫去問 PM:

VIP 在週年慶期間,是折抵 50 元、打八折,還是兩個都算?如果兩個都算,要先折 50 元再打八折,還是先打八折再折 50 元?滿 100 件的 95 折又能不能一起疊加?

這些問題在開發前問,可能只需要五分鐘,阿如果是上線後才發現,可能就是幾小時的緊急修復、一次向客戶道歉,以及下個月小張再次出現在同一個 method 裡。

所以,當規格還沒說清楚時,請先在腦中告訴自己:

我們現在根本還沒有答案


總結

今天最重要的重點不是「不要重構」,而是每次改動之前,都要先弄清楚自己究竟在做哪一種改動。

小張原本以為自己只是在改善設計,卻沒有先用測試保護既有行為,一個三元運算式看起來比 if 更簡潔(實際上也沒有),但他在改寫的過程中,悄悄把 >= 變成了 >,而且沒有人察覺。

彌達斯只看見自己想獲得的能力,卻沒有先想清楚哪些東西不能跟著變成黃金,小張其實也差不多,他只看見程式碼可以變得更乾淨,卻沒有先確認哪些行為必須保持原樣。

被這次教訓修理過後,小張總算學乖了,下次 PM 拿著新需求出現時,他的第一個念頭就會是:

「我這次先把測試補上,再動 code。」

很好,有進步。

不過,「寫測試」三個字說出口很容易,真正動手時,問題馬上會一個個冒出來:

測試要寫成什麼樣子才算數?
把整套流程連同資料庫一起跑過一遍嗎?
如果跑一輪就要好幾分鐘,那還算是我們想要的單元測試嗎?

明天,我們繼續來看:什麼才算是真正的單元測試?

參考資料


上一篇
Day 02 -「忒修斯之船」客戶要的是什麼?
下一篇
Day 04 -「特洛伊木馬」單元測試?
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言