重构这本书由著名的世界软件开发大师Martin Fowler编写,是软件开发领域的经典书籍。书中的部分内容在refactoring.com上也有提及。
什么是重构
视上下文不同,重构有两个定义:
- 重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本
- 重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构
为什么要重构
重构是个工具,它可以用于以下几个目的:
- 重构改进软件设计
- 重构使软件更容易理解
- 重构帮助找到bug
- 重构提高编程速度
何时重构
不需要专门拨出时间进行重构,重构应该随时随地进行。你之所以重构,是因为你想做别的什么事,而重构可以帮助你把那些事做好。
- 事不过三,三则重构
- 添加功能时重构
- 修补错误时重构
- 复审代码时重构
何时不该重构
- 当既有代码实在太混乱,重构不如重写来得简单
- 当项目已接近最后期限,应该避免进行重构,因为已经没有时间了
代码的坏味道
「如果尿布臭了,就换掉它」。代码的坏味道指出了重构的可能性。
- 重复代码 (Duplicated Code)
- 过长函数 (Long Method)
- 过大的类 (Large Class)
- 过长参数列 (Long Parameter List)
- 发散式变化 (Divergent Change)
- switch语句 (Switch Statements)
- 中间人 (Middle Man)
- 异曲同工的类 (Alternative Classes with Different Interfaces)
- 过多的注释 (Comments)
- ...
构筑测试体系
重构的基本技巧「小步前进,频繁测试」已经得到了多年的实践检验。因此如果你想进行重构,首要前提就是拥有一个可靠的测试体系。
常用重构方法
提炼函数 (Extract Method)
当我看见一个过长的函数或者一段需要注释才能让人理解用途的代码,我就会将这段代码放进一个独立函数中
void printOwing() {
printBanner();
//print details
System.out.println ("name: " + _name);
System.out.println ("amount " + amount);
}
void printOwing() {
printBanner();
printDetails(amount);
}
void printDetails (double amount) {
System.out.println ("name: " + _name);
System.out.println ("amount " + amount);
}
引入解释性变量 (Introduce Explaining Variable)
表达式有可能非常复杂而难以阅读。这种情况下,临时变量可以帮助你将表达式分解为比较容易管理的形式。
if ((platform.toUpperCase().indexOf("MAC") > -1) &&
(browser.toUpperCase().indexOf("IE") > -1) &&
wasInitialized() && resize > 0)
{
// do something
}
final boolean isMacOs = platform.toUpperCase().indexOf("MAC") > -1;
final boolean isIEBrowser = browser.toUpperCase().indexOf("IE") > -1;
final boolean wasResized = resize > 0;
if (isMacOs && isIEBrowser && wasInitialized() && wasResized) {
// do something
}
分解临时变量 (Split Temporary Variable)
如果临时变量承担多个责任,它就应该被替换(分解)为多个临时变量,每个变量只承担一个责任。同一个临时变量承担两件不同的事情,会令代码阅读者糊涂。
double temp = 2 * (_height + _width);
System.out.println (temp);
temp = _height * _width;
System.out.println (temp);
final double perimeter = 2 * (_height + _width);
System.out.println (perimeter);
final double area = _height * _width;
System.out.println (area);
移除对参数的赋值 (Remove Assignments to Parameters)
我之所以不喜欢(对参数赋值)这样的做法,因为它降低了代码的清晰度,而且混淆了按值传递和按引用传递这两种参数传递方式。
当然,面对那些使用「输出式参数」(output parameters)的语言,你不必遵循这条规则。不过在那些语言中我会尽量少用输出式参数。
int discount (int inputVal, int quantity, int yearToDate) {
if (inputVal > 50) {
inputVal -= 2;
}
}
int discount (int inputVal, int quantity, int yearToDate) {
int result = inputVal;
if (inputVal > 50) {
result -= 2;
}
}
提炼类 (Extract Class)
某个类做了应该由两个类做的事。
此时你需要考虑哪些部分可以分离出去,并将它们分离到一个单独的类中。
移除中间人 (Remove Middle Man)
每当客户要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数。随着受托类的特性(功能)越来越多,这一过程会让你痛苦不已。服务类完全变成了一个“中间人”,此时你就应该让客户直接调用受托类。
以字面常量取代魔法数 (Replace Magic Number with Symbolic Constant)
所谓魔法数(magic number)是指拥有特殊意义,却又不能明确表现出这种意义的数字。如果你需要在不同的地点引用同一个逻辑数,魔法数会让你烦恼不已,因为一旦这些数发生改变,你就必须在程序中找到所有魔法数,并将它们全部修改一遍,这简直就是一场噩梦。就算你不需要修改,要准确指出每个魔法数的用途,也会让你颇费脑筋。
double potentialEnergy(double mass, double height) {
return mass * 9.81 * height;
}
double potentialEnergy(double mass, double height) {
return mass * GRAVITATIONAL_CONSTANT * height;
}
static final double GRAVITATIONAL_CONSTANT = 9.81;
分解条件表达式 (Decompose Conditional)
程序之中,复杂的条件逻辑是最常导致复杂度上升的地点之一。你必须编写代码来检查不同的条件分支、根据不同的分支做不同的事,然后你很快就会得到一个相当长的函数。
对于条件逻辑,将每个分支条件分解成新函数可以给你带来更多好处:可以突出条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。
if (date.before (SUMMER_START) || date.after(SUMMER_END))
charge = quantity * _winterRate + _winterServiceCharge;
else charge = quantity * _summerRate;
if (notSummer(date))
charge = winterCharge(quantity);
else charge = summerCharge (quantity);
合并条件表达式 (Consolidate Conditional Expression)
之所以要合并条件代码,有两个重要原因。首先,合并后的条件代码会告诉你“实际上只有一次条件检查,只不过有多个并列条件需要检查而已”,从而使这一次检查的用意更清晰。其次,这项重构往往可以为你使用提炼函数(Extract Method)做好准备。将检查条件提炼成一个独立函数对于厘清代码意义非常有用,因为它把描述“做什么”的语句换成了“为什么这样做”。
double disabilityAmount() {
if (_seniority < 2) return 0;
if (_monthsDisabled > 12) return 0;
if (_isPartTime) return 0;
// compute the disability amount
double disabilityAmount() {
if (isNotEligableForDisability()) return 0;
// compute the disability amount
合并重复的条件片段 (Consolidate Duplicate Conditional Fragments)
有时你会发现,一组条件表达式的所有分支都执行了相同的某段代码。如果是这样,你就应该将这段代码搬移到条件表达式外面。这样,代码才能更清楚地表明哪些东西随条件的变化而变化、哪些东西保持不变。
if (isSpecialDeal()) {
total = price * 0.95;
send();
}
else {
total = price * 0.98;
send();
}
if (isSpecialDeal())
total = price * 0.95;
else
total = price * 0.98;
send();
移除控制标记 (Remove Control Flag)
人们之所以会使用这样的控制标记,因为结构化编程原则告诉他们:每个子程序只能有一个入口和一个出口。我赞同“单一入口”原则(而且现代编程语言也强迫我们这样做),但是“单一出口”原则会让你在代码中加入讨厌的控制标记,大大降低条件表达式的可读性。这就是编程语言提供break语句和continue语句的原因:用它们跳出复杂的条件语句。去掉控制标记所产生的效果往往让你大吃一惊:条件语句真正的用途会清晰得多。
boolean checkSecurity(String[] people) {
boolean found = false;
for (int i = 0; i < people.length; i++) {
if (!found){
if (people[i].equals("Don")) {
sendAlert();
found = true;
}
if (people[i].equals("John")) {
sendAlert();
found = true;
}
}
}
return found;
}
boolean checkSecurity(String[] people) {
for (int i = 0; i < people.length; i++) {
if (!found){
if (people[i].equals("Don")) {
sendAlert();
return true;
}
if (people[i].equals("John")) {
sendAlert();
return true;
}
}
}
return false;
}
以卫语句取代嵌套条件表达式 (Replace Nested Conditional with Guard Clauses)
如果条件表达式的两条分支都是正常行为,就应该使用形如if…else…的条件表达式;如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”(guard clauses)。
这个方法的精髓是:给某一条分支以特别的重视。它告诉阅读者:这种情况很罕见,如果它真地发生了,请做一些必要的整理工作,然后退出。
“每个函数只能有一个入口和一个出口”的观念,根深蒂固于某些程序员的脑海里。现今的编程语言都会强制保证每个函数只有一个入口,至于“单一出口”规则,其实不是那么有用。保持代码清晰才是最关键的:如果单一出口能使这个函数更清晰易读,那么就使用单一出口;否则就不必这么做。
double getPayAmount() {
double result;
if (_isDead) result = deadAmount();
else {
if (_isSeparated) result = separatedAmount();
else {
if (_isRetired) result = retiredAmount();
else result = normalPayAmount();
};
}
return result;
};
double getPayAmount() {
if (_isDead) return deadAmount();
if (_isSeparated) return separatedAmount();
if (_isRetired) return retiredAmount();
return normalPayAmount();
};
扩展阅读:关于如何重构嵌套条件表达式,可以阅读如何重构“箭头型”代码,这篇文章更深层次地讨论了这个问题。
将查询函数和修改函数分离 (Separate Query from Modifier)
下面是一条好规则:任何有返回值的函数,都不应该有看得到的副作用。
如果你遇到一个“既有返回值又有副作用”的函数,就应该试着将查询动作从修改动作中分割出来。