在上一篇文章——重構(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ì)算”代碼
下一步我們就要對(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)后,我們來(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)下效果。


思路總結(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)的條件邏輯