代码重构:函数重构的 7 个小技巧

代码重构:函数重构的 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]
    }
  }
}

总结

建议:

  • 在我们回顾曾经的代码的时候,如果你有更好的实现方案(保证输入输出相同的前提下),就应该直接替换掉它
  • 记得通过单元测试后,再提交代码(不想被人打的话)

参考文献:

posted @ 2021-10-09 18:33  肖卫卫讲编程  阅读(1438)  评论(0编辑  收藏  举报