讀重構(gòu)(改善既有代碼的設(shè)計(jì))一點(diǎn)心得
序言
距離我讀重構(gòu)這本書(shū)已經(jīng)過(guò)去了很久,最近需要分享一下讀這本書(shū)的感悟心得,可能不能詳盡的闡述書(shū)中提到的所有細(xì)節(jié),不過(guò)我會(huì)盡力而為。雖然這么說(shuō),當(dāng)初的印象已經(jīng)不深了,所以又快速回顧了一下,由于看的是中譯本,不管是因?yàn)樽陨淼脑?,還是譯者與原作者理解上的偏差,如果有紕漏還請(qǐng)見(jiàn)諒(有能力的可以看一下英文原版)
正式重構(gòu)代碼之前
作者在介紹重構(gòu)代碼的技巧或者說(shuō)方法之前,先是使用一個(gè)簡(jiǎn)單的案例作為整本書(shū)的起始,雖然簡(jiǎn)單,但涵蓋了大量的重構(gòu)方法,有的方法也許看上去太過(guò)簡(jiǎn)單,是我們每天都在用的,不過(guò)在看后續(xù)分別介紹各種重構(gòu)方法時(shí),可以回過(guò)頭來(lái)與這個(gè)案例結(jié)合,思考作者的重構(gòu)思路,便是不小的收獲了。
接著這個(gè)案例的后續(xù)三個(gè)章節(jié),是對(duì)正式重構(gòu)之前,一些概念、注意點(diǎn)的梳理,讓我們來(lái)看一下:
首先需要給出的一點(diǎn)便是,什么是重構(gòu),在重構(gòu)原則這一章中,作者在這里給出了兩個(gè)定義,分別是對(duì)重構(gòu)作為名詞和動(dòng)詞時(shí)的概念。
重構(gòu)作為名詞時(shí),其定義是:對(duì)軟件內(nèi)部結(jié)構(gòu)的一種調(diào)整,目的是在不改變軟件可觀察行為的前提下,提高其可理解性,降低其修改成本。
重構(gòu)作為動(dòng)詞時(shí),其定義是:使用一系列重構(gòu)手法,在不改變軟件可觀察行為的前提下,調(diào)整其結(jié)構(gòu)。
這是原作者的定義,不管作為動(dòng)詞或者名詞,重構(gòu)這一概念,其前提都是需要不改變軟件可觀察行為,而需要改變的是軟件內(nèi)部結(jié)構(gòu),這也是指導(dǎo)重構(gòu)方法的核心原則。
作者在介紹重構(gòu)概念時(shí),單獨(dú)寫(xiě)個(gè)一個(gè)部分,以兩頂帽子為標(biāo)題,作為重構(gòu)的前提即不改變軟件可觀察行為的引申,也可以看做對(duì)這個(gè)概念進(jìn)一步的闡述。兩頂帽子比喻在開(kāi)發(fā)過(guò)程中,程序員會(huì)進(jìn)行兩種類(lèi)型的工作,重構(gòu)以及添加新功能。當(dāng)你在做其中一件事時(shí),就好比戴上了對(duì)應(yīng)的帽子,此時(shí)因?yàn)閷?zhuān)注于這件事上,所以當(dāng)你在重構(gòu)時(shí),不應(yīng)該添加新功能,反之亦然,這也就是作者說(shuō)的重構(gòu)需要不改變軟件可觀察行為。同一時(shí)間只應(yīng)該戴上一頂帽子,并且應(yīng)該清楚自己現(xiàn)在是戴的那頂帽子。(如果你說(shuō)同時(shí)戴兩頂帽子也可以,那就祝你好運(yùn))
下面作者闡述了為何需要重構(gòu),這里不展開(kāi)說(shuō)明。接下來(lái)是何時(shí)重構(gòu),這個(gè)也許也是需要提一下的。作者反對(duì)專(zhuān)門(mén)抽出時(shí)間來(lái)做重構(gòu),他的觀點(diǎn)中,重構(gòu)不應(yīng)該是目的,而是手段,是可以幫助你做好其他事的手段。
這里作者提到了一個(gè)準(zhǔn)則,也許可以作為參考:
事不過(guò)三,三則重構(gòu)。這也是對(duì)經(jīng)典的DRY原則(Don't repeat yourself)的一種應(yīng)用,當(dāng)出現(xiàn)重復(fù)時(shí),便是需要重構(gòu)的時(shí)候。作者在后面還寫(xiě)了其他重構(gòu)時(shí)機(jī),這里不展開(kāi)。
作者在重構(gòu)的難題中,提到了一些需要注意的地方。不要過(guò)早的發(fā)布接口,注意管理自己負(fù)責(zé)的代碼的可見(jiàn)性,也就是說(shuō),在團(tuán)隊(duì)協(xié)作時(shí),如果其他團(tuán)隊(duì)成員越多的使用了你負(fù)責(zé)的代碼,對(duì)于你修改這部分代碼,就會(huì)越發(fā)麻煩,如果這些代碼只有你自己在使用,修改起來(lái)就會(huì)相對(duì)輕松不少。更關(guān)鍵的是,這還是同公司一個(gè)開(kāi)發(fā)團(tuán)隊(duì)中,如果這些代碼需要繼續(xù)向外開(kāi)放,那么設(shè)計(jì)這部分代碼的可見(jiàn)性時(shí),需要更加的小心仔細(xì)。
關(guān)于何時(shí)不應(yīng)該重構(gòu),作者的闡述中,可以感受到其對(duì)這一點(diǎn)的謹(jǐn)慎態(tài)度。如果可能的話,重構(gòu)應(yīng)該是一直需要的,也許關(guān)于這一點(diǎn),影響程序員做出此時(shí)不應(yīng)該重構(gòu)的因素,很有可能不是技術(shù)上的。
在代碼的壞味道這一章中,作者通過(guò)這個(gè)比喻,來(lái)說(shuō)明什么樣的代碼需要進(jìn)行重構(gòu)。其中原書(shū)中寫(xiě)的非常詳細(xì),這里不做更多的說(shuō)明了。
后面一章是構(gòu)筑測(cè)試體系,作者在本書(shū)第一個(gè)例子開(kāi)始,就強(qiáng)調(diào)了測(cè)試的重要性,這里作者強(qiáng)調(diào)的是單元測(cè)試,為的正是重構(gòu)的前提,保證不改變軟件的可觀察行為。如果沒(méi)有測(cè)試,那么在進(jìn)行重構(gòu)時(shí),對(duì)于有沒(méi)有破壞其他代碼邏輯,有沒(méi)有影響軟件行為,心里并沒(méi)有底,這里并不是測(cè)試可以完美的避免這種情況,只是想要強(qiáng)調(diào),相比沒(méi)有測(cè)試的環(huán)境,重構(gòu)帶有測(cè)試的代碼,確保沒(méi)有破壞軟件行為的可能性大大提高。
同時(shí),展開(kāi)來(lái)說(shuō),可能在這里與重構(gòu)這一主題關(guān)系不是很大,測(cè)試對(duì)于軟件的設(shè)計(jì)也會(huì)產(chǎn)生正面的影響。為了測(cè)試代碼,需要實(shí)現(xiàn)代碼中待測(cè)試代碼的可測(cè)試性,這也會(huì)驅(qū)動(dòng)程序員寫(xiě)出與其他模塊低耦合的代碼,這也是測(cè)試驅(qū)動(dòng)開(kāi)發(fā)的真正價(jià)值所在。當(dāng)然寫(xiě)測(cè)試因?yàn)闀?huì)增加一時(shí)的工作量,所以有的時(shí)候會(huì)不受重視,這里可以給出幾點(diǎn)建議:
1)針對(duì)邏輯復(fù)雜或者容易出錯(cuò)的代碼編寫(xiě)測(cè)試,如果已經(jīng)有bug出現(xiàn),則需要編寫(xiě)測(cè)試復(fù)現(xiàn)此bug,以便以后在開(kāi)發(fā)過(guò)程中不會(huì)再次引入這個(gè)bug
2)針對(duì)變化不大的模塊優(yōu)先編寫(xiě)測(cè)試。在程序開(kāi)發(fā)中,ui肯定是最容易變化的,可以放在最后考慮,而系統(tǒng)的核心邏輯,相對(duì)來(lái)說(shuō)變化的頻率更低,同時(shí)也是第一條里提到的可能邏輯比較復(fù)雜的部分,相對(duì)的需要更全的測(cè)試
關(guān)于如何寫(xiě)好單元測(cè)試,這里只是一個(gè)引子,可以作為開(kāi)始測(cè)試的第一步。
開(kāi)始重構(gòu)
1、提煉函數(shù)
當(dāng)代碼中存在過(guò)長(zhǎng)的函數(shù),或者需要使用注釋來(lái)闡明一段代碼的用途時(shí),應(yīng)該考慮可以從中提煉出函數(shù)。過(guò)長(zhǎng)的函數(shù),很容易讓人望而生畏,并且包含多個(gè)職責(zé),這個(gè)時(shí)候,就應(yīng)該把各個(gè)職責(zé)的代碼,提煉成函數(shù);另一種情況,當(dāng)代碼需要注釋來(lái)說(shuō)明的時(shí)候,就應(yīng)該將其提煉成一個(gè)函數(shù),并且函數(shù)名需要仔細(xì)斟酌,以便表達(dá)原先的注釋索要表達(dá)的意思。
這里順便說(shuō)一點(diǎn)對(duì)注釋的理解。當(dāng)你想要對(duì)函數(shù),類(lèi),接口添加注釋時(shí),需要考慮到,注釋也是需要與時(shí)俱進(jìn)的更新的,函數(shù),類(lèi),接口隨著不斷開(kāi)發(fā),其行為和內(nèi)在的意義也會(huì)隨著變化,很多時(shí)候,代碼更新了,然而注釋卻依然留在那里,給以后的維護(hù)者帶來(lái)困擾,這個(gè)時(shí)候,注釋并沒(méi)有起到其應(yīng)有的作用,相反增加了維護(hù)的成本。所以,如果可以的話,讓函數(shù)名,類(lèi)名, 接口名直接表達(dá)其行為,并且在內(nèi)在邏輯行為更新時(shí),更新函數(shù)名,類(lèi)名,接口名,表達(dá)當(dāng)前的行為??紤]如下代碼:
void printOwing{
double outstanding = 0.0;
//print banner
System.out.println("******");
System.out.println("***Customer Owes***");
System.out.println("******");
}
其可以重構(gòu)為
void printOwing{
double outstanding = 0.0;
printBanner();
}
void printBanner(){
System.out.println("******");
System.out.println("***Customer Owes***");
System.out.println("******");
}
2、分解臨時(shí)變量
臨時(shí)變量有很多用途,很多時(shí)候,它們被用來(lái)保存一段代碼的運(yùn)算結(jié)果,以便稍后使用,這種臨時(shí)變量應(yīng)該只被賦值一次,如果它被賦值了多次,說(shuō)明它承擔(dān)了多個(gè)責(zé)任,而這會(huì)導(dǎo)致代碼閱讀者產(chǎn)生困惑,此時(shí)它就應(yīng)該被分解為多個(gè)臨時(shí)變量。(當(dāng)然常用的循環(huán)變量不需要)
考慮如下代碼:
```java
double getDistance(int time){
double result;
double acc = getPrimaryForce()/getMass();
int primaryTime = Math.min(time,getDelay());
result = 0.5 * acc * getPrimaryTime() * getPrimaryTime();
int secondaryTime = time - getDelay();
if(secondaryTime > 0){
double primaryVel = acc * getDelay();
acc = (getPrimaryForce() + getSecondForce())/getMass();
result += primaryVel * getPrimaryTime() + 0.5 * acc * secondaryTime
* secondaryTime;
}
return result;
}
其中acc被賦值了兩次,并且保存的值的含義是不同的,可以考慮重構(gòu)為:
double getDistance(int time){
double result;
final double primaryAcc = getPrimaryForce()/getMass();
int primaryTime = Math.min(time,getDelay());
result = 0.5 * primaryAcc * getPrimaryTime() * getPrimaryTime();
int secondaryTime = time - getDelay();
if(secondaryTime > 0){
double primaryVel = primaryAcc * getDelay();
double acc = (getPrimaryForce() + getSecondForce())/getMass();
result += primaryVel * getPrimaryTime() + 0.5 * acc * secondaryTime
* secondaryTime;
}
return result;
}
3、搬移字段
在開(kāi)發(fā)過(guò)程中,有的時(shí)候,你會(huì)遇到這種情況,對(duì)于一個(gè)字段,在其所駐類(lèi)之外的另一個(gè)類(lèi)中有更多的函數(shù)使用了它,就應(yīng)該考慮搬移這個(gè)字段。上述所謂使用,除了直接使用此字段(如果可以的話),也包括調(diào)用其賦值函數(shù)和取值函數(shù)。
考慮如下代碼:
```java
class Account{
private double interestRate;
public double getInterestRate(){
return interestRate;
}
public void setInterestRate(double interestRate){
this.interestRate = interestRate;
}
}
class AccountType{
private Account account;
public void interestForAmountDays(double amount,int days){
return account.getInterestRate() * amount * days /365;
}
}
可以考慮重構(gòu)為:
class AccountType{
private double interestRate;
public double getInterestRate(){
return interestRate;
}
public void setInterestRate(double interestRate){
this.interestRate = interestRate;
}
public void interestForAmountDays(double amount,int days){
return getInterestRate() * amount * days /365;
}
}
4、重構(gòu)魔法數(shù)
在軟件行業(yè)中,魔法數(shù)(magic number)大概是歷史最悠久的不良現(xiàn)象之一了。所謂魔法數(shù)是指擁有特殊意義,卻又不能明確表現(xiàn)出這種意義的數(shù)字。許多語(yǔ)音都允許聲明常量,以指代魔法數(shù)。不過(guò),在這里,我們應(yīng)該觀察魔法數(shù)是如何被使用的。如果魔法數(shù)是一個(gè)類(lèi)型碼,考慮使用類(lèi)代替類(lèi)型碼(這個(gè)在稍后介紹);如果魔法數(shù)代表的是一個(gè)數(shù)組的長(zhǎng)度,則可以使用Array.length替代;如果都不是的話,再考慮使用常量替代。
5、使用類(lèi)代替類(lèi)型碼
在使用類(lèi)型碼替代魔法數(shù)的時(shí)候,有一個(gè)問(wèn)題,類(lèi)型碼只是這個(gè)數(shù)值的別名,任何使用類(lèi)型碼作為參數(shù)的函數(shù)中,所期望的實(shí)際是一個(gè)數(shù)值,編譯器無(wú)法施加強(qiáng)制檢查以保證只使用類(lèi)型碼。這個(gè)時(shí)候,如果使用類(lèi)替代類(lèi)型碼,編輯器就可以進(jìn)行類(lèi)型檢查,使得調(diào)用更加安全。但是,只是類(lèi)型碼是純數(shù)值,而不會(huì)對(duì)程序行為有影響時(shí),才可以考慮使用類(lèi)替代。
考慮如下代碼,Person類(lèi)中有作為其血型的類(lèi)型碼:
```java
class Person{
public static final int O = 0;
public static final int A = 1;
public static final int B = 2;
public static final int AB = 3;
private int bloodGroup;
public Person(int bloodGroup){
this.bloodGroup = bloodGroup;
}
public int getBloodGroup(){
return bloodGroup;
}
}
可以考慮如此重構(gòu),首先建立一個(gè)血型類(lèi):
enum BloodGroup{
O,A,B,AB
}
然后修改Person類(lèi),使用枚舉,然后將針對(duì)血型的函數(shù)放到枚舉類(lèi)中。
6、封裝集合
我們常常會(huì)在一個(gè)類(lèi)中使用集合來(lái)保存一組實(shí)例,同時(shí)會(huì)提供針對(duì)此集合的取值/賦值函數(shù)。但是,集合的處理方式,是應(yīng)該有別于其他種類(lèi)的數(shù)據(jù)的。首先,取值函數(shù)不應(yīng)該返回集合本身,因?yàn)檫@會(huì)讓集合使用者有意或意外的修改集合內(nèi)容,同時(shí)集合擁有者對(duì)此卻一無(wú)所知。如果集合使用者只需要查詢集合內(nèi)容,則應(yīng)該只提供集合的不可變的只讀副本,這也額外提供了線程安全性。如果集合使用者還需要可以修改集合內(nèi)容,那么也不應(yīng)該提供集合的賦值函數(shù),而是提供添加或刪除集合元素的函數(shù),這可以提供集合擁有者更精細(xì)的控制能力。
考慮如下代碼:
```java
class Person{
private List<Course> courses;
public List<Course> getCourses(){
return courses;
}
public setCourser(List<Course> courses){
this.course = courses;
}
}
可以重構(gòu)為如下版本:
class Person{
private List<Course> courses = new ArrayList<Course>;
public List<Course> getCourses(){
return Collections.unmodifiedList(courses);
}
public addCourse(List<Course> courses){
this.courses.addAll(courses);
}
public removeCourse(List<Course> courses){
this.courses.removeAll(courses);
}
}
上述重構(gòu)只是針對(duì)集合的取值/賦值,原書(shū)中這一部分還結(jié)合了其他重構(gòu)方法,大家可以參閱。
7、將查詢函數(shù)和修改函數(shù)分離
書(shū)中提到了一個(gè)很好的原則:任何有返回值的函數(shù),都不應(yīng)該有看得到的副作用。這樣做的好處是,可以任意調(diào)用此函數(shù),重構(gòu)調(diào)用此函數(shù)的函數(shù),也會(huì)輕松很多。當(dāng)然這里使用了“看得到的副作用”這一說(shuō)法,要解釋這點(diǎn),考慮這種情況:查詢函數(shù)將查詢結(jié)果緩存起來(lái),以便加快后續(xù)查詢的速度,這種修改對(duì)于調(diào)用者來(lái)說(shuō)是感知不到的,因?yàn)椴徽摵螘r(shí)查詢,都會(huì)返回相同結(jié)果(當(dāng)然可以設(shè)計(jì)邏輯使緩存失效,這里不多做說(shuō)明)。
8、移除對(duì)參數(shù)的賦值
在函數(shù)簽名中有參數(shù)聲明是非常常見(jiàn)的情況,同時(shí)很多時(shí)候還會(huì)對(duì)參數(shù)賦值,不過(guò),這里所說(shuō)的對(duì)參數(shù)賦值,不是指在參數(shù)對(duì)象上進(jìn)行什么操作,而是改變這個(gè)參數(shù),是它引用另一個(gè)對(duì)象。這里我為什么要提到這點(diǎn),是因?yàn)樵瓡?shū)在解釋這一節(jié)時(shí),討論了Java的傳參方式,Java只采用按值傳遞的方式,這點(diǎn)我之前一直是以為傳參方式是區(qū)分引用對(duì)象和原始數(shù)據(jù)類(lèi)型。
原書(shū)中作者提到了這么幾個(gè)例子,
class Param{
public static void main(String[] args){
int x = 5;
triple(x);
System.out.println("x after tripple: " + x);
}
private static void tripple(int arg){
arg = arg * 3;
System.out.println("arg in tripple: " + arg);
}
}
上述例子使用原始數(shù)據(jù)類(lèi)型會(huì)產(chǎn)生這樣的輸出:
arg int tripple : 15
x after tripple: 5
而如果參數(shù)傳遞的是對(duì)象,那情況就會(huì)有所不同,下面代碼中傳遞代表日期的Date對(duì)象:
class Param{
public static void main(String[] args){
Date d1 = new Date("1 Apr 98");
nextDateUpdate(d1);
System.out.println("d1 after nextDay: " + d1);
Date d2 = new Date("1 Ar 98");
nextDateReplace(d2);
System.out.println("d2 after nextDay: " + d2);
}
private static void nextDateUpdate(Date arg){
arg.setDate(arg.getDate() + 1);
System.out.println("arg in nextDay: " + arg);
}
private static void nextDateReplace(Date arg){
arg = new Date(arg.getYear(),arg.getMonth(),arg.getDate + 1);
System.out.println("arg in nextDay: " + arg);
}
}
上述程序產(chǎn)生的輸出:
arg in nextDay: Thu Apr 02 00:00:00 EST 1998
d1 after nextDay: Thu Apr 02 00:00:00 EST 1998
arg in nextDay: Thu Apr 02 00:00:00 EST 1998
d2 after nextDay: Wed Apr 01 00:00:00 EST 1998
之所以會(huì)產(chǎn)生這樣的結(jié)果,是因?yàn)閷?duì)象的引用也是按值傳遞的,因此修改對(duì)象內(nèi)部狀態(tài)沒(méi)什么問(wèn)題,但對(duì)參數(shù)重新賦值,就需要仔細(xì)斟酌。
先寫(xiě)到這里
重構(gòu)這本書(shū)里還有大量重構(gòu)手法,以及一個(gè)專(zhuān)門(mén)講解大型重構(gòu)的篇章,不過(guò)我這里先寫(xiě)到這里。讀者如果仔細(xì)思考的話,可以發(fā)現(xiàn),很多重構(gòu)的核心思想,在其他書(shū)籍里也提到,就比如將查詢和修改函數(shù)分離,就體現(xiàn)了軟件開(kāi)發(fā)的經(jīng)典思想:?jiǎn)我宦氊?zé)原則,其實(shí)SOLID原則之一,當(dāng)然其他的原則(開(kāi)放封閉原則,里氏替代原則,接口隔離原則,依賴倒置原則)也都有體現(xiàn),而且這些原則不僅僅指導(dǎo)重構(gòu)技巧,其貫穿整個(gè)軟件生命周期,良好的應(yīng)用這些原則,可以帶來(lái)很多好處。
另外,原書(shū)中的最后一張總結(jié)部分,提到了其他關(guān)于重構(gòu)的部分。上述的重構(gòu)技巧或者稱(chēng)為方法,只是進(jìn)入重構(gòu)世界的敲門(mén)磚,真正的困難在于,你需要知道何時(shí)開(kāi)始重構(gòu),何時(shí)停止,應(yīng)該以什么節(jié)奏進(jìn)行重構(gòu)。畢竟今天重構(gòu)之后讓你滿意的代碼,明天你就會(huì)覺(jué)得這么重構(gòu)是完全有問(wèn)題的,所以原書(shū)的作者提醒我們,每一步重構(gòu)都應(yīng)該是可以返回到原始狀態(tài)的,你是可以走回頭路的,而且書(shū)中大量的重構(gòu)方法,很多是對(duì)于另一種方法的翻轉(zhuǎn),使你不至于陷入窘境。
原書(shū)的作者最后再次重申永遠(yuǎn)不要忘記“兩頂帽子”。畢竟重構(gòu)時(shí),你會(huì)發(fā)現(xiàn)某些代碼不正確,并且你有絕對(duì)相信自己的判斷,因此想馬上修改這處代碼。稍等,如果你現(xiàn)在在重構(gòu),那么久應(yīng)該先做好重構(gòu),而重構(gòu)并不是修改代碼功能,對(duì)于這些修改點(diǎn),也許你可以使用TODO記錄下來(lái),重構(gòu)完再來(lái)修改它,畢竟很多時(shí)候,當(dāng)你同時(shí)重構(gòu)和修改代碼功能bug,然后發(fā)現(xiàn)程序出問(wèn)題了,這個(gè)時(shí)候你卻無(wú)法判斷是重構(gòu)還是修改代碼功能導(dǎo)致的,也許你想調(diào)試一下很快就解決了,不過(guò)原作者的經(jīng)驗(yàn)告訴你,調(diào)試所花的時(shí)間也許只有幾分鐘,也許就要幾小時(shí)了。