程式碼重構:函式重構的 7 個小技巧
重構的範圍很大,有包括類結構、變數、函式、物件關係,還有單元測試的體系構建等等。
在這一章,我們主要分享重構函式的 7 個小技巧。?
在重構的世界裡,幾乎所有的問題都源於過長的函式導致的,因為:
- 過長的函式包含太多資訊,承擔太多職責,無法或者很難複用
- 錯綜複雜的邏輯,導致沒人願意去閱讀程式碼,理解作者的意圖
對於過長函式的處理方式,在 《重構》中作者推薦如下手法進行處理:
1:提煉函式
示例一
我們看先一個示例,原始程式碼如下:
void printOwing(double amout) {
printBanner();
// Print Details
System.out.println("name:" + _name);
System.out.println("amount:" + _amount);
}
Extract Method 的重構手法是將多個 println()
抽離到獨立的函式中(函式需要在命名上,下點功夫),這裡對抽離的函式命名有 2 個建議:
- 保持函式儘可能的小,函式越小,被複用的可能性越大
- 良好的函式命名,可以讓呼叫方的程式碼看起來上註釋(結構清晰的程式碼,其實並不是很需要註釋)
將 2 個 println()
方法抽離到 printDetails()
函式中:
void printDetails(double amount) {
System.out.println("name:" + _name);
System.out.println("amount:" + _amount);
}
當我們擁有 printDetails()
獨立函式後,那麼最終 printOwing()
函式看起來像:
void printOwing(double amout) {
printBanner();
printDetails(double amount);
}
示例二
示例一可能過於簡單,無法表示 Extract Method 的奇妙能力,我們通過一個更復雜的案例來表示,程式碼如下:
void printOwing() {
Enumeration e = _orders.elements();
double oustanding = 0.0
// print banner
System.out.println("*******************")
System.out.println("***Customer Owes***")
System.out.println("*******************")
// calculate outstanding
while(e.hasMoreElements()){
Order each = (Order)e.nextElement();
outstanding += each.getAmount();
}
// print details
System.out.println("name:" + _name);
System.out.println("amount:" + outstanding);
}
首先審視一下這段程式碼,這是一段過長的函式(典型的糟糕程式碼的代表),因為它企圖去完成所有的事情。但通過註釋我們可以將它的函式提煉出來,方便函式複用,而且 printOwing() 程式碼結構也會更加清晰,最終版本如下:
void printOwing(double previousAmount) {
printBaner(); // Extract print banner
double outstanding = getOutstanding(previousAmount * 1.2) // Extract calculate outstanding
printDetails(outstanding) // print details
}
printOwing()
看起來像註釋的程式碼,對於閱讀非常友好,然後看看被 Extract Method 被提煉的函式程式碼:
void printBanner() {
System.out.println("*******************")
System.out.println("***Customer Owes***")
System.out.println("*******************")
}
double getOutstanding(double initialValue) {
double result = initialValue; // 賦值引用物件,避免對引用傳遞
Enumeration e = _orders.elements();
while(e.hasMoreElements()){
Order each = (Order)e.nextElement();
result += each.getAmount();
}
return result;
}
void printDetails(double outstanding) {
System.out.println("name:" + _name);
System.out.println("amount:" + outstanding);
}
總結
提煉函式是最常用的重構手法之一,就是將過長函式按職責拆分至合理範圍,這樣被拆解的函式也有很大的概率被複用到其他函式內
2:移除多餘函式
當函式承擔的職責和內容過小的時候,我們就需要將兩個函式合併,避免系統產生和分佈過多的零散的函式
示例一
假如我們程式中有以下 2 個函式,示例程式:
int getRating() {
return (moreThanFiveLateDeliveries()) ? 2 : 1;
}
boolean moreThanFiveLateDeliveries() {
return _numberOfLateDeliveries > 5;
}
moreThanFiveLateDeliveries()
似乎沒有什麼存在的必要,因為它僅僅是返回一個 _numberOfLateDeliveries
變數,我們就可以使用 Inline Method 行內函數 來重構它,修改後的程式碼如下:
int getRating() {
return (_numberOfLateDeliveries > 5) ? 2 : 1;
}
注意事項:
- 如果
moreThanFiveLateDeliveries()
已經被多個呼叫方引用,則不要去修改它
總結
Inline Method 行內函數 就是邏輯和職責簡單的,並且只被使用 1 次的函式進行合併和移除,讓系統整體保持簡單和整潔
3:移除臨時變數
先看示例程式碼:
示例一
double basePrice = anOrder.basePrice();
return basePrice > 1000;
使用 Inline Temp Variable 來內聯 basePrice 變數,程式碼如下:
return anOrder.basePrice() > 1000;
總結
如果函式內的臨時變數,只被引用和使用一次,那麼它就應該被內聯和移除,避免產生過多冗餘程式碼,從而影響閱讀
4:函式替代表示式
如果你的程式依賴一段表示式來進行邏輯判斷,那麼你可以利用一段函式封裝表示式,來讓計算過程更加靈活的被複用
示例一
double basePrice = _quantity * _itemPrice;
if (basePrice > 1000) {
return basePrice * 0.95;
} else {
return basePrice * 0.98;
}
在示例一,我們可以把 basePrice
的計算過程封裝起來,這樣其他函式呼叫也更方便,重構後示例如下:
if (basePrice() > 1000) {
return basePrice() * 0.95;
} else {
return basePrice() * 0.98;
}
// 抽取 basePrice() 計算過程
double basePrice() {
return _quantity * _itemPrice;
}
以上程式比較簡單,不太能看出函式替代表示式的效果,我們換一個更負責的看看,先看一段獲取商品價格的程式:
double getPrice() {
final int basePrice = _quantity * _itemPrice;
final double discountFactor;
if (basePrice > 1000) {
discountFactor = 0.95;
} else {
discountFactor = 0.98;
}
return basePrice * discountFactor;
}
如果我們使用 函式替代表示式 的重構手法,那麼程式最終讀起來可能就像:
double getPrice() {
// 讀起來像不像註釋 ? 這裡的程式碼還需要寫註釋嗎?
return basePrice() * discountFactor();
}
至於 basePrice()、discountFactor() 是怎麼拆解的,這裡回憶一下 提煉函式 的內容,以下放出提煉的程式碼:
int basePrice() {
return _quantity * _itemPrice;
}
double discountFactor() {
final double discountFactor;
return basePrice() > 1000 ? 0.95 : 0.98;
}
總結
使用函式替代表示式替代表示式,對於程式來說有以下幾點好處:
- 封裝表示式的計算過程,呼叫方無需關心結果是怎麼計算出來的,符合 OOP 原則
- 當計算過程發生改動,也不會影響呼叫方,只要修改函式本身即可
5:引入解釋變數
當你的程式內部出現大量晦澀難懂的表示式,影響到程式閱讀的時候,你需要 引入解釋變數 來解決這個問題,不然程式碼容易變的腐爛,從而導致失控。另外引入解釋變數也會讓分支表示式更好理解。
示例一
我們先看一段程式碼(我敢保證這段程式碼你看的肯定會很頭疼。。。?)
if (platform.tpUpperCase().indexOf("MAC") > -1 && browser.toUpperCase().indexOf("IE") > -1 &&
wasInitialized() && resize > 0) {
// do something ....
}
使用 引入解釋變數 的方法來重構它的話,會讓你取起來有不同的感受,程式碼如下:
final boolean isMacOs = platform.tpUpperCase().indexOf("MAC") > -1;
final boolean isIEBrowser = browser.toUpperCase().indexOf("IE") > -1;
final boolean wasResized = resize > 0;
if (isMacOs && isIEBrowser && wasInitialized() && wasResized()) {
// do something ...
}
這樣做還有一個好處就是,在 Debug 程式的時候你可以提前知道每段表示式的結果,不必等到執行到 IF
的時候再推算
示例二
其實 引入解釋變數 ,只是解決問題的方式之一,複習我們剛才提到的 提煉函式也能解決這個問題,我們再來看一段容易引起生理不適的程式碼 ?:
double price() {
// price is base price - quantity discount + shipping
return (_quantity * _itemPrice) -
Math.max(0, _quantity - 500) * _itemPrice * 0.05 +
Math.min(_quantity * _itemPrice * 0.1, 100.0);
}
我們使用 Extract Method 提煉函式處理程式碼後,那麼它讀起來就像是這樣:
double price() {
return basePrice() - quantityDiscount() + shipping();
}
有沒有感受到什麼叫好的程式碼就像好的文章??? 這樣的程式碼根本不用寫註釋了,當然把被提煉的函式也放出來:
private double quantityDiscount() {
return Math.max(0, _quantity - 500) * _itemPrice * 0.05;
}
private double shipping() {
return Math.min(_quantity * _itemPrice * 0.1, 100.0);
}
private double basePrice() {
return (_quantity * _itemPrice);
}
總結
當然大多數場景是可以使用 Extract Method 提煉函式來替代引入解釋變數來解決問題,但這並不代表 引入解釋變數 這種重構手法就毫無用處,我們還是可以根據一些特定的場景來找到它的使用場景:
- 當 Extract Method 提煉函式使用成本比較高,並且難以進行時……
- 當邏輯表示式過於複雜,並且只使用一次的時候(如果會被複用,推薦使用 提煉函式 方式)
6:避免修改函式引數
雖然不同的程式語言的函式引數傳遞會區分:“按值傳遞”、“按引用傳遞”的兩種方式(Java 語言的傳遞方式是按值傳遞),這裡不就討論兩種傳遞方式的區別,相信大家都知道。
示例一
我們不應該直接對 inputVal 引數進行修改,但是如果直接修改函式的引數會讓人搞混亂這兩種方式,如下以下程式碼:
int discount (int inputVal) {
if (inputVal > 50) {
intputVal -= 2;
}
return intputVal;
}
如果是在 引用傳遞 型別的程式語言裡,discount()
函式對於 intputVal 變數的修改,甚至還會影響到呼叫方。所以我們正確的做法應該是使用一個臨時變數來處理對引數的修改,程式碼如下:
int discount (int inputVal) {
int result = inputVal;
if (inputVal > 50) {
result -= 2;
}
return result;
}
辯證的看待按值傳遞
眾所周知在按值傳遞的程式語言中,任何對引數的任何修改,都不會對呼叫端造成任何影響。但是如何不加以區分,這種特性依然會讓你感到困惑?,我們先看一段正常的程式碼:
public class Param {
public static void main(String[] args) {
int x = 5;
triple(x);
System.out.println("x after triple: " + x);
}
private static void triple (int arg) {
arg = arg * 3;
System.out.println("arg in triple: " + arg);
}
}
這段程式碼不容易引起困惑,習慣按值傳遞的小夥伴,應該瞭解它的輸出會如下:
arg in triple: 15
x after triple: 5
但是如果函式的引數是物件,你可能就會覺得困惑了,我們再看一下程式碼,把函式物件改為物件試試:
public 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 Apr 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) {
// 嘗試改變物件的引用,又不生效。。what the fuck ?
arg = new Date(arg.getYear(), arg.getMonth(), arg.getDate() + 1);
System.out.println("arg in nextDay: " + arg);
}
}
最終輸出如下,有沒有被弄的很迷糊 ??:
arg in nextDay: Thu Apr 02 00:00:00 CST 1998
d1 after nextDay:Thu Apr 02 00:00:00 CST 1998
arg in nextDay: Thu Apr 02 00:00:00 CST 1998
d2 after nextDay:Wed Apr 01 00:00:00 CST 1998
總結
對於要修改的函式變數,乖乖的使用臨時變數,避免造成不必要的混亂
7:替換更優雅的函式實現
示例一
誰都有年少無知,不知天高地厚和輕狂的時候,那時候的我們就容易寫下這樣的程式碼:
String foundPerson(String[] people) {
for (int i = 0; i < perple.length; i++) {
if (peole[i].equals("Trevor")) {
return "Trevor";
}
if (peole[i].equals("Jim")) {
return "Jim";
}
if (peole[i].equals("Phoenix")) {
return "Phoenix";
}
// 弊端:如果加入新人,又要寫很多重複的邏輯和程式碼
// 這種程式碼寫起來好無聊。。而且 CV 大法也容易出錯
}
}
那時候我們程式碼寫的不好,還不自知,但隨著我們的能力和經驗的增改,我們回頭看看自己的程式碼,這簡直是一坨 ? 但是年輕人嘛,總歸要犯一些錯誤,佛說:知錯能改善莫大焉。現在我們變牛逼 ? 了,對於曾經的糟糕程式碼肯定不能不聞不問,所以的重構就是,在不更改輸入和輸出的情況下,給他替換一種更優雅的實現,程式碼如下:
String foundPerson(String[] people) {
// 加入新人,我們擴充套件陣列就好了
List condidates = Arrays.asList(new String[] {"Trevor", "Jim", "Phoenix"});
// 邏輯程式碼不動,不容易出錯
for (int i = 0; i <= people.length; i++) {
if (condidates.equals(people[i])) {
return people[i]
}
}
}
總結
建議:
- 在我們回顧曾經的程式碼的時候,如果你有更好的實現方案(保證輸入輸出相同的前提下),就應該直接替換掉它
- 記得通過單元測試後,再提交程式碼(不想被人打的話)
參考文獻: