[30天快速上手TDD][Day 11]Refactoring - 讓程式碼說話

[30天快速上手TDD][Day 11]Refactoring - 讓程式碼說話

前言

上一篇文章,介紹了重構的第一步,就是建立測試。跨出了這第一步,才能確保後面的重構動作不會影響到結果。這也是為什麼本系列文章,需要先介紹測試的技巧、目的以及方式。

重構先有了測試保護後,接下來就是想辦法理解程式碼,並且讓程式碼說話,這篇文章會介紹第二式:說人話,以及第三式:垃圾分類

相信我,您絕對可以在 3 ~ 5 分鐘內理解這兩招。

 

目前的程式碼

要重構的程式碼,原始模樣:


protected void btnCalculate_Click(object sender, EventArgs e)
{
    if (this.IsValid)
    {
        if (this.drpCompany.SelectedValue == "1")
        {
            this.lblCompany.Text = "黑貓";
            var weight = Convert.ToDouble(this.txtProductWeight.Text);
            if (weight > 20)
            {
                this.lblCharge.Text = "500";
            }
            else
            {
                var fee = 100 + weight * 10;
                this.lblCharge.Text = fee.ToString();
            }
        }
        else if (this.drpCompany.SelectedValue == "2")
        {
            this.lblCompany.Text = "新竹貨運";
            var length = Convert.ToDouble(this.txtProductLength.Text);
            var width = Convert.ToDouble(this.txtProductWidth.Text);
            var height = Convert.ToDouble(this.txtProductHeight.Text);

            var size = length * width * height;

            //長 x 寬 x 高(公分)x 0.0000353
            if (length > 100 || width > 100 || height > 100)
            {
                this.lblCharge.Text = (size * 0.0000353 * 1100 + 500).ToString();
            }
            else
            {
                this.lblCharge.Text = (size * 0.0000353 * 1200).ToString();
            }
        }
        else if (this.drpCompany.SelectedValue == "3")
        {
            this.lblCompany.Text = "郵局";

            var weight = Convert.ToDouble(this.txtProductWeight.Text);
            var feeByWeight = 80 + weight * 10;

            var length = Convert.ToDouble(this.txtProductLength.Text);
            var width = Convert.ToDouble(this.txtProductWidth.Text);
            var height = Convert.ToDouble(this.txtProductHeight.Text);
            var size = length * width * height;
            var feeBySize = size * 0.0000353 * 1100;

            if (feeByWeight < feeBySize)
            {
                this.lblCharge.Text = feeByWeight.ToString();
            }
            else
            {
                this.lblCharge.Text = feeBySize.ToString();
            }
        }
        else
        {
            var js = "alert('發生不預期錯誤,請洽系統管理者');location.href='http://tw.yahoo.com/';";
            this.ClientScript.RegisterStartupScript(this.GetType(), "back", js, true);
        }
    }
}

 

重構第二式:說人話

當把我們預期的行為錄製完之後,我們要開始對系統進行淨化的動作了。(相信我,每一招都是『易如反掌』,你絕對可以做到)

可以看一下我們原本重構的目標,那就是一坨散落一地攤著的 code 。很有可能有著奇怪的邏輯判斷式,複雜的演算法,亂七八糟的排版,甚至是一些已經過期的註解,都有可能是引誘你上當的陷阱跟地雷。但在咒罵前人之餘,我們先靜下心來。

因為淨化程式碼的第一步,就是說人話:面對程式碼,試著用人類的語言,去描述程式碼在做什麼事。正如同小說需要角色來說話,系統也需要程式碼來說話。

計算運費

以這例子來說,上圖的程式碼,就可以用一句話來表達:『計算運費』。

所以,我們先為原本的程式加上人話。不需要修改任何程式碼,只需要理解程式碼的目的、行為後,為程式碼加上人類語言的註解,並且將每一件事(不是每一行 code ,而是每一段 code )排版分開來。

要注意的是,不需要每一行程式都加上人話,而是針對事情、行為來描述

經過這一步,加上註解與排版的程式碼如下所示:


protected void btnCalculate_Click(object sender, EventArgs e)
{
    if (this.IsValid)
    {
        //選黑貓,計算出運費,呈現物流商名稱與運費
        if (this.drpCompany.SelectedValue == "1")
        {
            this.lblCompany.Text = "黑貓";
            var weight = Convert.ToDouble(this.txtProductWeight.Text);
            if (weight > 20)
            {
                this.lblCharge.Text = "500";
            }
            else
            {
                var fee = 100 + weight * 10;
                this.lblCharge.Text = fee.ToString();
            }
        }
        //選新竹貨運,計算出運費,呈現物流商名稱與運費
        else if (this.drpCompany.SelectedValue == "2")
        {
            this.lblCompany.Text = "新竹貨運";
            var length = Convert.ToDouble(this.txtProductLength.Text);
            var width = Convert.ToDouble(this.txtProductWidth.Text);
            var height = Convert.ToDouble(this.txtProductHeight.Text);

            var size = length * width * height;

            //長 x 寬 x 高(公分)x 0.0000353
            if (length > 100 || width > 100 || height > 100)
            {
                this.lblCharge.Text = (size * 0.0000353 * 1100 + 500).ToString();
            }
            else
            {
                this.lblCharge.Text = (size * 0.0000353 * 1200).ToString();
            }
        }
        //選郵局,計算出運費,呈現物流商名稱與運費
        else if (this.drpCompany.SelectedValue == "3")
        {
            this.lblCompany.Text = "郵局";

            var weight = Convert.ToDouble(this.txtProductWeight.Text);
            var feeByWeight = 80 + weight * 10;

            var length = Convert.ToDouble(this.txtProductLength.Text);
            var width = Convert.ToDouble(this.txtProductWidth.Text);
            var height = Convert.ToDouble(this.txtProductHeight.Text);
            var size = length * width * height;
            var feeBySize = size * 0.0000353 * 1100;

            if (feeByWeight < feeBySize)
            {
                this.lblCharge.Text = feeByWeight.ToString();
            }
            else
            {
                this.lblCharge.Text = feeBySize.ToString();
            }
        }
        //發生預期以外的狀況,呈現警告訊息,回首頁
        else
        {
            var js = "alert('發生不預期錯誤,請洽系統管理者');location.href='http://tw.yahoo.com/';";
            this.ClientScript.RegisterStartupScript(this.GetType(), "back", js, true);
        }
    }
}

這邊只加上了四行註解,分別是:

  1. 選了哪一間物流商。
  2. 計算出運費。
  3. 呈現物流商的名稱與運費結果。
  4. 防呆的處理。

別忘嚕,我們最終的目標是,讓程式碼自己會說話!

 

說人話的效益

這時候,其實開發人員的腦袋,已經大致上釐清這一大段程式碼的基本目的為何,裡面每一件事情抽象地來說,要做什麼事情。

這是很重要的一步,只有腦袋先想清楚了,接著透過註解、排版,來讓下次腦袋不用這麼辛苦,還得重新解譯一次這一坨鳥 code 。

 

重構第三式:垃圾分類

程式碼本身的現況,仍然跟一坨垃圾沒什麼兩樣,又髒又臭。

但有了上一步「人話的描述」之後,就可以簡單的為其進行「垃圾分類」的動作。

什麼是垃圾分類?就是下圖這樣(笑):

垃圾分類

簡單的說,就是重構中的『擷取方法』。

透過 Visual Studio 的輔助,只需要:

  1. 把原本人話所描述的區塊選取後;
  2. 按滑鼠右鍵,選擇「重構」;
  3. 選擇「擷取方法」

如下圖所示:

extre method

接著把「人話的意義」當做「新方法的名稱」,這個動作就算完成了。如下圖所示:

method naming

 

垃圾分類完的版本

經過[重構]>[擷取方法]後,程式碼如下:


protected void btnCalculate_Click(object sender, EventArgs e)
{
    //若頁面通過驗證
    if (this.IsValid)
    {
        //選黑貓,計算出運費,呈現物流商名稱與運費
        if (this.drpCompany.SelectedValue == "1")
        {
            CalculatedByBlackCat();
        }
        //選新竹貨運,計算出運費,呈現物流商名稱與運費
        else if (this.drpCompany.SelectedValue == "2")
        {
            CalculatedByHsinchu();
        }
        //選郵局,計算出運費,呈現物流商名稱與運費
        else if (this.drpCompany.SelectedValue == "3")
        {
            CalculatedByPostOffice();
        }
        //發生預期以外的狀況,呈現警告訊息,回首頁
        else
        {
            var js = "alert('發生不預期錯誤,請洽系統管理者');location.href='http://tw.yahoo.com/';";
            this.ClientScript.RegisterStartupScript(this.GetType(), "back", js, true);
        }
    }
}

private void CalculatedByPostOffice()
{
    this.lblCompany.Text = "郵局";

    var weight = Convert.ToDouble(this.txtProductWeight.Text);
    var feeByWeight = 80 + weight * 10;

    var length = Convert.ToDouble(this.txtProductLength.Text);
    var width = Convert.ToDouble(this.txtProductWidth.Text);
    var height = Convert.ToDouble(this.txtProductHeight.Text);
    var size = length * width * height;
    var feeBySize = size * 0.0000353 * 1100;

    if (feeByWeight < feeBySize)
    {
        this.lblCharge.Text = feeByWeight.ToString();
    }
    else
    {
        this.lblCharge.Text = feeBySize.ToString();
    }
}

private void CalculatedByHsinchu()
{
    this.lblCompany.Text = "新竹貨運";
    var length = Convert.ToDouble(this.txtProductLength.Text);
    var width = Convert.ToDouble(this.txtProductWidth.Text);
    var height = Convert.ToDouble(this.txtProductHeight.Text);

    var size = length * width * height;

    //長 x 寬 x 高(公分)x 0.0000353
    if (length > 100 || width > 100 || height > 100)
    {
        this.lblCharge.Text = (size * 0.0000353 * 1100 + 500).ToString();
    }
    else
    {
        this.lblCharge.Text = (size * 0.0000353 * 1200).ToString();
    }
}

private void CalculatedByBlackCat()
{
    this.lblCompany.Text = "黑貓";
    var weight = Convert.ToDouble(this.txtProductWeight.Text);
    if (weight > 20)
    {
        this.lblCharge.Text = "500";
    }
    else
    {
        var fee = 100 + weight * 10;
        this.lblCharge.Text = fee.ToString();
    }
}

由於有修改程式了,所以請記得跑一下 Selenium 的測試腳本,以確保垃圾分類完後,結果仍如預期般正確。如下圖所示:

很好,程式碼經過擷取方法後,仍然通過 UI 面的測試(這個測試其實可以稱作 Acceptance Testing ,也可以稱作 Integration Testing ),符合使用者的需求。

到這邊的手法,讀者朋友也可以參考小弟之前寫過的文章:[ASP.NET]重構之路系列v1 – UI, Business logic, Data access概念分開

 

小結

到目前為止,整個系列的文章,幾乎都是一環扣著一環。

先介紹測試如何進行,是因為重構的第一步,就是測試。

而有了測試的保護之後,就是讓開發人員發揮「抽象設計」的能力。透過第二式:說人話,促使開發人員在面對亂七八糟的 legacy code 時,可以先用心思考,每一段程式碼的目的與意義。套一句 Ruddy 老師的話,開發人員應該把自己拉到三萬英尺的高空,去觀看這段程式碼。

重構最容易出現的問題,就是開發人員把自己陷入程式碼的細節中,就像進入了八卦陣,當局者迷,走不出來。當沒有測試保護時,改了一堆細節之後,可能是一塌糊塗,也可能是瞎貓碰上死耗子,也可能根本沒測到問題,但程式碼其實已經出錯了。

透過第二式的排版,並加上註解,來輔助開發人員一一釐清程式碼。還有個美妙的地方是,當第二式完成後,其實一行程式碼都沒改到,但是開發人員卻不會像一開始這麼爆怒了。為什麼?因為他面對的,是自己改過的 code 了,人嘛…這時候就會覺得自己排版過,加過註解的程式碼,比剛剛那陀垃圾要好看、好懂十幾倍。

只要能讓自己靜下心來思考,又對未來的可讀性、維護性有幫助,又沒什麼風險與成本,不要小看了這一步的效益。另外,第三式可是承接著第二式而來。

有了前面的排版與註解,開發人員腦袋中也已經瞭解了要重構的程式碼全貌與目的(記得,要跳脫細節),接下來,只需要接著上一步的動作,將每一件事,也就是每一段程式碼,透過擷取方法,把人話的註解,變成一個個的 function 。透過工具的輔助,就可以輕鬆完成。

這時候,這些人話註解,就會成為每一個 function 的 API document ,在 C# 中,也就是 /// 區塊,例如 <summary> 。

每一件事,每一段程式碼,都變成了一句話,每一句話,都變成了一個function。

更讚的是,基本上,我們還沒動手異動程式碼的邏輯。但是,整個原本程式碼的區塊,卻變成光看程式碼的命名,就能闡述整段程式碼的目的跟過程。(筆者真的幻想有一天,把整段程式碼貼到 google 翻譯時,可以透過發音就把故事說出來的感覺)

讓我再貼一次剛剛第二式+第三式的結果:

code can tell a story

可以感受一下節奏,當按了計算運費的按鈕後:

  1. 若頁面通過驗證,則
  2. 當選[黑貓]時,計算出運費,呈現物流商名稱與運費;
  3. 當選[新竹貨運]時,計算出運費,呈現物流商名稱與運費;
  4. 當選[郵局]時,計算出運費,呈現物流商名稱與運費;
  5. 當發生預期以外的狀況時,呈現警告訊息,回首頁。

很棒,不是嗎?

很簡單,對吧?

最後要叮嚀的是,擷取方法有可能會導致一些參數或回傳值有所變化,但基本上絕不會影響到原本程式碼的邏輯。但已經有了自動化測試的保護,就不要客氣,給自己個獎勵,改完程式,按下測試,就可以讓自己稍微休息一下,喝杯咖啡,上個廁所。

重構的節奏就是,建立綠燈,移動程式,按下測試,打完收工。接著繼續往下一步前進

這樣的節奏,會使開發人員很容易進入「flow」的狀態,不容易累,也不容易寫錯程式而導致重工。建議可以搭配「蕃茄鐘工作法」,讓自己集中精神在每個 25 分鐘。

亞里斯多德說過:「卓越不是行為,而是習慣。」 在軟體專案成功的管理之道Ship it! A Practical Guide to Successful Software Projects一書中也提到,要刻意的去培養好的習慣,而不是讓自己不經意的被壞習慣纏身。

上述的心法、手法,都是一種習慣的養成,每一步都可以讓你更加輕鬆、進入狀態、提高生產力與品質,建議各位讀者可以多多練習,這一整個系列的手法與開發方式,最後就會是您的習慣,最後就會成就一個卓越的系統。

對敏捷開發有興趣的朋友,可以參考我的粉絲專頁:91敏捷開發之路

對 TDD 課程有興趣的朋友,課程內容、大綱與學員心得,可以參考 skilltree 的公開課程:自動測試與 TDD 實務開發

若需要聯絡我,可以透過粉絲專頁私訊或是側欄的關於我。