如何實施重構
稍微複雜的重構,基本都是由一系列的重構手法組成. 《重構》一書中針對各種重構場景,給出了大量的重構手法.這些手法有的複雜,有的簡單,如果不加以系統化的整理和提煉,很容易迷失在細節中.
另外,在不同場景下重構手法的使用是非常講究其順序的.一旦順序不當,很容易讓重構失去安全性,或者乾脆讓某些重構變得很難完成.
本節是個人對重構手法的整理和提煉,幫助大家跳出細節,快速掌握重要的重構手法並且能夠儘快在自己的重構實踐中進行使用.隨後我們整理了重構手法應用順序的背後思想,幫助大家避免死記硬背,可以根據自己的重構場景推匯出合理的重構順序.
基本手法
根據2-8原則,我們平時80%的工作場景中只使用到20%的基本重構手法. 而往往復雜的重構手法,也都是由一些小的基本手法組合而成. 熟練掌握基本手法,就能夠完成絕大多數重構任務. 再按照一定順序對其加以組合,就能夠完成大多數的複雜重構.
經過對《重構》一書中的所有重構手法進行分析,結合日常工作中的使用情況,我們認為以下幾類重構手法為基本手法:
- 重新命名 (rename)
- 提煉 (extract)
- 內聯 (inline)
- 移動 (move)
以上每一類的命名皆是動詞,其賓語可以是變數,函式,型別,對於C/C++還應該再包含檔案.
例如重新命名,包含重新命名變數,重新命名函式,重新命名類,以及重新命名檔案,它們皆為基本重構手法,都屬於重新命名這一類.
其它所有的重構手法大多數都是上述基本手法的簡單變異,或者乾脆由一系列基本手法組成.
例如常用的Self Encapsulate Field(自封裝欄位)
,本質上就是簡化版的Extract Method
.
再例如稍微複雜的Replace Condition with Polymorphism(以多型取代條件表示式)
,就是由Extract Method
和 Move Method
組成的.
所以我們學習重構手法,只要能夠熟練掌握上面四類基本手法,就可以滿足日常絕大多數重構場景.通過對基本重構手法的組合,我們就能完成複雜的重構場景.
原子步驟
在我們提煉出了上述四類基本手法後,我們還是想問,既然重構手法都是程式碼等價變化操作,它們背後是否存在哪些共性的東西? 因為即使是四類基本手法,展開後也包含了不少手法,而且要去死記硬背每種手法的具體操作過程,也是相當惱人的.
事實上每種重構手法為了保證程式碼的等價變化,必須是安全且小步的,其背後的操作步驟都是相似的.我們對組成每種基本重構手法的步驟加以整理和提煉,形成一些原子步驟.一項基本重構手法是由原子步驟組成的.每一個原子步驟實施之後我們保證程式碼的功能等價性.
我們可以認為,基本上重構手法都是由以下兩個有序的原子步驟組成:
- setup
- 根據需要建立一個新的程式碼元素. 例如:變數,函式,類,或者檔案
- 新建立的程式碼元素需要有好的名稱,更合適的位置和訪問性.更好體現出設計意圖
- 新的程式碼元素的實現,可以copy原有程式碼,在copy過來的程式碼基礎之上進行修改.(注意是copy)
- 這一原子步驟的操作過程中,不會對原有程式碼元素進行任何修改.
- 這一過程的安全性只需要編譯的保證
- substitute
- 將原子步驟1中新建立的程式碼元素替換回原有程式碼
- 這一過程需要搜尋待替換元素在老程式碼中的所有引用點
- 對引用點進行逐一替換; 一些場景下為了方便替換,需要先建立引用”錨點”
- 這一過程是一個修改原始碼的過程,所以每一次替換之後,都應該由測試來保證安全性
原子步驟1,2的交替進行,可以完成一項基本重構或者複雜重構. 在這裡1和2可以稱之為原子步驟,除了因為大多數的重構手法可以拆解成這兩個原子步驟.更是因為每個原子步驟也是一項程式碼的等價變換(只是層次更低),嚴苛條件下我們可以按照原子步驟的粒度進行程式碼的提交或者回滾.然而我們之所以不把原子步驟叫做手法,是因為原子步驟的單獨完成往往不能獨立達成一項重構目標. 靈活掌握了原子步驟的應用,我們除了不用死記硬背每種重構手法背後的繁瑣步驟,更可以使自己的重構過程更安全和小步,做到更小粒度的提交和回滾,快速恢復程式碼到可用狀態.
以下是兩個應用原子步驟完成基本重構手法的例子:
- 重新命名變數(Rename Variable)
1 2 3 4 |
unsigned int start = Date::getTime(); // load program ... unsigned int offset = Date::getTime() - start; cout << "Load time was: " << offset/1000 << "seconds" << endl; |
在上面的示例程式碼中,變數offset
的含義太過寬泛,我們將其重新命名為elapsed
,第一步我們執行原子步驟setup,建立一個新的變數eclapsed
,並且將其初始化為offset
.在這裡為了更好的體現設計意圖,我們將其定義為const.
1 2 3 4 5 |
unsigned int start = Date::getTime(); // load program ... unsigned int offset = Date::getTime() - start; const unsigned int elapsed = offset; cout << "Load time was: " << offset/1000 << "seconds" << endl; |
經過這一步,我們完成了原子步驟1. 在這個過程中,我們只是增加了新的程式碼元素,並沒有修改原有程式碼.新增加的程式碼元素體現了更好的設計意圖. 最後我們編譯現有程式碼,保證這一過程的安全性.
接下來我們進行原子步驟substitute,首先找到待替換程式碼元素的所有引用點.對於我們的例子就是所有使用變數offset的地方.對於每個引用點逐一進行替換和測試.
1 2 3 4 5 |
unsigned int start = Date::getTime(); // load program ... unsigned int offset = Date::getTime() - start; const unsigned int elapsed = offset; cout << "Load time was: " << elapsed/1000 << "seconds" << endl; |
最後別忘了變數定義之處的替換:
1 2 3 4 |
unsigned int start = Date::getTime(); // load program ... const unsigned int elapsed = Date::getTime() - start; cout << "Load time was: " << elapsed/1000 << "seconds" << endl; |
每一次替換之後都需要執行測試,保證對原始碼修改的安全性.
在上述例子中,對於變數start和elapsed可以有更好的命名,這兩個變數最好能夠體現其代表時間的單位,例如可以叫做 startMs以及elapsedMs,大家可以自行練習替換. 另外程式中存在魔術數字1000,可以自行嘗試用原子步驟進行extract variable
重構手法,完成用變數對1000的替換.
- 提煉函式 (Extract Method)
1 2 3 4 5 6 7 8 9 10 11 |
void printAmount(const Items& items) { int sum = 0; for(auto item : items) { sum += item.getValue(); } cout << "The amount of items is " << sum << endl; } |
上述函式完成了兩件事,首先統計一個items的集合的所有元素value的總和,然後對總和進行列印.
為了把統計和列印職責分開,我們提煉一個函式calcAmount
用來專門對一個給定的Items集合求總和.為了完成Extract Method重構手法,我們首先使用原子步驟setup.
首先建立calcAmount
函式的原型,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
int calcAmount(const Items& items) { return 0; } void printAmount(const Items& items) { int sum = 0; for(auto item : items) { sum += item.getValue(); } cout << "The amount of items is " << sum << endl; } |
接下來完成calcAmount
函式的實現.這一步需要將源函式中相關部分copy到calcAmount
中並稍加修改.切記由於原子步驟1中不能修改原始碼,所以這裡千萬不要用剪下,否則一旦重構出錯,是很難快速將程式碼回滾到正確的狀態的,這點新手尤其需要注意!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
int calcAmount(const Items& items) { int sum = 0; for(auto item : items) { sum += item.getValue(); } return sum; } void printAmount(const Items& items) { int sum = 0; for(auto item : items) { sum += item.getValue(); } cout << "The amount of items is " << sum << endl; } |
到目前為止,原子步驟1就已經OK了,我們執行編譯,保證新增加的程式碼元素是可用的.
接下來我們進行原子步驟substitute.將新函式calcAmount
替換到每一個對Items計算總量的地方.對於我們的例子,只有一個地方就是printAmount
函式(相信對於真實程式碼,這類對Items求總量的計算會到處都是,寫法各異).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
int calcAmount(const Items& items) { int sum = 0; for(auto item : items) { sum += item.getValue(); } return sum; } void printAmount(const Items& items) { cout << "The amount of items is " << calcAmount(items) << endl; } |
替換之後執行測試.到目前為止我們的Extract Method已經完成了.
如果更進一步,我們發現可以運用基本重構手法Move Method將calcAmount
函式移入到Items類中,然後使用Rename Method手法將其重新命名為getAmount
會更好. 對於Move Method和Rename Method大家可以發現,它們都是由我們總結的原子步驟組成. 例如Move Mehod,我們首先應用原子步驟setup,在Items類中建立public成員方法calcAmount
,然後將函式的具體實現copy過去修改好,保證編譯OK. 接下來應用原子步驟substitute,用新建立的Items成員函式替換老的CalcAmount,測試OK後,我們就完成了Move Method重構.
重構的最終效果如下,大家可自行練習.
1 2 3 4 5 6 7 8 9 10 |
struct Items { int getAmount() const; ... }; void printAmount(const Items& items) { cout << "The amount of items is " << items.getAmount() << endl; } |
在這裡我們沒有將printAmount
也移入到Items中,是因為getAmount
作為Items的介面是穩定的.但是如何列印往往是表示層關注的,各種場景下列印格式各異,所以沒有將其移入Items中.
通過上面的示例,我們演示瞭如何用原子步驟組合出基本的重構手法. 實際上,對於所有的rename和普通的extract重構,一般的C++ IDE
都提供了直接的自動化重構快捷鍵供我們使用,平時開發直接使用重構快捷鍵即高效又安全.但是這並不影響我們掌握原子步驟的使用.由於C++
語言的複雜性,大多數重構手法都是沒有自動化重構快捷鍵支援的,即便有重構快捷鍵支援,一旦上下文稍微複雜一點(例如對有很多臨時變數的函式執行Extract Method),自動化重構的結果也往往不能讓人滿意. 這裡不僅對C++語言,對於一些動態型別語言(例如ruby),自動化重構更是匱乏.所以我們要掌握重構手法背後的思想,熟練掌握原子步驟,學會安全高效地手動重構.
這裡總結的原子步驟是非常適普的! 不僅我們列舉出的基本重構手法都是由原子步驟組成.對於許多複雜的重構手法,除了會直接使用基本重構手法,甚至也會直接使用原子步驟.
例如對於Martin描述的手法”Replace Type Code with Class(以類取代型別碼)”,裡面基本是在反覆使用原子步驟,我們摘錄原書中的操作描述:
- 為型別碼建立一個類
- 修改源類的實現,讓它使用新建的類
- 編譯,測試
- 對於源類中每一個使用型別碼的函式,相應建立一個函式,讓新函式使用新建的類
- 逐一修改源類使用者,讓它們使用新介面
- 每修改一個使用者,編譯並測試
- 刪除使用型別碼的舊介面,並刪除儲存舊型別碼的靜態變數
- 編譯,測試
對於該重構,其中步驟1是原子步驟setup,步驟2是原子步驟substitute(第3步是對第2步的編譯和測試,對第1步的編譯過程作者省略了). 然後第4步又是setup,第5步開始執行substitute. 每一次按照原子步驟的要求進行執行,都可以保證重構是安全的,甚至嚴苛條件下,程式碼可以按照每一次原子步驟進行提交或者回滾. 另外對於每一種重構手法的操作描述,如果把它們統一成對原子步驟的組合的描述,也會極大的方便記憶. 掌握了原子步驟,即使忘記了某一項重構手法的具體操作,也可以方便的自行推匯出來.
錨點的使用
在前面介紹原子步驟substitute的時候提到,為了方便替換,可以使用引用錨點.
對於重構,最容易出錯的地方就在替換.可以藉助IDE幫助自動搜尋到對舊碼元素的所有的引用點.但是搜尋的質量往往和IDE以及語言特性相關.例如對於C++巨集內程式碼元素的搜尋,IDE就很難搜尋準確. 另外對於引用點很多的情況,逐一替換/測試也是相當累人的.
所謂錨點,就是先增加一箇中間層,把所有對舊程式碼元素的引用匯聚到一點,編譯測試過後,然後在這一個點完成新舊程式碼元素的統一替換.完成替換後,可以保留錨點,或者用inline重構手法再去掉錨點.
有兩類手法經常被用來建立錨點,Encapsulate field(自封裝欄位)
和Replace Constructor with Factory Method(以工廠函式取代建構函式)
。
Encapsulate field一般在修改類的某一成員欄位的實現方式的重構場景下使用。例如:Move Field(在類間搬移欄位)
和Replace Type Code with Subclasses(以子類取代型別碼)
。Encapsulate field對於欲修改欄位建立引用錨點,將類內部對該成員欄位的使用匯聚到一起,方便對欄位的實現方式進行替換。它的操作方式也是由我們前面介紹的兩個原子步驟組成:
- Setup:在類內建立兩個方法分別對於要修改欄位的讀取和設值(就是物件導向初學者最愛寫的
get
和set
成員函式)。編譯。 - Substitute:將類內所有直接使用欄位的地方替換為呼叫對應的函式. 讀取的地方替換為呼叫
get
,設值的地方替換為呼叫set
。執行測試!
當執行完Encapsulate field後,類內再無對欲修改欄位的直接使用了,這時就可以方便地在get
和set
方法內對於欄位的實現方式進行修改了。
例如對於”Move Field(在類間搬移欄位)”重構操作,首先就需要在源類內使用Encapsulate field對需要搬移的欄位建立引用錨點. 當執行完Encapsulate field後,源類內再無對欲搬移欄位的直接使用了,這個時候再在目標類中建立對應欄位,將源類內get
和set
方法內對於自身欄位的使用修改為使用目標類中的欄位. 測試通過後,再刪除源類內的搬移欄位. 最後對於源類內提取出來的get
和set
方法可以再inline回去.
可以看到錨點將引用點匯聚到一處,每個客戶都呼叫一箇中間層,不再面對具體的待替換程式碼細節. 錨點的使用簡化了替換過程,並且可以讓替換更加安全.
在某些場合下使用錨點還有更重要的意義,一些重構手法必須藉助錨點才能完成,尤其是對一些需要子類化的重構! 例如對於 Replace Constructor with Factory Method(以工廠函式取代建構函式)
,在該重構手法裡面,工廠函式就是錨點,它將類的建立匯聚到工廠函式裡面,對客戶程式碼隱藏了類的構造細節,後面如果進行某些子類化的重構就非常容易實施.
下面我們以一個例子作為對原子步驟和錨點的總結。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// Shoes.h enum Type { REGULAR, NEW_STYLE, LIMITED_EDITION }; struct Shoes { Shoes(Type type, double price); double getCharge(int quantity) const; private: Type type; double price; }; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// Shoes.cpp #include "Shoes.h" Shoes::Shoes(Type type, double price) : type(type), price(price) { } double Shoes::getCharge(int quantity) const { double result = 0; switch(type) { case REGULAR: result += price * quantity; break; case NEW_STYLE: if(quantity > 1) { result += (price + (quantity - 1) * price * 0.8); } else { result += price; } break; case LIMITED_EDITION: result += price * quantity * 1.1; break; } return result; } |
以上程式碼中有一個Shoes
類,它的type
欄位指明一個Shoes
物件的具體型別:REGULAR
、NEW_STYLE
或者LIMITED_EDITION
。Shoes
類的介面getCharge
根據傳入的數量quantity
來計算總費用。getCharge
根據不同型別按照不同方法進行計算。對於普通款(REGULAR),總價等於單價乘以數量;對於新款(NEW_STYLE),從第二雙開始打八折;對於限量版(LIMITED_EDITION),每一雙需要多收10%的費用。
由於Shoes
類的物件在其生命週期中type
不會發生變化,所以可以為不同的型別碼建立Shoes
型別的子類,將不同type
的計算行為放到相應子類中去,這樣程式碼就可以滿足開放封閉性,以後再增加新型別的Shoes
,只用獨立地再增加一個子類,不會干擾到別的型別的計算。
於是我們決定使用重構手法Replace type code with subclasses(以子類取代型別碼)
來完成重構目標。(注意:倘若在Shoes
的物件生命週期內type
可以變化,就不能使用該重構手法,而應該使用Replace type code with strategy/state(以策略或者狀態模式取代型別碼)
)。
以下是具體的重構過程,我們著重展示如何使用原子步驟以及錨點。
- 由於我們要以子類取代型別碼
type
,所以首先使用Encapsulate field對type
建立引用錨點,方便後面對type
的替換。
12345678910111213141516171819202122232425262728293031323334Type Shoes::getType() const{return type;}double Shoes::getCharge(int quantity) const{double result = 0;switch(getType()){case REGULAR:result += price * quantity;break;case NEW_STYLE:if (quantity > 1){result += (price + (quantity - 1) * price * 0.8);}else{result += price;}break;case LIMITED_EDITION:result += price * quantity * 1.1;break;}return result;}
上面我們將getCharge
中對type
的直接使用替換為呼叫新建立的私有成員方法getType()
。到最後Shoes
內只有建構函式中仍然直接使用type
,接下來再處理建構函式。 - 為了遮蔽
Shoes
型別被子類化後對客戶程式碼的影響,我們對Shoes
建立工廠函式作為建構函式的引用錨點,用來對客戶遮蔽不同種類Shoes
的具體構造。首先執行原子步驟setup,建立出工廠函式。
1234567891011121314struct Shoes{static Shoes* create(Type type, double price);Shoes(Type type, double price);double getCharge(int quantity) const;private:Type getType() const;private:Type type;double price;};
1234Shoes* Shoes::create(Type type, double price){return new Shoes(type, price);}
編譯通過後,接下來執行原子步驟substitute。將原來客戶程式碼直接呼叫Shoes
建構函式的地方替換為呼叫工廠函式。替換完成後將Shoes
的建構函式修改為protected
(子類要用)。執行測試!
12345678910111213141516struct Shoes{static Shoes* create(Type type, double price);double getCharge(int quantity) const;protected:Shoes(Type type, double price);private:Type getType() const;private:Type type;double price;};
1234// client code// Shoes* shoes = new Shoes(REGULAR, 100.0);Shoes* shoes = Shoes::create(REGULAR, 100.0);
為了簡化,這裡假設客戶程式碼需要對建立出來的Shoes
物件進行顯示記憶體管理。至此內外部錨點都已經建立OK。內部錨點
getType
在類內遮蔽對type
的直接使用,方便後續以子類對type
進行替換。外部錨點create
向客戶隱藏Shoes
的具體構造,方便以子類的構造替換具體型別Shoes
的構造。 - 接下來,我們逐一建立
Shoes
的子類,用於對Shoes
中型別碼的替換。
首先將Shoes
內的getType
函式修改為虛方法。然後執行原子步驟setup,建立類RegularShoes
繼承自Shoes
,它覆寫了getType
方法,返回對應的型別碼。1234567struct RegularShoes : Shoes{RegularShoes(double price);private:Type getType() const override;};123456789RegularShoes::RegularShoes(double price): Shoes(REGULAR, price){}Type RegularShoes::getType() const{return REGULAR;}下面執行原子步驟substitute,用
RegularShoes
替換Shoes
的建構函式中對於REGULAR
型別的構造。12345Shoes* Shoes::create(Type type, double price){if(type == REGULAR) return new RegularShoes(price);return new Shoes(type, price);}同樣的方式建立
NewStyleShoes
和LimitedEditionShoes
,並替換進工廠函式中。123456789NewStyleShoes::NewStyleShoes(double price): Shoes(NEW_STYLE, price){}Type NewStyleShoes::getType() const{return NEW_STYLE;}123456789LimitedEditionShoes::LimitedEditionShoes(double price): Shoes(LIMITED_EDITION, price){}Type LimitedEditionShoes::getType() const{return LIMITED_EDITION;}1234567891011121314Shoes* Shoes::create(Type type, double price){switch(type){case REGULAR:return new RegularShoes(price);case NEW_STYLE:return new NewStyleShoes(price);case LIMITED_EDITION:return new LimitedEditionShoes(price);}return nullptr;}在
Shoes::create
方法中,型別都不匹配的情況下返回了nullptr
,當然你也可以建立Shoes
的一個NullObject
在此返回。至此,
Shoes
中就不再需要型別碼type
了。為了安全的刪除,我們執行原子步驟setup,先為Shoes
新增一個無需type
引數的建構函式Shoes(double price)
,然後執行原子步驟substitute,將子類中呼叫的Shoes(Type type, double price)
全部替換掉。在這裡為了避免子類間建構函式的重複,我們使用了C++11的繼承建構函式特性。編譯測試通過後,我們可以安全地將type
和Shoes(Type type, double price)
一起刪除,同時將Shoes
中的getType
修改為純虛擬函式,刪除其在cpp檔案中的函式實現。1234567891011121314struct Shoes{static Shoes* create(Type type, double price);Shoes(double price);virtual ~Shoes(){}double getCharge(int quantity) const;private:virtual Type getType() const = 0;private:double price;};1234567struct RegularShoes : Shoes{using Shoes::Shoes;private:Type getType() const override;}; - 重構到現在,我們已經成功地用子類替換掉了型別碼。但是這不是我們的目的,我們最終希望能夠把
getCharge
中的計算行為分解到對應子類中去。這就是Replace Condition with Polymorphism(以多型取代條件表示式)
。下面我們以原子步驟的方式完成它。
首先將getCharge
宣告為虛方法。然後使用原子步驟setup在子類中建立getCharge
的覆寫函式,將對應子類的計算部分copy過去。這裡為了能夠編譯通過,需要將Shoes
中的price
成員變數修改為protected
。123456789101112131415struct Shoes{static Shoes* create(Type type, double price);Shoes(double price);virtual ~Shoes(){}virtual double getCharge(int quantity) const;private:virtual Type getType() const = 0;protected:double price;};12345678struct RegularShoes : Shoes{using Shoes::Shoes;private:double getCharge(int quantity) const override;Type getType() const override;};1234double RegularShoes::getCharge(int quantity) const{return price * quantity;}然後執行原子步驟substitute,用子類的
getCharge
對父類中的實現進行替換。這裡只用刪除Shoes::getCharge
中對應REGULAR
的分支。執行測試。12345678910111213141516171819202122232425double Shoes::getCharge(int quantity) const{double result = 0;switch(getType()){case NEW_STYLE:if (quantity > 1){result += (price + (quantity - 1) * price * 0.8);}else{result += price;}break;case LIMITED_EDITION:result += price * quantity * 1.1;break;}return result;}同樣的方式,將
Shoes::getCharge
中其餘部分分別挪入到另外兩個子類中,完成對Shoes::getCharge
的替換,最後刪除Shoes::getCharge
的實現部分,將其宣告為純虛擬函式。這時繼承體系上的getType
也不再需要了,一起刪除。1234567891011struct Shoes{static Shoes* create(Type type, double price);Shoes(double price);virtual ~Shoes(){}virtual double getCharge(int quantity) const = 0;protected:double price;};12345678910111213141516171819Shoes::Shoes(double price): price(price){}Shoes* Shoes::create(Type type, double price){switch(type){case REGULAR:return new RegularShoes(price);case NEW_STYLE:return new NewStyleShoes(price);case LIMITED_EDITION:return new LimitedEditionShoes(price);}return nullptr;}123456789101112struct RegularShoes : Shoes{using Shoes::Shoes;private:double getCharge(int quantity) const override;};double RegularShoes::getCharge(int quantity) const{return price * quantity;}12345678910111213141516171819struct NewStyleShoes : Shoes{using Shoes::Shoes;private:double getCharge(int quantity) const override;};double NewStyleShoes::getCharge(int quantity) const{double result = price;if (quantity > 1){result += ((quantity - 1) * price * 0.8);}return result;}123456789101112struct LimitedEditionShoes : Shoes{using Shoes::Shoes;private:double getCharge(int quantity) const override;};double LimitedEditionShoes::getCharge(int quantity) const{return price * quantity * 1.1;}至此我們的重構目標已經達成。通過上例我們再次展示瞭如何使用原子步驟進行安全小步的程式碼修改,並且展示了錨點的使用。對於本例可能有人會說,
getCharge
中的switch-case
被分解到每個子類中去了,但是我們在Shoes::create
中又建立出了一個switch-case
結構。但其實這兩個switch-case
背後的意義是不同的,重構後的僅僅是在做構造分發,職責清晰、簡單明瞭; 如果後續再有根據型別執行不同演算法的行為,就直接實現在具體子類中,而不用增加新的switch-case
結構了! 實際操作中,我們其實是在變化發生的時候先來進行上述重構,然後再增加新的程式碼。
重構的順序
通過之前的例子可以看到稍微複雜一點的重構都是由一系列的基本手法或者原子步驟組成. 在實踐中對基本手法或者原子步驟的使用順序非常重要,如果順序不當,有時甚至會讓整個重構變得很難實施.
關於重構的順序,最基本的一點原則是自底向上! 我們只有先從最細節的重構開始著手,才能使得較大的重構輕易完成.
例如對於消除兄弟型別之間的重複表示式,我們只有先運用Extract Method將重複部分和差異部分分離,然後才能將重複程式碼以Pull Up Method重構手法推入父類中.
對於稍微複雜的重構,當我們確定了重構目標後,接下來就可以進行重構順序編排,順序編排的具體操作方法是:從目標開始反向推演,為了使當前目標達成的這一步操作能夠非常容易、安全和高效地完成,它的上一步狀態應該是什麼? 如此遞迴,一直到程式碼的現狀.
下面我們以複雜一點的Extract Method為例,應用上述原則,完成重構順序的編排.
我們知道Extract Method在函式有較多臨時變數的時候,是比較難以實施的.原函式內較多的臨時變數可能會導致提取出的函式有大量入參和出參,增加提煉難度,並且降低了該重構的使用效果.
所以為了使得提煉較為容易實施,我們一般需要在原函式中解決臨時變數過多的問題.
在這裡我們把函式內的臨時變數分為兩類,一類是累計變數(或者收集變數),其作用是收集狀態,這類變數的值在初始化之後在函式內還會被反覆修改.累計變數一般包括迴圈變數或者函式的出參. 除過累計變數外,函式內其它的變數都是查詢變數.查詢變數的特點是可以在初始化的時候一次賦值,隨後在函式內該變數都是隻讀的,其值不再被修改.
接下來我們逐步反推如何使得Extract Method較為容易實施:
- 目標狀態: 新提取出來的方法替換原方法中的舊程式碼,並且測試通過!
- 為了使得1容易完成,我們現在應該已經具有新提煉出來的新方法,該方法命名良好,其返回值和出入參是簡單明確的,其內部實現已經調整OK.並且這個新提煉出來的方法是編譯通過的.
- 為了使得2容易完成,我們需要先為其建立一個方法原型,將老程式碼中要提煉部分copy進去作為其實現,根據copy過去的程式碼非常容易確定新函式的返回值型別和出入參.
- 為了使得3容易完成,原有程式碼中被提煉部分和其餘部分應該具有很少的臨時變數耦合.
- 為了滿足4,我們需要消除原有函式中的臨時變數. 對於所有的查詢變數,我們可以將其替換成查詢函式(應用重構手法:以查詢替代臨時變數).
- 為了使得5容易做到,我們需要區分出函式內的累計變數和查詢變數.如果某一查詢變數被多次賦值,則將其分解成多個查詢變數,保證每個查詢變數都只被賦值一次.並且對每個查詢變數定義即初始化,而且要定義為const型別.
- 為了使6方便做到,我們需要容易地觀察到變數是否被賦值多次,這時變數應該被定義在離使用最近的地方.所以我們應該對變數的定義位置進行調整,讓其最靠近其使用的位置.(對C語言程式這點尤其重要).
有了上面的分析,我們將其反過來,就是對於稍微複雜的Extract Method重構手法每一步的操作順序:
- 修改原函式內每一個區域性變數的定義,讓其最靠近其使用的地方,並儘量做到定義即初始化;
- 區分查詢變數和累計變數.對於查詢變數有多次賦值的情況,將其拆分成多個查詢變數.保證每個查詢變數只被賦值一次.
- 對每個查詢變數的型別定義加上const,進行編譯.
- 利用”以查詢替代臨時變數”重構,消除所有查詢變數.減少原函式中臨時變數的數目.
- 建立新的方法,確定其原型.
- 將原函式中待提煉程式碼拷貝到新的函式中,調整其實現,保證編譯通過.
- 將新的函式替換回原函式,保證測試通過.
下面是一個例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
bool calcPrice(Product* products,U32 num,double* totalPrice) { U32 i; double basePrice; double discountFactor; if(products != NULL) { for(i = 0; i < num; i++) { basePrice = products[i].price * products[i].quantity; if(basePrice >= 1000) { discountFactor = 0.95; } else { discountFactor = 0.99; } basePrice *= discountFactor; *totalPrice += basePrice; } return true; } return false; } |
上面是一段C風格的程式碼. 函式calcPrice
用來計算所有product的總price. 其中入參為一個Product型別的陣列,長度為num. 每個product的價格等於其單價乘以總量,然後再乘以一個折扣. 當單價乘以總量大於等於1000的時候,折扣為0.95,否則折扣為0.99. 出參totalPrice為最終計算出的所有product的價格之和. 計算成功函式返回true,否則返回false並且不改變totalPrice的值.
Product是一個簡單的結構體,定義如下
1 2 3 4 5 6 7 |
typedef unsigned int U32; struct Product { double price; U32 quantity; }; |
calcPrice
函式的實現顯得有點長.我們想使用Extract Method將其分解成幾個小函式.一般有註釋的地方或者有大的if/for分層次的地方都是提煉函式不錯的入手點.但是對於這個函式我們無論是想在第一個if層次內,或者for層次內提煉函式,都遇到不小的挑戰,主要是臨時變數太多導致函式出入引數過多的問題.
下面是我們藉助eclipse的自動重構工具將for內部提取出一個函式的時候給出的提示:
可以看到它提示新的函式需要有5個引數之多.
對於這個問題,我們採用上面總結出來的Extract Method的合理順序來解決.
- 將每一個區域性變數定義到最靠近其使用的地方,儘量做到定義即初始化;
12345678910111213141516171819202122232425262728bool calcPrice(Product* products,U32 num,double* totalPrice){if(products != NULL){for(U32 i = 0; i < num; i++){double basePrice = products[i].price * products[i].quantity;double discountFactor;if(basePrice >= 1000){discountFactor = 0.95;}else{discountFactor = 0.99;}basePrice *= discountFactor;*totalPrice += basePrice;}return true;}return false;}
在上面的操作中,我們將變數i
,basePrice
和dicountFactor
的定義位置都挪到了其第一次使用的地方.對於i
和basePrice
做到了定義即初始化. - 對於查詢變數有多次賦值的情況,將其拆分成多個查詢變數.保證每個查詢變數只被賦值一次.
在這裡我們辨識出totalPrice
和i
為累計變數,其它都是查詢變數.對於查詢變數basePrice
存在多次賦值的情況,這裡我們把它拆成兩個變數(增加actualPrice
),保證每個變數只被賦值一次.對於所有查詢變數儘量加上const加以標識.
12345678910111213141516171819202122232425262728bool calcPrice(const Product* products,const U32 num,double* totalPrice){if(products != NULL){for(U32 i = 0; i < num; i++){const double basePrice = products[i].price * products[i].quantity;double discountFactor;if(basePrice >= 1000){discountFactor = 0.95;}else{discountFactor = 0.99;}const double actualPrice = basePrice * discountFactor;*totalPrice += actualPrice;}return true;}return false;} - 利用”以查詢替代臨時變數”重構,消除所有查詢變數.減少原函式中臨時變數的數目.
在這裡先從依賴較小的basePrice
開始.
12345678910111213141516171819202122232425262728293031double getBasePrice(const Product* product){return product->price * product->quantity;}bool calcPrice(const Product* products,const U32 num,double* totalPrice){if(products != NULL){for(U32 i = 0; i < num; i++){double discountFactor;if(getBasePrice(&products[i]) >= 1000){discountFactor = 0.95;}else{discountFactor = 0.99;}const double actualPrice = getBasePrice(&products[i]) * discountFactor;*totalPrice += actualPrice;}return true;}return false;} - 下來搞定
discountFactor
12345678910111213141516171819202122232425double getBasePrice(const Product* product){return product->price * product->quantity;}double getDiscountFactor(const Product* product){return (getBasePrice(product) >= 1000) ? 0.95 : 0.99;}bool calcPrice(const Product* products,const U32 num,double* totalPrice){if(products != NULL){for(U32 i = 0; i < num; i++){const double actualPrice = getBasePrice(&products[i]) * getDiscountFactor(&products[i]);*totalPrice += actualPrice;}return true;}return false;} - 下來消滅
actualPrice
:
1234567891011121314151617181920212223242526272829double getBasePrice(const Product* product){return product->price * product->quantity;}double getDiscountFactor(const Product* product){return (getBasePrice(product) >= 1000) ? 0.95 : 0.99;}double getPrice(const Product* product){return getBasePrice(product) * getDiscountFactor(product);}bool calcPrice(const Product* products,const U32 num,double* totalPrice){if(products != NULL){for(U32 i = 0; i < num; i++){*totalPrice += getPrice(&products[i]);}return true;}return false;} - 到目前為止,我們最初的目標已經達成了.如果你覺得
getBasePrice
呼叫過多擔心造成效能問題,可以在getDiscountFactor
和getPrice
函式中使用inline function重構手法將其再內聯回去.但是getBasePrice
可以繼續保留,假如該方法還存在其它客戶的話.另外是否效能優化可以等到有效能資料支撐的時候再進行也不遲. - 最後,可以使用重構手法對
calcPrice
做進一步的優化:
1234567891011121314151617181920double getTotalPrice(const Product* products,const U32 num){double result = 0;for(U32 i = 0; i < num; i++){result += getPrice(&products[i]);}return result;}bool calcPrice(const Product* products,const U32 num,double* totalPrice){if(products == NULL) return false;*totalPrice = getTotalPrice(products,num);return true;}
針對最後是否提取getTotalPrice
函式可能會有爭議. 個人認為將其提取出來是有好處的,因為大多數情況下只關注正常場景計算的函式是有用的.例如我們可以單獨複用該函式完成對計算結果的列印:
123Product products[10];...printf("The total price of products is %fn",getTotalPrice(product,10));
通過上面的例子可以看到按照合理順序進行重構的重要性. 當我們實施重構的時候,如果到某一步覺得很難進行,就要反思自己的重構順序到底對不對,首先看看是不是自底向上操作的,再思考一下如果要讓當前步驟變得簡單,它之前還應該做什麼.具有這樣的思維後,以後碰到各種場景就都能遊刃有餘了.
總結
本節我們總結了四類基本的重構手法.複雜的重構基本上可以藉助基本手法的組合來完成. 更進一步我們提煉了重構的原子步驟,將大家從學習重構的繁瑣步驟中解放出來. 只要掌握了兩個原子步驟及其要求,就可以組合出大多數的重構手法. 而且原子步驟是安全小步的,程式碼的提交和回滾可以以原子步驟為單位進行. 為了使substitute容易進行,我們討論了錨點以及其經常使用的場合. 最後我們總結了重構操作順序背後的思想,藉助合理的順序,可以讓我們的重構變得輕鬆有序.