iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Software Development

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

Day 16 -「冥府之旅」別只用看的,透過草稿重構直接動手

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260816/20182564Qf6zVKyGgT.png

特洛伊戰爭結束後,奧德修斯只想回伊薩卡,見他的妻子和兒子。

但是結果他帶著船隊在海上漂流了好幾年,一路遇到風暴、怪物與神明的憤怒,怎麼樣都回不了家。

漂流途中,他來到女巫喀耳刻居住的島上,好不容易準備再次出發,喀耳刻卻告訴他:

「想知道回家的路,得先去冥府,向已經死去的盲眼先知特瑞西阿斯問清楚。」

他照著喀耳刻交代的方法航行,一路來到大地的盡頭,抵達後他先在地上挖出一道溝,再讓血流進溝裡,嗜血的亡靈立刻從冥界四面八方湧了上來。

問題是,奧德修斯現在還不能讓他們接近,因為真正要找的特瑞西阿斯還沒有出現,他只好拔出劍守在溝邊,把其他亡靈擋在外面,就連自己死去的母親來到眼前,他也得先忍住,等到真正要找的亡靈。

特瑞西阿斯終於出現,喝了血,把奧德修斯必須知道的事情說了出來:

「波塞頓為什麼發怒,還有即使真的回到家,仍然有什麼麻煩在等著他。」

事情問到這裡,奧德修斯其實已經得到原本要找的答案了。

但人都已經來到冥府邊上了,怎麼可能就這樣走掉?

他讓母親喝下血,趁這個機會追問妻子、兒子和父親的近況,後來又和一個個死去的同伴與英雄說話,原本只是想問回家的路,最後得到的答案,比他一開始知道該問的還要多。

全部問完後,奧德修斯回到船上離開了那裡,他其實可以直接回家的,但是如果沒先到冥府問清楚接下來會遇到什麼事情,恐怕最後也不會這麼順利。

當我們在處理 Legacy Code 時,無意間在路上看到一段不能忽略的程式碼,我們做的事情其實也是一樣,得先把問題跟程式碼搞清楚,但是無奈不管是從文件還是老學長都不知道這段程式碼到底會影響到什麼,這使得我們沒辦法當下就馬上知道應該要投入多少成本在這裡,而我們又沒辦法說自己覺得可以就動手,那該怎麼辦?


開始吧!

今日範例 是一個房間費用的計費邏輯,直接看程式碼,先感受一下它:

public class RoomBill
{
    private static readonly int[] PH = [17, 18, 19, 20, 21, 22, 23];

    public decimal Compute(
        string mid, int rt, int sh, int eh, bool wd, string[]? fo, int gc)
    {
        decimal r = 0, f = 0, d = 0;
        var pk = false;
        var dur = eh - sh;
        if (dur <= 0) return 0;

        foreach (var h in PH)
            if (h >= sh && h < eh) { pk = true; break; }

        decimal[,] @base = { { 120, 180, 240 }, { 160, 230, 300 }, { 250, 360, 450 } };
        var bi = pk ? 2 : (wd ? 1 : 0);
        r = @base[rt, bi] * dur;

        var ml = GetMemberLevel(mid);
        if (ml is { Length: > 1 })
        {
            if (ml[1] == "gold") r *= 0.85m;
            else if (ml[1] == "silver") r *= 0.92m;
        }

        if (fo is not null)
        {
            foreach (var item in fo)
            {
                var p = item.Split(':');
                if (p.Length == 2 && decimal.TryParse(p[1], out var price))
                    f += price * 1.1m;
            }
        }

        if (gc > 6) d += (gc - 6) * 30;
        var t = r + f + d;
        var mn = @base[rt, 0] * 2;
        return t < mn ? mn : t;
    }

    private string[] GetMemberLevel(string mid)
    {
        return mid switch
        {
            "M001" => ["gold"],
            "M002" => ["silver"],
            _ => []
        };
    }
}

變數名都是縮寫,rtshehbi,有個神秘的 PH 陣列,fogc 是什麼完全看不出來,method 裡做了好幾件不同的事,如果想追某一段在算什麼,腦袋要一直在上下跳來跳去,這就是那種讀了好幾遍還是不知道在幹嘛的 code,我們唯一的資訊是從這個 class 的名稱,知道它是在算房間的價錢。


呼叫端

既然這份 code 有呼叫端,我們就直接拿來看有沒有線索:

var foodOrders = booking.FoodOrders
    .Select(item => $"{item.Name}:{item.Price}")
    .ToArray();

var total = roomBill.Compute(
    member.Id,
    (int)booking.RoomType,
    booking.StartHour,
    booking.EndHour,
    booking.IsWeekend,
    foodOrders,
    booking.GuestCount);

看了一下 Git,這裡很明顯是新的程式碼,而 RoomBill 已經經過十幾年沒人動過了...

多虧有了呼叫端,我們可以把參數名稱跟傳入做比對:

  • mid 來自會員 ID
  • rt 來自房型
  • sheh 是開始和結束時間
  • wd 代表是否為週末
  • fo 是餐點名稱和價格組成的字串陣列
  • gc 則是客人人數。

開心的同時也感到很生氣,啊你都知道傳進去的是什麼了,到底為什麼不順手 Rename 一下?

想必當初的學長姐也是深陷焦油坑,跟現在的我們一樣動彈不得吧 TT,有前人的禮物當然要好好利用,不過如果今天呼叫端一樣看不懂勒?所以我們今天先假裝沒有看到呼叫端長怎樣,只靠 RoomBill 自己透露的線索直接動手。


讓 code 說話

有一個技巧叫做 草稿重構 (Scratch Refactoring),其實做法蠻簡單的:

  1. 先保存手上正在做的修改,再用 git 開一個 branch 或 worktree
  2. 不用寫特徵測試,直接開始無情重構
  3. 不需要保證每一步都保持行為,也不用寫得多漂亮,我們的目的只有一個,讓結構浮出來,讓自己能看懂它在幹嘛
  4. 理解之後把整份草稿 branch 整個刪除,小心不要混進主要分支。

「痾...那你是在浪費時間嗎?」

這跟有 Characterization Tests 保護,為正式改動的重構完全不同,草稿重構可以沒有安全網,都說了「草稿」了嘛!我們的目的是理解,不是交付,能不能保持行為不是此刻的重點,因為這份 code 本來最後都要丟掉。

不過這個技巧有兩點要注意,第一是做著做著有可能會把 code 改成「讓自己感覺它在做某件事」,但那可能是誤解,不是系統的真實行為,特別是遇到複雜的條件分支,重構方向如果帶著假設在走,草稿裡的結構就有可能在某個段落偏掉,這樣得出的理解就會是有缺陷的,所以這個技巧也必須要多練習。

第二個陷阱是草稿做到一半覺得「這樣分出來的結構還不錯欸」,就開始想把它 commit,但那個時間點對系統的理解還不完整,很容易把一個「剛好看懂了」的結構當成最終設計,後面真的要改動時反而會被這份草稿給侷限住。

所以說草稿要真的扔掉,不是說說而已,聽起來很浪費,但半小時的草稿探索和半天的焦慮閱讀比起來,前者我自己覺得划算多了 XD。


先把自己變成白痴,直接動手

接下來我們就以草稿重構技巧,把這個當成一份可以隨便玩的程式碼,先不管測試,也不用追求安全重構,大膽把程式碼整理成「看得懂的形狀」。

先從 dur 下手

第一行至少有個看起來能下手的地方:

var dur = eh - sh;
if (dur <= 0) return 0;

兩個輸入相減,結果小於等於 0 就直接結束,而且 dur 後面還會拿去乘上一個數字,現在還不知道 ehsh 的正式意思,不過 dur 看起來很像某種區間性的元素,是 duration?

不知道,沒關係,就先大膽地 Rename:

var duration = eh - sh;
if (duration <= 0) return 0m;

先讓第一個概念浮出來,不管它是不是真的是這個意思,這就是草稿重構的節奏,不用等所有答案到齊才開始,線索會逐漸浮現,並且讓我們知道是不是對的。

PH

接著是這段:

foreach (var h in PH)
    if (h >= sh && h < eh) { pk = true; break; }

它逐一檢查 17 到 23,有沒有任何一個數字落在 sh <= h < eh 裡面,看到這裡,duration 又多了一點線索,sheh 很可能是時間的起點和終點,我們可以先試著改成 startHourendHour

pk 被改成 true 之後似乎又拿去做選擇某種資料,我們還不知道它在幹嘛。

所以這段邏輯似乎在處理有關時間的東西,我們就可以暫時叫做「特殊費率時間」,但還不確定它就是「尖峰時間」。

先試著 Rename,再 Extract Method:

private bool OverlapsSpecialRateHours(int startHour, int endHour)
{
	int[] specialRateHours = [17, 18, 19, 20, 21, 22, 23];
	
    foreach (var hour in specialRateHours)
    {
        if (hour >= startHour && hour < endHour)
            return true;
    }

    return false;
}

原本的迴圈就縮成一句:

var usesSpecialRate = OverlapsSpecialRateHours(startHour, endHour);

做完這兩個修改後,我不會立刻停在原地繼續往下拆,而是回到 Compute 的第一行,重新從頭讀到尾,因為每次 Rename 或 Extract Method,都會讓 code 變成一個新的形狀,原本讀不懂的地方,也可能因為前面多了一個名字而突然接得起來。

現在從頭讀,雖然粗糙,但至少已經可以先念出一個起點故事:

先算出一段 duration,小於等於 0 就結束,接著判斷這段時間有沒有碰到某個特殊費率區間...

這當然還不能算看懂了,裡面甚至有一半都是暫時的猜測,但它已經比一開始好很多,更重要的是,有了好的開始,我們就繼續往下。

費率表

usesSpecialRate 最後流到這裡:

decimal[,] @base =
{
    { 120, 180, 240 },
    { 160, 230, 300 },
    { 250, 360, 450 }
};

var bi = usesSpecialRate ? 2 : (wd ? 1 : 0);
r = @base[rt, bi] * duration;

先不猜 rtwd 的業務意思,只看它們做了什麼,rt 選 row,wd 在沒有使用特殊費率時切換 column,選出的數字再乘上 durationr,所以我們先用行為替它們取暫定名稱,把這一坨當作「費率 row、替代費率開關、房間金額」,r 就先把它叫做是 roomCharge 吧,稍微替這些 Rename 一下:

decimal[,] RateTable =  
{  
    { 120, 180, 240 },  
    { 160, 230, 300 },  
    { 250, 360, 450 }  
};  
var rateColumnIndex = usesSpecialRate ? 2 : (usesAlternateRate ? 1 : 0);  
var rate = RateTable[rateRowIndex, rateColumnIndex];  
roomCharge = rate * duration;

一拆開,我們就來看看目前讀起來合不合理:

  1. rateRowIndex 決定 row,但現在還不知道每個 row 的業務名稱。
  2. 命中特殊費率時直接選第 3 筆費率。
  3. 沒命中特殊費率時,才由另一個 boolean 決定要選第 1 或第 2 筆。
  4. 最後用選到的費率乘上使用時間。

這時候可以再往把整塊抽出一個 method,並且因為後面的程式碼還會用到 RateTable 所以我們把它設為私有成員:

private static readonly decimal[,] RateTable =
{
    { 120, 180, 240 },
    { 160, 230, 300 },
    { 250, 360, 450 }
};
roomCharge = CalculateRoomCharge(
	rateRowIndex,
	duration,
	usesAlternateRate,
	usesSpecialRate);

抽出的 method 暫時長這樣:

private static decimal CalculateRoomCharge(
    int rateRowIndex,
    int duration,
    bool usesAlternateRate,
    bool usesSpecialRate)
{
    var rateColumnIndex = usesSpecialRate ? 2 : (usesAlternateRate ? 1 : 0);
    var rate = RateTable[rateRowIndex, rateColumnIndex];
    return rate * duration;
}

我們依然不知道三個 column 的正式名稱,也不知道特殊費率是什麼, boolean 是為了判斷什麼,但 Compute 已經不用同時背著二維陣列、巢狀三元運算子與迴圈,慢慢地可讀性有了,我們至少知道這段似乎是在計算房間的收費。

而這時候我們還不知道正確設計應該如何,而是先把看起來屬於同一件事的 code 圈在一起,接著看看剩下的故事會長什麼樣子。

跟著 roomCharge 往下看

r 原本裝的是剛算出的房間金額,所以現在我們已經先把命名改成 roomCharge,我們在下一次改變它的地方,很幸運的看到 GetMemberLevel(mid),似乎會拿去查會員等級,mid 可以先試著改成 memberId

var ml = GetMemberLevel(memberId);
if (ml is { Length: > 1 })
{
    if (ml[1] == "gold") roomCharge *= 0.85m;
    else if (ml[1] == "silver") roomCharge *= 0.92m;
}

光看 GetMemberLevelgoldsilver 和兩個倍率,大膽推測房間金額會隨著會員等級做折扣,我們就直接把這塊抽出來試試看:

roomCharge *= GetMemberDiscountRate(memberId);

抽方法出去後再進去稍微整理一下:

private decimal GetMemberDiscountRate(string memberId)
{
    var rate = 0m;
    var memberLevel = GetMemberLevel(memberId);
    if (memberLevel is not { Length: > 1 }) return rate;
    rate = memberLevel[1] switch
    {
        "gold" => 0.85m,
        "silver" => 0.92m,
        _ => 1m
    };
    return rate;
}

現在 method 看起來好像很合理,但別被自己取的名字催眠了,繼續打開 GetMemberLevel

private string[] GetMemberLevel(string memberId)
{
    return memberId switch
    {
        "M001" => ["gold"],
        "M002" => ["silver"],
        _ => []
    };
}

咦?它只會回傳 0 或 1 個元素,goldsilver 都在第 1 格,也就是 index 0,可是剛才的條件要求長度大於 1,照現在的 code,ApplyMemberDiscount 根本不會套用折扣。

這就是直接動手的好處,如果我們只是盯著原本的 mlr 上下對照,很容易看見 gold 就自動腦補成「有打折」,把整塊抽成一個 method 後,method 名稱和實際行為的衝突反而會變得超級明顯。

那要不要立刻把 index 改掉?在這份草稿裡當然可以亂玩,甚至可以把它改成自己猜測的版本,看看上層結構會不會更合理,但那只能叫探索假設,不能變成正式修改,現在還不知道它是 bug、過期 code,還是資料格式改到一半,所以先把這個問題記住,等等整份草稿還是要丟掉。

另外兩筆金額

接著往下看到 ffo,在迴圈中,每次都會把 fo 裡面的 item 用冒號拆開,取第二段轉成 price 乘上 1.1,再把結果累加到 f

到這裡只知道 fo 裝著某種編碼後的項目,還不知道在業務上到底是什麼,所以先用保守一點的名字直接抽方法:

itemCharge = CalculateItemCharge(encodedItems);

裡面先整理成這樣:

private static decimal CalculateItemCharge(string[]? encodedItems)
{
    var itemCharge = 0m;
    if (encodedItems is null) return itemCharge;
        
    foreach (var item in encodedItems)
    {
        var p = item.Split(':');
        if (p.Length == 2 && decimal.TryParse(p[1], out var price))
            itemCharge += price * 1.1m;
    }

    return itemCharge;
}

抽完以後,Compute 不必再管字串怎麼拆,但 1.1 仍然是未知的,我們不知道它是稅、服務費還是別的加成,那些 item 究竟是商品、餐點還是其他東西,也不知道,草稿可以大膽去改,但我建議先暫時不要把這些猜測當成業務語言,可以先把它們保持叫做 item,等到我們試著去跟真實業務做比較:

「什麼東西會乘上 1.1?」

自然到最後我們會知道答案的,吧?

d、gc

再接著往下看,gc 大於 6 時,超出 6 的每一個數量為 30,並將總和加到 d,所以先把 gc 當成某種 count,把這筆錢改成「超過門檻的額外金額」:

additionalCountCharge = count > 6 
    ? (count - 6) * 30m 
    : 0m;

或者想讓 Compute 更薄,也可以繼續抽成:

additionalCountCharge = CalculateAdditionalCountCharge(count);

抽出的 method 可以稍微讓語意更好的表達:

private static decimal CalculateAdditionalCountCharge(int count)
{
    var additionalCount = count - 6;
    return additionalCount > 0 
        ? additionalCount * 30m 
        : 0m;
}

t 、mn

原本最後三行是長這樣的,完全不懂它是在計算什麼:

var t = r + f + d;
var mn = @base[rt, 0] * 2;
return t < mn ? mn : t;

但是經過我們 Rename 、拆出 method 後:

var t = roomCharge + itemCharge + additionalCountCharge;
var mn = RateTable[rateRowIndex, 0] * 2;
return t < mn ? mn : t;

