代码重构:函数重构的 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;
}
总结
使用函数替代表达式替代表达式,对于程序来说有以下几点好处:
- 封装表达式的计算过程,调用方无需关心结果是怎么计算出来的,符合 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]
}
}
}
总结
建议:
- 在我们回顾曾经的代码的时候,如果你有更好的实现方案(保证输入输出相同的前提下),就应该直接替换掉它
- 记得通过单元测试后,再提交代码(不想被人打的话)
参考文献: