重構(gòu),第一個(gè)案例(C++版)——分解并重組Statement()

在上一篇文章——重構(gòu),第一個(gè)案例(C++版)——最初的程序,我們已經(jīng)實(shí)現(xiàn)了一個(gè)影片出租程序的最初版本。我們也分析了,這個(gè)版本的程序雖然能跑起來(lái),沒(méi)有bug。但是,明顯的,程序中有一些“代碼的壞味道”。為了重構(gòu)它,我們首先寫出了一段測(cè)試代碼,方便我們重構(gòu)的時(shí)候進(jìn)行測(cè)試。

下面我們的重點(diǎn)就在于將已經(jīng)看得很不順眼的長(zhǎng)函數(shù)Statement進(jìn)行大卸八塊了。記住,代碼塊越小,代碼的功能就越容易管理,代碼的處理和移動(dòng)也就越輕松。

解決邏輯泥團(tuán)

首先,在Statement()中我們一眼能看到的就是包含switch語(yǔ)句的這團(tuán)邏輯泥團(tuán)。我們將用Extrate Method(提煉函數(shù))的方法將這團(tuán)代碼提煉成一個(gè)函數(shù)。經(jīng)過(guò)重構(gòu)的部分代碼如下, Statement函數(shù)中沒(méi)有改動(dòng)的部分我用...省略表示了(后面代碼中的...都表示省略的相同代碼,不贅述)。

    string Statement() {
        ...
        this_amount = AmountFor(each);
        ...
    }

private:
    double AmountFor(Rental each) {
        double this_amount = 0;
        switch (each.GetMovie().GetPriceCode()) {
        case PriceCode::REGULAR:
            this_amount += 2;
            if (each.GetDaysRented() > 2)
                this_amount += (each.GetDaysRented() - 2) * 1.5;
            break;
        case PriceCode::NEW_RELEASE:
            this_amount += each.GetDaysRented() * 3;
            break;
        case PriceCode::CHILDREN:
            this_amount += 1.5;
            if (each.GetDaysRented() > 3)
                this_amount += (each.GetDaysRented() - 3) * 1.5;
            break;
        }
        return this_amount;                          
    }

書中,作者還展示了他在重構(gòu)的一開始,錯(cuò)誤的將提煉后的函數(shù)返回值定義成int,然后運(yùn)行測(cè)試代碼后就馬上發(fā)現(xiàn)了錯(cuò)誤。顯示了測(cè)試在代碼重構(gòu)過(guò)程中的重要性。作者總結(jié)道:

重構(gòu)技術(shù)就是以微小的步伐修改程序。如果你犯下錯(cuò)誤,很容易便可發(fā)現(xiàn)它。

然后,我們發(fā)現(xiàn)新提煉出來(lái)的AmountFor函數(shù)中有些變量名是讓人不好理解的。這里可以更改一下這些變量名,將入?yún)⒏臑橛蒭ach改為rental,將計(jì)算后的返回值由this_amount改為result,更名后代碼如下。

private:
    double AmountFor(Rental rental) {
        double result = 0;
        switch (rental.GetMovie().GetPriceCode()) {
        case PriceCode::REGULAR:
            result += 2;
            if (rental.GetDaysRented() > 2)
                result += (rental.GetDaysRented() - 2) * 1.5;
            break;
        case PriceCode::NEW_RELEASE:
            result += rental.GetDaysRented() * 3;
            break;
        case PriceCode::CHILDREN:
            result += 1.5;
            if (rental.GetDaysRented() > 3)
                result += (rental.GetDaysRented() - 3) * 1.5;
            break;
        }
        return result;                          
    }

為什么要做這樣看似無(wú)用的操作呢?因?yàn)椋兞棵Q是代碼清晰的關(guān)鍵。而使代碼清晰,方便其他人閱讀、理解是每個(gè)程序員應(yīng)有的覺(jué)悟。目前,無(wú)論是編輯器還是ide都給我們提供了很多很方便更改名稱的重構(gòu)功能,那么為什么不用起來(lái)呢。沒(méi)有注釋,就是最好的注釋,因?yàn)槟愕拇a已經(jīng)很清晰的表達(dá)了你的想法。

任何一個(gè)傻瓜都能寫出計(jì)算機(jī)可以理解的代碼。唯有寫出人類容易理解的代碼,才是優(yōu)秀的程序員。

搬移“金額計(jì)算”代碼

下面我們將注意力放在剛剛提煉出來(lái)的AmountFor函數(shù)中,我們可以發(fā)現(xiàn)該函數(shù)中使用了Rental類的函數(shù),而沒(méi)有使用它所在的對(duì)象Customer類中的函數(shù)或者變量。這里我們就應(yīng)該感覺(jué)到有問(wèn)題了。

絕大多數(shù)情況下,函數(shù)應(yīng)該放在它所使用的數(shù)據(jù)的所屬對(duì)象內(nèi)。

我們應(yīng)該使用Move Method(搬移函數(shù))的方法,將AmountFor()搬移到Rental類中。首先,我們要把代碼復(fù)制到Rental類中。然后,讓它“適應(yīng)新家”——要做的工作包括,去掉參數(shù)rental,更改函數(shù)名稱為GetCharge。最后在AmountFor函數(shù)中調(diào)用我們剛剛搬移的新函數(shù)。修改完成后編譯運(yùn)行下測(cè)試,看看有沒(méi)有問(wèn)題。修改的代碼如下。

class Rental
{
    ...
    double GetCharge() {
        double result = 0;
        switch (GetMovie().GetPriceCode()) {
        case PriceCode::REGULAR:
            result += 2;
            if (GetDaysRented() > 2)
                result += (GetDaysRented() - 2) * 1.5;
            break;
        case PriceCode::NEW_RELEASE:
            result += GetDaysRented() * 3;
            break;
        case PriceCode::CHILDREN:
            result += 1.5;
            if (GetDaysRented() > 3)
                result += (GetDaysRented() - 3) * 1.5;
            break;
        }
        return result;                          
    }
    ...
}

class Customer
{
    ...
private:
    double AmountFor(Rental each) {
        return each.GetCharge();                          
    }
    ...
}

搬移完函數(shù)后,我們就需要做對(duì)舊函數(shù)的善后工作了。首先找出所有使用到舊函數(shù)AmountFor()的位置,將其替換為我們的新函數(shù)。所幸,在我們這個(gè)程序中只有Statement函數(shù)中這一處使用了它,修改起來(lái)比較簡(jiǎn)單。一般情況下,我們可以使用查找工具找出舊函數(shù)的所有引用的地方。替換工作完成后,我們就可以刪除掉舊函數(shù)。最后,按照慣例,我們需要運(yùn)行下測(cè)試代碼,看看有沒(méi)有改出什么新問(wèn)題。這里我們修改的代碼就比較簡(jiǎn)單了(修改的代碼省略了刪除的說(shuō)明,下同)。

class Customer
{
    ...
    this_amount = each.GetCharge();
    ...
}

接下來(lái)我們將會(huì)注意到剛剛修改的地方,有一個(gè)變量this_amount顯得有點(diǎn)多余了。它被each.GetCharge()賦值后就沒(méi)有任何變化了。所以,我們可以使用Replace Temp with Query(以查詢?nèi)〈R時(shí)變量)。這里的查詢實(shí)際上是指的一個(gè)函數(shù)表達(dá)式。在我們的程序中就是刪除掉this_amount,將使用這個(gè)臨時(shí)變量的位置直接用each.GetCharge()來(lái)替換。

class Customer
{
    ...
    result += "\t" + each.GetMovie().GetTitle() + "\t" +
        to_string(each.GetCharge()) + "\n";
    total_amount += each.GetCharge();
    ...
}

這里對(duì)臨時(shí)變量的修改會(huì)付出性能上的代價(jià),因?yàn)槿サ袅伺R時(shí)變量,each.GetCharge()會(huì)被調(diào)用兩次。但作者覺(jué)得這種代價(jià)是值得的,這樣的改動(dòng)更有利于今后的優(yōu)化。為此作者專門用了一節(jié)——“重構(gòu)與性能”來(lái)談這個(gè)問(wèn)題,有興趣的同學(xué)可以找書來(lái)看看。后文中我也會(huì)提到一點(diǎn)。

經(jīng)過(guò)這次修改后,我們程序的UML類圖變?yōu)榱讼旅孢@樣。


搬移“金額計(jì)算”代碼后的UML類圖

提煉“常客積分計(jì)算”代碼

下一步我們就要對(duì)“??头e分計(jì)算”這部分的代碼做類似上面的處理了。簡(jiǎn)單分析下,積分的計(jì)算一般只會(huì)因?yàn)橛捌N類而不同,且其計(jì)算方式一般變動(dòng)不大。那么這部分的代碼像“金額計(jì)算”一樣搬移在Rental類中應(yīng)該是合適的。這里我們使用的重構(gòu)方法也和上面類似,使用Extrate Method(提煉函數(shù))提煉出新的“??头e分計(jì)算”的函數(shù)。然后使用Move Method(搬移函數(shù))將新函數(shù)搬移到Rental類中使它適應(yīng)新家。最后常規(guī)操作,運(yùn)行測(cè)試代碼。下面是這次修改后的代碼。

class Rental
{
    ...
    int GetFrequentRenterPoints() {
        if (GetMovie().GetPriceCode() == PriceCode::NEW_RELEASE &&
            GetDaysRented() > 1)
            return 2;
        else
            return 1;   
    }
    ...
}

class Customer
{
    ...
    string Statement() {
    ...
            // add frequent renter points
            frequent_renter_points += each.GetFrequentRenterPoints();
    ...
    }
    ...
}

去除臨時(shí)變量

最后,我們要針對(duì)Statement函數(shù)中兩個(gè)臨時(shí)變量進(jìn)行重構(gòu)。在這個(gè)函數(shù)的開始部分我們定義了兩個(gè)總量的臨時(shí)變量——total_amount和frequent_renter_points。這兩個(gè)變量都是用來(lái)從Customer對(duì)象相關(guān)的Rental對(duì)象中獲取某個(gè)總量的?,F(xiàn)在我們Statement函數(shù)的輸出是ASCII版的,如果以后我們要增加其他輸出版本的(而這是很有可能發(fā)生的),比如需要HTML版的,那么獲取價(jià)格總量和積分總量的函數(shù)就是這兩個(gè)版本都需要的。這就是促使我們進(jìn)行這個(gè)重構(gòu)的理由之一。重構(gòu)方式也很簡(jiǎn)單,在刪除掉這兩個(gè)臨時(shí)變量后,我們需要提供兩個(gè)private的函數(shù)來(lái)替代者兩個(gè)臨時(shí)變量。當(dāng)然在這兩個(gè)新函數(shù)中不可避免的會(huì)有包含有循環(huán)語(yǔ)句的代碼,這里就可能會(huì)影響程序的性能。關(guān)于這個(gè)問(wèn)題,我們后面再來(lái)討論。經(jīng)過(guò)這輪重構(gòu)后,我們也可以看到Statement函數(shù)已經(jīng)變得比之前“清爽”太多了??梢詠?lái)看看目前的代碼。

class Customer
{
    ...
        string Statement() {
        string result = "Rental Record for " + GetName() + "\n";
        for (auto& each : rentals_) {
            // show figures for this rental
            result += "\t" + each.GetMovie().GetTitle() + "\t" +
                to_string(each.GetCharge()) + "\n";
        }

        // add footer lines
        result += "Amount owed is " + to_string(GetTotalCharge()) + "\n";
        result += "You earned " + to_string(GetTotalFrequentRenterPoints()) +
            " frequent renter points";
        
        return result;
    }

private:
    double GetTotalCharge() {
        double result = 0;
        for (auto& each : rentals_) {
            result += each.GetCharge();
        }
        return result;
    }
    double GetTotalFrequentRenterPoints() {
        double result = 0;
        for (auto& each : rentals_) {
            result += each.GetFrequentRenterPoints();
        }
        return result;
    }
    ...
}

經(jīng)過(guò)重構(gòu)后我們的UML類圖和序列圖如下。


重構(gòu)后的UML類圖

重構(gòu)后的序列圖