線索慢慢拼湊後,似乎我們也能輕易的看出這是在幹嘛了呢。

t 是房間費用加上不知道什麼 item 的費用,再加上不知道什麼的額外費用總和,那麼我們就可以姑且先幫他命名為 calculatedTotal

接著看最後一行,如果 calculatedTotal 小於 mn 就回傳 mn,否則回傳 calculatedTotal,也就是說,不管前面算出多少,最終結果都不會低於 mn

mn 是什麼?它固定拿費率表同一個 row 的第 1 筆費率,再乘上 2,搭配 RoomBill 這個 class 名稱來看,我們可以先大膽猜它是某種「最低金額」,甚至可能是「兩個單位時間的最低消費」,但 code 只證明它是一條金額下限,並沒有告訴我們乘以 2 的業務原因。

草稿裡先把這個猜測寫進名字,再抽成一個 method:

private static decimal ApplyMinimumCharge(
    decimal calculatedTotal,
    int rateRowIndex)
{
    var minimumCharge = RateTable[rateRowIndex, 0] * 2;
    return calculatedTotal < minimumCharge
        ? minimumCharge
        : calculatedTotal;
}

最後段落就變成:

var calculatedTotal = roomCharge + itemCharge + additionalCountCharge;
return ApplyMinimumCharge(calculatedTotal, rateRowIndex);

ApplyMinimumCharge 讀起來很像需求已經確認,但現在它仍然只是草稿名稱。

完成

現在我們回頭看草稿重構後的版本:

public decimal Compute(
    string memberId,
    int rateRowIndex,
    int startHour,
    int endHour,
    bool usesAlternateRate,
    string[]? encodedItems,
    int count)
{
    var duration = endHour - startHour;
    if (duration <= 0) return 0;

    var usesSpecialRate = OverlapsSpecialRateHours(startHour, endHour);
        
    var roomCharge = CalculateRoomCharge(
        rateRowIndex,
        duration,
        usesAlternateRate,
        usesSpecialRate);

    roomCharge *= GetMemberDiscountRate(memberId);

    var itemCharge = CalculateItemCharge(encodedItems);
    var additionalCountCharge = CalculateAdditionalCountCharge(count);
    var calculatedTotal = roomCharge + itemCharge + additionalCountCharge;
	
    return ApplyMinimumCharge(calculatedTotal, rateRowIndex);
}

原本很醜的程式碼,透過草稿重構,現在至少已經變成一個可以討論的故事了:

先算出一段時間區間,接著判斷這段區間有沒有碰到特殊費率時間,搭配一個 row 和另一個 boolean 選出費率,算出主要金額,然後嘗試套用目前似乎沒有用的會員倍率,再加上字串項目產生的金額與超過數量門檻的額外金額,最後確保結果不低於某個最低金額。

這段故事還混著不少暫定名稱,但我們至少已經能從頭念到尾,不需要一邊記 rfd,一邊回頭找 bi 到底選了哪一格。

不過還有幾個問題我們需要釐清。


草稿寫完,把問題寫出來

探索過程裡冒出的問題:

  • 17 到 23 在業務上到底代表什麼?為什麼命中它時,會優先選第 3 筆費率?
  • 費率表的三個 row、三個 column 各自代表什麼?
  • usesAlternateRate 這個 boolean 到底是什麼?
  • GetMemberLevel 只會回傳 0 或 1 個元素,卻要求長度必須大於 1,這段會員邏輯原本期待的是什麼?因為現在看起來似乎無效。
  • 編碼字串裡的項目是什麼?價格為什麼乘上 1.1,格式錯誤時又為什麼直接略過?
  • count 數的是什麼?為什麼超過 6 之後,每一個要加 30?
  • 最低金額為什麼固定使用第 1 筆費率乘以 2,而不是這次實際選到的費率?

原本我們只知道「這段 code 看不懂」,而現在問題清單在我們做完草稿後浮現了出來。

最後,把這些理解隨手記成一張草圖或幾個 bullet,並稍微把草稿程式碼做一下記錄後,然後真的把整份 branch 或 worktree 丟掉,並且注意別混進主分支,這只是我們的小小實驗室,不要因為草稿看起來比原本舒服就捨不得。


說故事時間

草稿重構做到這裡,我們手上其實已經有兩個產物,一個勉強說得通的流程,還有一堆疑問。

這時候就可以找團隊夥伴一起做 說故事(Telling the Story),在這個階段會敘述草稿重構後的程式碼在做什麼,再請對方針對每一句話追問到底。

這個過程需要多做練習,甚至可以盡可能請對方犀利一點,效果往往會比想像中好,因為每一次被打斷、被要求拿出證據,都會強迫我們重新思考,順便把自己沒發現的盲點挖出來,我自己覺得這蠻像另類的 對抗式審查?一個人負責講,另一個人刻意站在反方挑戰說法。

不過犀利是針對說法,不是針對人,總之有一點也需要特別注意,就是不要討論到吵架 XD

那麼廢話不多說,我們直接拿剛才的 RoomBill 模擬演練一次,這次請一位資深學長坐在旁邊,來和我們一起說故事。

先把草稿講成一條主線

我:「這段 code 在計算房間帳單,先用一段時間、費率表和兩個條件算出房間金額,再嘗試套用會員折扣,加上另外兩筆金額,最後檢查最低消費。」

學長:「聽起來很完整,但你剛才用了『房間金額』、『會員折扣』和『最低消費』,哪些是 code 能證明的,哪些只是你替草稿取的名字?」

我:「code 能證明的是,第一筆金額來自費率乘上時間,會員資料會進入一段倍率判斷,最後結果會和一個下限取較大的值,至於這些名稱是不是正式的業務語言,目前都還不能確定。」

學長:「好,那重講一次,不要把暫定名稱講得像需求規格。」

我:「先依時間與費率表算出一筆金額,接著經過會員倍率判斷,再加上字串項目與超過數量門檻產生的兩筆金額,最後確保結果不低於某個下限。」

這一輪只是在確認草稿可不可以讓故事主線說的出來,並且我們不能把不確定的東西,用我們自以為的語言說出來,所以我才建議另一位夥伴最好要犀利一點,因為這樣才不會也被虛假事物給繞進去,接下來才依照主線故事,一段一段接受審問。

沿著主線往下

先從正文最早拆開的 duration 與特殊費率時間開始:

學長:「duration 真的就是使用時間嗎?」

我:「呼叫端傳進來的是 StartHourEndHour,原始 code 會用後者減前者,小於等於 0 就回傳 0,資料來源和計算方式都有證據,但它在業務上代表預約時間、計費時數,還是別的東西,仍然不能只靠名字確定。」

學長:「那你把 PH 想成特殊費率時間 ,證據呢?」

我:「原始 code 只證明區間內只要包含 17~23 的任一整點,就會選第 3 個 column,而且它會蓋過另一個 boolean 的選擇,『特殊費率』只是我目前覺得最好的命名。」

學長:「很好,『怎麼選』已經知道,『為什麼這樣選』還不知道,不要混在一起。」

接著沿著 roomCharge 進到費率表:

學長:「row 和另一個 boolean 呢?你剛才有沒有也偷偷替它們編故事?」

我:「把呼叫端拿回來看,row 來自 RoomType,另一個 boolean 來自 IsWeekend,所以可以確認呼叫端把它們當成房型與是否為週末狀態傳入。」

學長:「那三個 row、三個 column 分別叫什麼?特殊費率和週末同時成立時,為什麼特殊費率優先?」

我:「我目前從呼叫端看不出來,code 只證明選擇順序,沒有解釋費率名稱與優先順序的業務原因,這些問題要記下來。」

學長好兇

我:「房間金額接著會套用會員折扣。」

學長:「停,原始 code 的折扣真的有套用嗎?」

我:「沒有,GetMemberLevel 只會回傳 0 或 1 個元素,但原始條件要求長度大於 1,接著還讀 index 1,所以照目前的 code 來看,折扣分支不會執行。」

學長:「那就是 bug?」

我:「不能這樣下結論,code 只能證明它目前不會生效,不能證明這違反需求,原本期待的資料格式和意圖可能都還要確認,才能知道目前是不是存在 Bug。」

學長:「很好,還有一件事,你草稿裡的 GetMemberDiscountRate 在資料長度不足時回傳 0,外面又拿房間金額乘上它,結果不是整筆被歸零了?跟你講的不一樣。」

我:「!?痾...這是草稿重構造成的,不是原始系統的現況。」

學長:「阿不就好險你是這是草稿,下次注意一點。」

我:「好...學長好兇...」

這就是請對方犀利一點的價值,對方不只挑戰我們對原始 code 的解讀,也會挑戰草稿本身,如果一個漂亮的 method 名讓我們忘了裡面已經被改壞,故事再順都沒有用,因為會跟我們講出來的故事不符,當遇到這樣的狀況發生時,甚至可以拿出原始 code 出來檢查一下。

學乖了

學長:「你說『字串項目產生的金額』,呼叫端能補上什麼?」

我:「它傳進來的是 FoodOrders,每個字串格式像 name:price,所以目前可以把資料來源理解成餐點,原始 code 則證明它會解析第二段價格、乘上 1.1,格式不符時直接略過。」

學長:「所以 1.1 就是服務費?」

我:「不知道,『餐點』有呼叫端的證據,『乘上 1.1』有 code 的證據,但『服務費』仍然只是探索假設。」

學長:「下一筆呢?」

我:「count 在呼叫端是 GuestCount,原始 code 證明超過 6 的部分,每一個會再加 30,但為什麼門檻是 6、30 代表什麼,都還沒答案。」

學長:「最後的 ApplyMinimumCharge 呢?既然名字都取好了,應該可以直接說是兩小時低消吧?」

我:「不行,code 只證明最後結果不能低於同一個 row 的第 1 筆費率乘以 2,『最低金額』是草稿名稱,『兩小時低消』更只是推測,而且還要追問為什麼不用這次實際選到的費率。」

學長:「很好。現在你終於不是在替 code 寫劇情,而是在分辨每一句話的證據從哪裡來。」

服務費、兩小時低消都很像正確答案,危險也正在這裡,名字越順口,我們越容易忘記它只是探索假設,對話中反覆出現的領域詞彙如果和 code 能證明的事情對不上,就是我們要注意的地方。

統整問題

故事講完還不算結束,最後要把被挑出來的問題變成下一步,而不是只留下「很多事情需要確認」。

學長:「現在請你具體說,每一類問題準備去哪裡找答案?」

我:「現況怎麼算就回原始 code,參數代表什麼就看呼叫端,規則曾經怎麼改就查紀錄,費率名稱、倍率、門檻和會員資料格式,則找需求文件或熟悉規則的人確認。」

學長:「如果 Git、文件和人講的不一樣呢?」

我:「把衝突本身也記成問題,另外我也會去確會員折扣現況應該是怎樣,有沒有存在 Bug。」

學長:「那這份草稿呢?」

我:「把理解、證據與問題留下,草稿 code 丟掉,它剛才已經證明,自己真的會騙人了。」

學長:「很好。」

透過說故事的方式,雖然學長很兇狠,但是卻幫助我們抓到本來就應該要釐清,但是我們不小心有可能忽略掉的問題,在這個階段不是請另一個人直接替我們解答,而是利用對話逼自己去想,哪些事情來自 code?哪些來自呼叫端?哪些只是猜測?還有哪些根本是打草稿的時候自己弄出來的?


總結

不知道各位平常有沒有做草稿重構,雖然這個技巧很早就被寫進書裡,可能看起來有點老了,但我自己還蠻喜歡的,拿到醜 Code 的當下就能先重構,想想就覺得讓人快樂,雖然最終還是得把它丟掉就是了 XD

不過這樣做對我的幫助是,比起一個變數一個方法上下來回查,草稿重構反而更快也更清楚這份 code 的未知問題是什麼,花幾分鐘打草稿浮現出問題,再來釐清問題,我自己覺得是很划算的投資。

寫程式就像在寫文章,怎麼讓人看得懂真是一門大學問,而我們不只要寫出會讓人看得懂的程式碼,還要學會怎麼去看不懂的程式碼,這確實很難也很花時間,難的地方在於不換個角度看就看不到問題,所以好朋友不可少,人際關係要打好:)

今天講述混亂不清晰的程式碼如何透過草稿重構及說故事來釐清,那麽測試的部分呢?

接下來我們繼續看:測試要怎麼管,才不會變成另一種找不到東西的混亂

Reference


上一篇
Day 15 -「機智奧德修斯」故意搞破壞的 Mutation Test
下一篇
Day 17 -「普羅克瑞提斯之床」我明明寫過這個測試啊?
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言