代碼重構:函數重構的 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]
}
}
}
總結
建議:
- 在我們回顧曾經的代碼的時候,如果你有更好的實現方案(保證輸入輸出相同的前提下),就應該直接替換掉它
- 記得通過單元測試后,再提交代碼(不想被人打的話)
參考文獻: