借用一个影片出租店的程序来演示重构的过程。
为什么要对代码进行重构?
编译器才不会在乎代码好不好看,但是当我们打算修改系统的时候,就涉及到了人,而人在乎这些。不规范的代码或者说差劲的系统是很难修改的,程序员很难找到修改点,修改起来也要浪费许多精力和时间,而且也很容易引入bug。其实很多时候程序并没有坏掉,但它却造成了伤害,它让你的生活比较难过,所以我们有必要对自己的代码进行重构。
下面借用一个影片出租店的程序来演示重构的过程来学习一下重构的思想。
customer表示一个顾客:name (姓名),rentals (rental集合,租用信息),statement(计算租用金额方法)
rental表示某个顾客租了一部影片:movie (影片,Movie对象),daysRented(租用天数)
movie表示电影:title (电影名称),priceCode(电影类型),CHILDRENs = 2; REGULAR = 0; NEW_RELEASE = 1(三个不可变常量)
customer 顾客类中的statement()方法:
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
Enumeration<Rental> rentals = this.rentals.elements();
String result = "Rental Record for " + getName() + "\n";
while (rentals.hasMoreElements()) {
double thisAmount = 0;
Rental each = rentals.nextElement();
switch (each.getMovie().getPriceCode()) {
case Movie.REGULAR:
thisAmount += 2;
if (each.getDaysRented() > 2)
thisAmount += (each.getDaysRented() - 2) * 1.5;
break;
case Movie.NEW_RELEASE:
thisAmount += each.getDaysRented() * 3;
break;
case Movie.CHILDRENs:
thisAmount += 1.5;
if (each.getDaysRented() > 3)
thisAmount += (each.getDaysRented() - 3) * 1.5;
break;
}
frequentRenterPoints++;
if (each.getMovie().getPriceCode() == Movie.NEW_RELEASE
&& each.getDaysRented() > 1)
frequentRenterPoints++;
result += "\t" + each.getMovie().getTitle() + "\t"
+ String.valueOf(thisAmount) + "\n";
totalAmount += thisAmount;
}
result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
result += "You earned " + String.valueOf(frequentRenterPoints)
+ " frequent renter points";
return result;
}
一看到这个程序,customer 顾客类中的statement()方法就让人有一种不想去看它的想法。如果客户想对系统进行更改,比如:输出账单格式改变,计算租用费用改变,影片父类规则改变等,为了完成客户所需的修改会要不停修改已有代码,代码也无法复用。而且这个方法做的事情也有点多,不符合单一职责原则,造成逻辑泥团,有必要对其进行拆分。
如果你发现自己需要为程序添加一个特性,而代码结构使你无法很方便地达成目的,那就先重构这个程序,使特性比较容易进行,然后再添加特性。
重构第一步:
修改了代码,测试是必要的,如果程序员一个一个自己去比较会花费大把时间降低开发速度,就这个程序而言,statement()方法返回的是个字符串,只需比较字符串是否相同,所以可以写一个测试程序对结果进行比较,成功就返回‘ok’,既省时又省力。
所以,非常有必要建立一套可靠的测试机制,这些测试必须有自我检测能力。
分解并重组statement()方法:
代码愈小,代码的功能愈容易管理,代码的处理和移动也就愈轻松。
如何把statement()方法切开?第一个步骤是找到代码的逻辑泥团,本例中的也就是switch语句,我们把它提炼到独立函数中比较好。如何提炼?首先找到这段代码中的局部变量和参数。thisAmount (会被修改)和 each(不会被修改),任何不会被修改的变量都可以被当成参数传入新的函数,被修改的就要格外小心。如果只有一个变量被修改可以作为函数的返回值。
将新函数的变量名称更改一下:each改为aRental,thisAmount改为result。更改变量名称是非常值得的行为,这样可以提高代码的清晰度,使人看到变量名就知道它是用来做什么的。代码应该表现自己的目的。
Customer类中
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
Enumeration<Rental> rentals = this.rentals.elements();
String result = "Rental Record for " + getName() + "\n";
while (rentals.hasMoreElements()) {
double thisAmount = 0;
Rental each = rentals.nextElement();
thisAmount = getAmount(each);
frequentRenterPoints++;
if (each.getMovie().getPriceCode() == Movie.NEW_RELEASE
&& each.getDaysRented() > 1)
frequentRenterPoints++;
result += "\t" + each.getMovie().getTitle() + "\t"
+ String.valueOf(thisAmount) + "\n";
totalAmount += thisAmount;
}
result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
result += "You earned " + String.valueOf(frequentRenterPoints)
+ " frequent renter points";
return result;
}
提出来变成一个新函数
private double getAmount(Rental aRental) {
double result= 0;
switch (aRental.getMovie().getPriceCode()) {
case Movie.REGULAR:
result+= 2;
if (aRental.getDaysRented() > 2)
result+= (aRental.getDaysRented() - 2) * 1.5;
break;
case Movie.NEW_RELEASE:
result+= aRental.getDaysRented() * 3;
break;
case Movie.CHILDRENs:
result+= 1.5;
if (aRental.getDaysRented() > 3)
result+= (aRental.getDaysRented() - 3) * 1.5;
break;
}
return result;
}
getAmount()函数使用了来自Rental类的信息,却没有使用Customer类的信息。绝大多数情况下,函数应该放在它所使用的数据的所属对象内。所以这个函数放在Rental类中更为合适。
搬移“金额计算”代码:
Rental类中增加方法
public double getAmount() {
double result = 0;
switch (getMovie().getPriceCode()) {
case Movie.REGULAR:
result += 2;
if (getDaysRented() > 2)
result += (getDaysRented() - 2) * 1.5;
break;
case Movie.NEW_RELEASE:
result += getDaysRented() * 3;
break;
case Movie.CHILDRENs:
result += 1.5;
if (getDaysRented() > 3)
result += (getDaysRented() - 3) * 1.5;
break;
}
return result;
}
再次回到customer 顾客类中的statement()方法,方法中的临时变量thisAmount便可以去掉了。接着处理常客积分计算,同样使用了来自Rental类的信息,可以将其放入Rental类中。
拆出两个函数之后:
Customer类中
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
Enumeration<Rental> rentals = this.rentals.elements();
String result = "Rental Record for " + getName() + "\n";
while (rentals.hasMoreElements()) {
Rental each = rentals.nextElement();
frequentRenterPoints += each.getFrequentRenterPoints();
result += "\t" + each.getMovie().getTitle() + "\t"
+ String.valueOf(each.getAmount()) + "\n";
totalAmount += each.getAmount();
}
result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
result += "You earned " + String.valueOf(frequentRenterPoints)
+ " frequent renter points";
return result;
}
Rental类中增加方法
public int getFrequentRenterPoints() {
if (getMovie().getPriceCode() == Movie.NEW_RELEASE
&& getDaysRented() > 1)
return 2;
else
return 1;
}
去除临时变量:
临时变量可能是个问题,它们只在自己所属的函数中有效为了减少冗长复杂的函数,有必要去除不需要的临时变量。
去除customer 顾客类中的statement()方法中不必要的临时变量totalAmount,frequentRenterPoints ;把计算总金额和积分的代码从statement()方法中提炼出来也使的statement()方法更加清晰。
Customer类中
public String statement() {
Enumeration<Rental> rentals = this.rentals.elements();
String result = "Rental Record for " + getName() + "\n";
while (rentals.hasMoreElements()) {
Rental each = rentals.nextElement();
result += "\t" + each.getMovie().getTitle() + "\t"
+ String.valueOf(each.getCharge()) + "\n";
}
result += "Amount owed is " + String.valueOf(getTotalAmount()) + "\n";
result += "You earned "
+ String.valueOf(getTotalFrequentRenterPoints())
+ " frequent renter points";
return result;
}
public double getTotalAmount() {
double result = 0;
Enumeration<Rental> rentals = this.rentals.elements();
while (rentals.hasMoreElements()) {
Rental each = rentals.nextElement();
result += each.getCharge();
}
return result;
}
public int getTotalFrequentRenterPoints() {
int result = 0;
Enumeration<Rental> rentals = this.rentals.elements();
while (rentals.hasMoreElements()) {
Rental each = rentals.nextElement();
result += each.getFrequentRenterPoints();
}
return result;
}
运用多态取代与价格相关的条件逻辑:
最好不要在另一个对象的属性基础上运用switch语句,如果不得不使用switch语句,也应该在对象自己的数据上使用。
所以,getAmount()和getFrequentRenterPoints()里的计算代码使用了影片类型做条件,这些代码应该移到Movie类里去。计算时所需的租用天数就必须要作为参数传入。
Rental类中
public double getAmount() {
return movie.getAmount(daysRented);
}
public int getFrequentRenterPoints() {
return movie.getFrequentRenterPoints(daysRented);
}
Movie类中
public double getAmount(int daysRented) {
double result = 0;
switch (priceCode) {
case REGULAR:
result += 2;
if (daysRented > 2)
result += (daysRented - 2) * 1.5;
break;
case NEW_RELEASE:
result += daysRented * 3;
break;
case CHILDRENs:
result += 1.5;
if (daysRented > 3)
result += (daysRented - 3) * 1.5;
break;
}
return result;
}
public int getFrequentRenterPoints(int daysRented) {
if (priceCode == NEW_RELEASE && daysRented > 1)
return 2;
else
return 1;
}
通过这样提炼以后,如果计算规则发生改变,只需在程序中做一处修改,不必剪剪贴贴。如果客户的影片分类规则发生变化,与之对应的费用计算方式和常客积分计算方式也会要修改,但这些规则客户还未决定好,所以我们有必要进入费用计算和常客计算中,把因条件而异的代码替换掉,为将来的改变镀上一层保护膜。开闭原则。
根据电影的共同特性写一个接口Price类。将Movie类中的priceCode属性换成接口Price类,Price类含有计算租用总金额方法,计算租用总积分方法,以及获得租用电影类型的价格等级方法。
Price类
public interface Price {
public int getPriceCode();
public double getAmount(int days);
public int getFrequentRenterPoints(int days);
}
普通片 RegularPrice类
public class RegularPrice implements Price {
@Override
public int getPriceCode() {
return Movie.REGULAR;
}
@Override
public double getAmount(int days) {
double result = 2;
if (days > 2)
result += (days - 2) * 1.5;
return result;
}
@Override
public int getFrequentRenterPoints(int days) {
return 1;
}
}
新片 NewReleasePrice类
public class NewReleasePrice implements Price {
@Override
public int getPriceCode() {
return Movie.NEW_RELEASE;
}
@Override
public double getAmount(int days) {
return days * 3;
}
@Override
public int getFrequentRenterPoints(int days) {
return 2;
}
}
儿童片 ChildrensPrice类
public class ChildrensPrice implements Price {
@Override
public int getPriceCode() {
return Movie.CHILDRENs;
}
@Override
public double getAmount(int days) {
double result = 1.5;
if (days > 3)
result += (days - 3) * 1.5;
return result;
}
@Override
public int getFrequentRenterPoints(int days) {
return 1;
}
}
相对应的Movie类中的方法也要做相应修改
Movie类中
private Price price;
Movie(String title,int priceCode){
this.title = title;
setPriceCode(priceCode);
}
public Double getAmount(int days){
return price.getAmount(days);
}
public int getFrequentRenterPoints(int days){
return price.getFrequentRenterPoints(days);
}
public void setPriceCode(int priceCode) {
switch(priceCode){
case REGULAR:
price = new RegularPrice();
break;
case NEW_RELEASE:
price = new NewReleasePrice();
break;
case CHILDRENs:
price = new ChildrensPrice();
break;
}
}
到此,重构过程结束。对比重构前后的代码,可读性及可维护性都得到了大大的提升。
总结:
1、找到代码的逻辑泥团,把其中的逻辑剥离成一个一个的方法,方法命名做到看名知意。
2、每个方法只做一件事情(例如算一本书的租用费用),每个方法抽象层级不能多于两层,方法中尽量少使用临时变量。
3、每个方法移至对应的类,绝大多数情况下,函数应该放在它所使用的数据的所属对象的类中。