程式碼重構:函式重構的 7 個小技巧

小二十七發表於2021-10-09

程式碼重構:函式重構的 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;
}

總結

使用函式替代表示式替代表示式,對於程式來說有以下幾點好處:

  1. 封裝表示式的計算過程,呼叫方無需關心結果是怎麼計算出來的,符合 OOP 原則
  2. 當計算過程發生改動,也不會影響呼叫方,只要修改函式本身即可

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]
    }
  }
}

總結

建議:

  • 在我們回顧曾經的程式碼的時候,如果你有更好的實現方案(保證輸入輸出相同的前提下),就應該直接替換掉它
  • 記得通過單元測試後,再提交程式碼(不想被人打的話)

參考文獻:

相關文章