這輪重構(gòu)后,我們來(lái)做個(gè)小小的總結(jié)。先來(lái)說(shuō)說(shuō)經(jīng)過(guò)這輪重構(gòu)后我們得到了什么。首先,我們得到了一個(gè)“清爽的”、擴(kuò)展性更強(qiáng)的Statement函數(shù)。并且我們讓類的結(jié)構(gòu)更合理了。那么我們引來(lái)了什么問(wèn)題呢?通過(guò)運(yùn)行測(cè)試代碼,我們可以肯定,經(jīng)過(guò)重構(gòu)我們沒(méi)有引入bug,這是非常重要的。但是,我們還是引入了兩個(gè)問(wèn)題。第一個(gè)是我們?cè)黾恿舜a總量,雖然重構(gòu)通常會(huì)減少代碼總量,這次我們?cè)黾恿舜a總量,但我覺(jué)得在這次重構(gòu)中這個(gè)問(wèn)題影響不大。第二個(gè)就是之前說(shuō)得性能問(wèn)題,我們?cè)谌サ鬝tatement函數(shù)中的兩個(gè)臨時(shí)變量時(shí),增加了兩個(gè)新的函數(shù),而這兩個(gè)新函數(shù)中都包含了循環(huán)語(yǔ)句。也就是說(shuō)經(jīng)過(guò)重構(gòu)我們的Statement函數(shù)會(huì)執(zhí)行3次同樣的循環(huán)過(guò)程。如果這里循環(huán)耗時(shí)過(guò)多的話,就很可能會(huì)大大的降低程序的性能。因?yàn)檫@個(gè)原因,包括我在內(nèi)很多程序員一開始會(huì)不愿意進(jìn)行這個(gè)重構(gòu)。但是,這里作者給出了他的兩個(gè)理由。而這兩個(gè)理由我是接受的。

  • 第一個(gè)理由實(shí)際上是作者專門討論這個(gè)問(wèn)題的章節(jié)“重構(gòu)與性能”中所提及的核心觀點(diǎn),也就是目前我們不確定這個(gè)循環(huán)的執(zhí)行時(shí)間是否會(huì)構(gòu)成我們的性能瓶頸(在不大部分情況下,實(shí)際影響不大)。所以在重構(gòu)時(shí),我們可以先忽略這個(gè)問(wèn)題,而在優(yōu)化程序性能的階段,通過(guò)專門的工具去找到程序的性能瓶頸,在針對(duì)瓶頸進(jìn)行優(yōu)化。這時(shí)候的優(yōu)化因?yàn)橹貥?gòu)后結(jié)構(gòu)更清晰,會(huì)使得優(yōu)化效果更好,也更容易。
  • 而第二個(gè)理由則是,通過(guò)新函數(shù)的提煉,實(shí)際上我們?cè)贑ustomer類中增加了兩個(gè)新功能。作為private函數(shù),我們?cè)贑ustomer類中要新增新版本的Statement函數(shù)時(shí),就可以使用者兩個(gè)新函數(shù)。而如果外面有地方需要計(jì)算這兩個(gè)總量時(shí),我們也可以開放這兩個(gè)函數(shù),將其改為public的。這樣外面使用時(shí),也只用調(diào)用Customer類的接口,而不用去了解Rental類。

擴(kuò)展打印輸入

接下來(lái)我們可以來(lái)簡(jiǎn)單的檢驗(yàn)下我們重構(gòu)的效果。比如前面提到的情況發(fā)生了,現(xiàn)在我們的客戶提出了輸出需要變成HTML格式的。那么為了添加這個(gè)功能,我們只用添加一個(gè)HTML版的Statement函數(shù)即可。其代碼如下。

    string HtmlStatement() {
        string result = "<H1>Rental for <EM>" + GetName() + "</EM></H1><p>\n";
        for (auto& each : rentals_) {
            // show figures for this rental
            result +=  each.GetMovie().GetTitle() + ":" +
                to_string(each.GetCharge()) + "<BR>\n";
        }

        // add footer lines
        result += "<P> You owe <EM>" + to_string(GetTotalCharge()) + "</EM><P>\n";
        result += "On this rental you earned <EM>" + to_string(GetTotalFrequentRenterPoints()) +
            "<EM> frequent renter points<P>";
        
        return result;
    }

可以看到這個(gè)HTML版的Statement寫起來(lái)很方便,基本邏輯都是復(fù)制的已經(jīng)重構(gòu)好的Statement(),當(dāng)然這里還存在著一些重復(fù)的代碼,我們可以通過(guò)進(jìn)一步的重構(gòu)消除它們,但這個(gè)不在目前的討論范圍。目前這樣的代碼的好處也很明顯,如果客戶想改變金額的計(jì)算方式,或者是積分的計(jì)算方式,那么我們只需要在程序的一處做修改(Rental類中相應(yīng)的函數(shù)),從而避免了散彈式修改。
最后我們還是來(lái)運(yùn)行下測(cè)試代碼,檢驗(yàn)下效果。


程序運(yùn)行結(jié)果.png

HTML顯示效果.png

思路總結(jié)

  • 找出程序中的邏輯泥團(tuán)。
  • 分析代碼結(jié)構(gòu),提煉、搬移函數(shù),讓它們“各回各家”。
  • 去除臨時(shí)變量,讓結(jié)構(gòu)更清晰。
  • 每一小步重構(gòu)都需要運(yùn)行測(cè)試代碼以避免引入bug。

本系列文章

找出那些代碼里的壞味道吧——《重構(gòu)》筆記(一)
重構(gòu),第一個(gè)案例(C++版)——最初的程序
重構(gòu),第一個(gè)案例(C++版)——分解并重組Statement()
重構(gòu),第一個(gè)案例(C++版)——運(yùn)用多態(tài)取代與價(jià)格相關(guān)的條件邏輯

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容