《重构 改善既有代码的设计》这本书是Martin Fowler等人撰写的一本关于代码重构的十分精彩的书籍,使用Java语言进行阐明主题,是一本非常精彩,甚至要成为优秀程序员不容错过的专业书籍。
实例非常简单。这是一个影片出租店用的程序,计算每一位顾客的消费金额并打印详单。操作者告诉程序:顾客租了那些影片、租期多长,程序根据租赁事件和影片类型算出费用。影片分为三类:普通片、儿童片和新片。除了计算费用,还要为常客计算积分,积分会根据租片种类是否为新片而不同。
2.1 Movie类(影片)Movie类只是一个单纯的数据类
package org.lilili.refactoring;
/**
* 影片
* @author lilili
* @version 0.0.1
* @date 2019-10-29 15:15
*/
public class Movie {
public static final int CHILDRENS = 2;
public static final int REGULAR = 0;
public static final int NEW_RELEASE = 1;
private String _title;
private int _priceCode;
public Movie(String title, int priceCode) {
_title = title;
_priceCode = priceCode;
}
public int getPriceCode() {
return _priceCode;
}
public String getTitle() {
return _title;
}
public void setPriceCode(int priceCode) {
_priceCode = priceCode;
}
}
2.2 Rental(租赁)Rental表示某个顾客租了一部影片
package org.lilili.refactoring;
/**
* 租赁
* @author lilili
* @version 0.0.1
* @date 2019-10-29 15:28
*/
public class Rental {
private Movie _movie; // 影片
private int _daysRented; // 租期
public Rental(Movie movie, int daysRented) {
_movie = movie;
_daysRented = daysRented;
}
public int getDaysRented() {
return _daysRented;
}
public Movie getMovie() {
return _movie;
}
}
2.3 Customer(顾客)Customer类用来表示顾客,与其他类一样,它也提供了访问用户姓名和增加租赁的方法。另外Customer类提供了一个用于生成详单的函数。
package org.lilili.refactoring;
/**
* 客户
* @author lilili
* @version 0.0.1
* @date 2019-10-29 15:30
*/
import java.util.Enumeration;
import java.util.Vector;
public class Customer {
private String _name; // 姓名
private Vector _rentals = new Vector(); // 租借记
public Customer(String name) {
_name = name;
};
public void addRental(Rental arg) {
_rentals.addElement(arg);
}
public String getName() {
return _name;
}
public String statement() {
double totalAmount = 0; // 总消费金。
int frequentRenterPoints = 0; // 常客积点
Enumeration rentals = _rentals.elements();
String result = "Rental Record for " + getName() + "\n";
while (rentals.hasMoreElements()) {
double thisAmount = 0;
Rental each = (Rental) rentals.nextElement(); // 取得一笔租借记。
// determine amounts for each line
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;
}
// add frequent renter points (累计常客积点。
frequentRenterPoints++;
// add bonus for a two day new release rental
if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE)
&& each.getDaysRented() > 1)
frequentRenterPoints++;
// show figures for this rental(显示此笔租借记录)
result += "\t" + each.getMovie().getTitle() + "\t"
+ String.valueOf(thisAmount) + "\n";
totalAmount += thisAmount;
}
// add footer lines(结尾打印)
result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
result += "You earned " + String.valueOf(frequentRenterPoints)
+ " frequent renter points";
return result;
}
}
2.4 问题
这个程序计的不好,不符合java的OOP设计。这主要是因为Customer类中的statement()方法处理的业务太多了,既要打印foot line,也要统计总的金额,统计常积分,根据影片类型去计算单个影片应该计算的价格,违反了GOF23高内聚原则。
个人提议如果分支过多建议用switch替代if 应为前者直接指向的是内存地址,if需要创建两个寄存器做减法比较。
在例子中,我们的用户希望对用户做一点修改,首先它们希望以HTML格式输出详单,这样就可以直接在网页上显示,非常符合潮流。你会发现,根本不可能在打印HTML报表的函数中复用目前statement()的任何代码,你唯一可以做的事情就是重新编写一个htmlStatement()函数,大量重复statement()的行为。这便会造成duplicated code的代码坏味道。
第二个变化:用户希望改变影片分类规则,但是还没有决定怎么改,他们设想了几种方案,这些方案都会影响顾客消费和常客积分点的计算方式。但你要肯定:不论用户提出什么方案,你唯一能够获得的保证就是他们一定会在六个月内再次修改它。
为了应对这种变化,如果我们把statement函数中的代码复制到打印HTML详单的函数中,就必须确保将来的任何修改在两个地方保持一致,而这就是最不好的一种编程行为,因为它增加了未来开发的复杂度和难度,相当于给后来挖坑。
第一个步骤永远相同:必须有自己调试的能力 ,自我检验能力 可以选择 jUnit4。
重构大刀阔斧,第一个目标就是长的离谱的statement函数。
每当看到这样的函数,我就想要把它大卸八块。要知道,代码块越小,代码的功能越容易管理,代码的处理和移动也就越轻松
4.1 switch部分提炼函数
提炼函数是最常用的重构手法之一。当看到一个过长的函数或者一段需要注释才能理解用途的代码,就要把这段代码放进一个独立的函数。
检查被提炼代码段,看看是否有任何局部变量的值被它改变。
如果一个临时变量值被修改了,看看是否可以将被提炼代码处理为一个查询,并将结果赋值给修改变量。如果很难这样做,或如果被修改的变量不止一个,你就不能仅仅将这段代码原封不动提炼出来。你可能需要先使用 Split Temporary Variable (分解临时变量),然后再尝试提炼。也可以使用 Replace Temp with Query (以查询取代临时变量)将临时变量消灭掉。
关键是在于函数名称和函数本地之间的语义距离所以函数多长不是问题。
重构技术就是以微小的步伐修改程序。如果你犯下错误,很容易便可发现它
重构的本质就是小改动,测试,小改动,测试,由于每次修改的幅度都很小,
所以任何错误都很容易发现。你不必耗费大把时间调试,哪怕你和我一样粗心
注意:任何一个傻瓜都能写出计算机可以理解的代码,唯有写出人类容易理解的代码,才是优秀的程序员。
代码应该表现自己的目的,所以阅读代码的时候应该不断的进行重构,不断的把理解嵌入代码。
private double amountDor(Rental each) {
int thisAmount = 0;
switch (each.getMovie().get_priceCode()) {
case Movie.REGULAR:
thisAmount += 2;
if (each.getDaysRented() > 2) {
thisAmount += (each.getDaysRented() - 2) * 1.5;
}
break;
case Movie.CHILDRENS:
thisAmount += each.getDaysRented() * 3;
break;
case Movie.NEW_RELEASE:
thisAmount += 1.5;
if (each.getDaysRented() > 3) {
thisAmount += (each.getDaysRented() - 3) * 1.5;
}
break;
default:
break;
}
return thisAmount;
}
观察这个amountFor(),我发现这个函数使用了来自Rental类的信息,却没有使用使用来自Customer类的信息。立刻会怀疑是否放错了位置,绝大多数情况下,函数应该放在它所使用的数据的所属对象的对象内,所以amountFor()应该移到Rental类去。为了这么做,使用Move Method 。适应新家,去掉参数,重新改名。
这样,只要改变Customer.amountFor()函数内容,让它委托调用新函数即可。
private double amountDor(Rental aRental) {
return aRental.getCharge();
}
class Rental…
public int getFrequentRenterPoints() {
if ((getMovie().getPriceCode() == Movie.NEW_RELEASE)
&& getDaysRented() > 1)
return 2;
else
return 1;
}
尽量除去这一类临时变量。临时变量往往会引发问题,它们会导致大量参数被传来传去,而其实完全没有这种必要。你很容易跟丢它们,尤其在长长的函数之中更是如此。当然这么做需要付出性能上的代价,例如本例的费用就被计算了两次。
此前的临时变量有两个totalAmount, frequentRenterPoints。使用查询函数query method来取代这两个临时变量。由于totalAmount在循环内部被赋值,不得不把循环复制到查询函数中
private double getTotalCharge() {
double result = 0;
Enumeration rentals = _rentals.elements();
while (rentals.hasMoreElements()) {
Rental each = (Rental) rentals.nextElement();
result += each.getCharge();
}
return result;
}
下述代码移除了totalAmount,同时用Customer类的getTotalCharge()取代totalAmount.
public String statement() {
int frequentRenterPoints = 0;
Enumeration rentals = _rentals.elements();
String result = "Rental Record for " + getName() + "\n";
while (rentals.hasMoreElements()) {
Rental each = (Rental) rentals.nextElement();
frequentRenterPoints += each.getFrequentRenterPoints();
// show figures for this rental
result += "\t" + each.getMovie().getTitle() + "\t"
+ String.valueOf(each.getCharge()) + "\n";
}
// add footer lines
result += "Amount owed is " + String.valueOf(getTotalCharge()) + "\n";
result += "You earned " + String.valueOf(frequentRenterPoints)
+ " frequent renter points";
return result;
}
之后便可在类Rental中提供getFrequentRenterPoints()函数,
这次重构存在另一个问题,那就是性能。原本代码替换两个临时变量之前只执行while循环一次,而新版本
public String statement() {
int frequentRenterPoints = 0;
Enumeration rentals = _rentals.elements();
String result = "Rental Record for " + getName() + "\n";
while (rentals.hasMoreElements()) {
Rental each = (Rental) rentals.nextElement();
frequentRenterPoints += each.getFrequentRenterPoints();
// show figures for this rental
result += "\t" + each.getMovie().getTitle() + "\t"
+ String.valueOf(each.getCharge()) + "\n";
}
// add footer lines
result += "Amount owed is " + String.valueOf(getTotalCharge()) + "\n";
result += "You earned " + String.valueOf(getTotalFrequentRenterPoints())
+ " frequent renter points";
return result;
}
要执行三次。如果while循环耗时很多,就可能大大降低程序的性能。单单为了这个原因,很多程序员就不愿进行这个重构动作。但是请注意我的用词,”如果”和“可能”。除非我进行评测,否则我无法确定循环的执行时间,也无法知道这个循环是否被经常使用以至于影响系统的整体性能。重构时你不必担心这些,优化时你才需要担心它们,但那时候你已经处于一个比较有利的位置,有更多的选择可以完成有效优化。
至此,脱下“重构”的帽子,戴上“添加功能”的帽子,可以如下添加htmlStatement函数
class Customer…
public String htmlStatement() {
Enumeration rentals = _rentals.elements();
String result = "Rentals for " + getName() + " H1>
\n";
while (rentals.hasMoreElements()) {
Rental each = (Rental) rentals.nextElement();
// show figures for each rental
result += each.getMovie().getTitle() + ": "
+ String.valueOf(each.getCharge()) + "
\n";
}
// add footer lines
result += "
You owe " + String.valueOf(getTotalCharge())
+ "
\n";
result += "On this rental you earned "
+ String.valueOf(getTotalFrequentRenterPoints())
+ " frequent renter points
";
return result;
}
可以看到,在这种情况下,不必进行剪剪贴贴,如果计算规则发生该改变,也只需要在一处修改,完成其他类型的详单也很快而且很容易。
更深入的重构动作可以清楚主要的循环设置部分代码。可以把处理表头header,表尾footer和详单西姆的代码都分别提炼出来,使用Form Template Method,利用模板方法模式进行重构。不在此赘述了。
4.6.1 起初
这个问题的第一部分是switch语句。最好不要再另一个对象的属性基础上运用switch语句,因为如果在另一个对象使用switch,如果该类型发生了变化,需要同时修改两种类型。
class Rental…
public double getCharge() {
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;
}
getMovie().getPriceCode()暗示应该把getCharge()函数移到Movie类里去。修改成如下
class Movie…
public double getCharge(int daysRented) {
double result = 0;
switch (getPriceCode()) {
case Movie.REGULAR:
result += 2;
if (daysRented > 2)
result += (daysRented - 2) * 1.5;
break;
case Movie.NEW_RELEASE:
result += daysRented * 3;
break;
case Movie.CHILDRENS:
result += 1.5;
if (daysRented > 3)
result += (daysRented - 3) * 1.5;
break;
}
return result;
}
为了让它得以运作,我必须把租期长度作为参数传递进去。当然,租期长度来自Rental对象。计算费用时需要两项数据:租期长度和影片类型。为什么选择将租期长度传给Movie对象,而不是将影片类型传给Rental对象呢?因为本系统可能发生的变化是加入新的影片类型,这种变化带有不稳定性。如果影片类型有所变化,我希望尽量控制它造成的影响,所以选择在Movie对象中计算。
把上述计费方法放进Movie类,然后修改Rental的getCharge(),让它调用这个新函数:
double getCharge() {
return _movie.getCharge(_daysRented);
}
Move Method(搬移函数)应用场景是你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。
主要思想:在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是将旧函数完全移除。
搬移函数是重构理论的支柱。如果一个类有太多行为,或如果一个类与另外一个类有太多合作而形成高度耦合就可以使用搬移函数。
在搬移getCharge()之后,以相同手法处理常客积分计算,这样就把根据影片类型而变化的所有东西,都放到了影片类型所属的类中。
5 去除switch
在这一部分,主要是回答了2.4问题中的第二个问题,如何添加新的类型码的问题。
5.1 继承
我们有数种影片类型,它们以不同的方式回答相同的问题。这听起来很像子类的工作,因此我们可以使用如下的结构图,建立三个子类,每个都有自己的计费方法
但遗憾的是这里有个小问题,一部影片可以在生命期内修改自己的分类,一个对象却不能在生命周期内修改自己所属的类。此时可以使用State模式。
加入这一层间接层,就可以在Price对象内进行子类化动作,于是可以在任何必要时刻修改价格。此时需要三种重构手法
1 Replace Type code with State/Strategy 将与类型相关的行为banyi 到State模式内
2 使用Move Method将switch语句移到Price类
3 最后使用Replace conditional with Polymorphism去掉switch子句
针对类型代码使用Self Encapsulate Field,确保任何时候都能通过取值函数和设值函数来访问类型代码。多数访问操作来自其他类,它们已经在使用取值函数,但构造函数仍然直接访问价格代码。
现在建立一个新类Price类,并在其中提供类型相关的行为。为了实现这一点,在Price类中加入一个抽象函数,并在所有子类中加上对应的实现:
public abstract class Price {
abstract int getPriceCode(); // 取得价格代号
}
分别建立三个Price的子类ChildrensPrice、NewReleasePrice、RegularPrice,并在其中实现getPriceCode()方法
public class NewReleasePrice extends Price {
int getPriceCode() {
return Movie.NEW_RELEASE;
}
}
修改Movie类中与“priceCode”相关的访问函数(取值函数/设置函数),让它们使用新类
重构之前
public class Movie {
private int _priceCode;
public int getPriceCode() {
return _priceCode;
}
public void setPriceCode(int priceCode) {
_priceCode = priceCode;
}
}
重构要使用Price类型继承层次,意味着必须在Movie类内部保存一个Price对象,而不再是一个_priceCode变量。同时要修改访问函数
public class Movie {
private Price _price; //关键之处,把类型码替换为价格对象
public int getPriceCode() { // 取得价格代号
return _price.getPriceCode();
}
public void setPriceCode(int arg) { // 设定价格代号
switch (arg) {
case REGULAR:
_price = new RegularPrice();
break;
case CHILDRENS:
_price = new ChildrensPrice();
break;
case NEW_RELEASE:
_price = new NewReleasePrice();
break;
default:
throw new IllegalArgumentException("Incorrect Price Code");
}
}
}
对getCharge()实施Move Method,因为getCharge()函数中有switch,因此要推入Price基类中。
class Movie…
public double getCharge(int daysRented) {
return _price.getCharge(daysRented);
}
public abstract class Price {
abstract int getPriceCode(); // 取得价格代号
public double getCharge(int daysRented) {
double result = 0;
switch (getPriceCode()) {
case Movie.REGULAR:
result += 2;
if (daysRented > 2)
result += (daysRented - 2) * 1.5;
break;
case Movie.NEW_RELEASE:
result += daysRented * 3;
break;
case Movie.CHILDRENS:
result += 1.5;
if (daysRented > 3)
result += (daysRented - 3) * 1.5;
break;
}
return result;
}
}
搬移getCharge()到Price基类之后,开始使用Replace Conditional with Polymorphism,做法是一次取出一个case分支,在相应的类中建立一个覆盖函数。先从RegularPrice类开始:
public class RegularPrice extends Price {
int getPriceCode() {
return Movie.REGULAR;
}
public double getCharge(int daysRented) {
//少于两天,该类租金2块,每多一天,租金1.5元
double result = 2;
if (daysRented > 2)
result += (daysRented - 2) * 1.5;
return result;
}
}
这个函数覆盖了父类中的case语句,而我暂时还把后者留在原处。现在编译并测试,然后取出下一个case分支,一直保持这样直到处理完所有的case分支。
处理完所有的case分支之后,就可以把Price.getCharge()声明为abstract。
class Price…
abstract double getCharge(int daysRented);
以同样的手法处理getFrequentRenterPoints()
重构前
class Movie…
public int getFrequentRenterPoints(int daysRented) {
//if-else可以理解为switch结构,因此可以同样的手法处理该函数
if ((getPriceCode() == Movie.NEW_RELEASE) && daysRented > 1)
return 2;
else
return 1;
}
首先把这个函数移到Price类中:
class Movie…
public int getFrequentRenterPoints(int daysRented) {
return _price.getFrequentRenterPoints(daysRented);//还是委托
}
//仅仅做一次搬移
public abstract class Price {
abstract int getPriceCode(); // 取得价格代号
abstract double getCharge(int daysRented);
public int getFrequentRenterPoints(int daysRented) {
if ((getPriceCode() == Movie.NEW_RELEASE) && daysRented > 1)
return 2;
else
return 1;
}
}
但是这一次不把超类声明为abstract,只是为新片类型增加一个覆写函数,并在超类内保留下一个已定义的函数,使他成为一种默认的行为。
public abstract class Price {
abstract int getPriceCode(); // 取得价格代号
abstract double getCharge(int daysRented);
// public int getFrequentRenterPoints(int daysRented) {
// if ((getPriceCode() == Movie.NEW_RELEASE) && daysRented > 1)
// return 2;
// else
// return 1;
// }
//不声明为abstract类,作为默认行为
public int getFrequentRenterPoints(int daysRented){
return 1;
}
}
public class NewReleasePrice extends Price {
int getPriceCode() {
return Movie.NEW_RELEASE;
}
public double getCharge(int daysRented) {
return daysRented * 3;
}
//对于新影片,覆盖默认行为获取自定义的常客积分计算方式
public int getFrequentRenterPoints(int daysRented) {
return (daysRented > 1) ? 2 : 1;
}
}
这么做有值得吗?这么做的收获是:如果我要修改任何与价格相关的行为,或是添加新的定价标准,或是加入其他取决于价格的行为,程序的修改会容易的多。
至此,已经完成了第二个重要的重构行为,从此,修改影片分类结构,或是改变费用计算规则,改变常客积分计算规则,都容易多了。