第一章最前面的书中前面的话说得很有道理,一本授之以渔的书,开场就来历史、原理性的东西,很难勾起继续阅读的欲望,写书是这样,开会做分享亦然,所以作者精挑细选了一个代码规模不是很大并且能告诉我们很多重构的道理。
案例说明
这是一个非常简单的案例,展示了一个影片出租店用的程序,计算每一位顾客的消费金额并打印详单的模块,同时还需要计算每一位客人的积分。给出一个 UML 图,是最初版本。
抽象了三个实体:影片(片名、片类型)、租赁(影片、租赁天数)、顾客(姓名、租赁清单),代码详见下面的三个类以及 MainClass。
public class Customer { private String name; private Vector<Rental> rentals = new Vector<Rental>(); public Customer(String name) { this.name = name; } public String getName() { return name; } public Vector<Rental> getRentals() { return rentals; } public void addRentals(Rental rental) { this.rentals.add(rental); } public String statement() { double totalAmount = 0; int frequentRenterPointers = 0; Enumeration<Rental> rentalEnumeration = rentals.elements(); String result = "Rental Records for " + getName() + "\n"; while (rentalEnumeration.hasMoreElements()) { double thisAmount = 0; Rental each = rentalEnumeration.nextElement(); // determine amounts for each line switch (each.getMovie().getPriceCode()) { case Movie.REGULAR: thisAmount += 2; if (each.getDayRented() > 2) { thisAmount += (each.getDayRented() - 2) * 1.5; } break; case Movie.NEW_RELEASE: thisAmount += each.getDayRented() * 3; break; case Movie.CHILDREN: thisAmount += 1.5; if (each.getDayRented() > 3) { thisAmount += (each.getDayRented() - 3) * 1.5; } break; } // add frequent renter points frequentRenterPointers++; // add bonus for two day new release rental if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDayRented() > 1) { frequentRenterPointers++; } //show figures for this rental result += "\t" + each.getMovie().getTitle() + "\t" + thisAmount + "\n"; totalAmount += thisAmount; } // add footer lines result += "Amount owed is " + totalAmount + "\n"; result += "You earned " + frequentRenterPointers + " frequent renter points"; return result; } } public class Movie { public static final int REGULAR = 0; public static final int NEW_RELEASE = 1; public static final int CHILDREN = 2; private String title; private int priceCode; public Movie(String title, int priceCode) { this.title = title; this.priceCode = priceCode; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public int getPriceCode() { return priceCode; } public void setPriceCode(int priceCode) { this.priceCode = priceCode; } } public class Rental { private Movie movie; private int dayRented; public Rental(Movie movie, int dayRented) { this.movie = movie; this.dayRented = dayRented; } public Movie getMovie() { return movie; } public void setMovie(Movie movie) { this.movie = movie; } public int getDayRented() { return dayRented; } public void setDayRented(int dayRented) { this.dayRented = dayRented; } } public class MainClass { public static void main(String[] args) { // 三部影片 Movie movie1 = new Movie("复仇者联盟", Movie.NEW_RELEASE); Movie movie2 = new Movie("超能陆战队", Movie.CHILDREN); Movie movie3 = new Movie("澳门风云", Movie.REGULAR); //两名顾客 Customer customer1 = new Customer("Jack"); Customer customer2 = new Customer("Stary"); //顾客租约 Rental rental1 = new Rental(movie1, 5); Rental rental2 = new Rental(movie2, 3); Rental rental3 = new Rental(movie3, 3); customer1.addRentals(rental1); customer1.addRentals(rental2); customer2.addRentals(rental2); customer2.addRentals(rental3); String statement1 = customer1.statement(); String statement2 = customer2.statement(); System.out.println(statement1); System.out.println("-----------------------"); System.out.println(statement2); } }
重构前提是不改变源程序的输出结果,原始的运行结果如下:
Rental Records for Jack
复仇者联盟 15.0
超能陆战队 1.5
Amount owed is 16.5
You earned 3 frequent renter points
-----------------------
Rental Records for Stary
超能陆战队 1.5
澳门风云 3.5
Amount owed is 5.0
Customer提供了 statement 方法来输出租赁清单信息
对这个例子,从功能需求上说,程序代码是没有什么问题,可是在用户要提出新的需求,比如,① 现在不仅仅要在console输出,需要能输出成 HTML 串显示成网页。针对这个需求,只能再编写一个全新的 htmlStatement(), 大量重复 statement()的行为。 ② 如果计费标准发生改变怎么办?statement 和 htmlStatement 方法都需要修改,并确保修改的一致性。后续还需要再修改,又得剪剪贴贴,复制粘贴的行为是造成一定的潜在的威胁。
军规1:如果你发现自己需要为程序添加一个特性,而代码结构使你无法方便达成目的,那就重构那个程序,使特性的添加比较容易进行,然后再添加特性。
重构第一步
重构的第一步就是要为即将修改的代码建立一组可靠的测试环境,这些测试是必要的,避免因为重构带来新的 bug。后面将会单独介绍构建测试机制。
军规2:重构之前,首先检查自己是否有一套可靠的测试机制,这些测试必须有自我检验的能力。
分解并重组 statement()
v01 中 statement 方法就是那种很长的代码,属于代码坏味道的一种(Long Method),需要分解长函数,把较小的代码移至更合适的地方。重构 statement 可以分 以下几个步骤:
① 提炼 Switch 语句,提炼到单独的函数中比较合适。
这一段只有两个临时变量,thisAmount 和 each,直接利用 idea 的 Refactor->Extract->Method 即可完成方法的提取。这里希望把 thisAmount 作为它的返回值,each 传入即可。提取成 amountFor 函数,然后修改函数中不好的变量名,如 thisAmount->result, 参数 each->rental。使用 idea 提供的 Refactor->Rename 即可快速重命名,清晰易懂的变量名称是代码能否容易理解的关键之所在。
军规3:任何一个傻瓜都能写出计算机可以理解的代码,唯有写出人类容易理解的代码,才是优秀的程序员。
原 statement 方法重构成如下:
public class Customer { ..... public String statement() { double totalAmount = 0; int frequentRenterPointers = 0; Enumeration<Rental> rentalEnumeration = rentals.elements(); String result = "Rental Records for " + getName() + "\n"; while (rentalEnumeration.hasMoreElements()) { double thisAmount = 0; Rental each = rentalEnumeration.nextElement(); // determine amounts for each line thisAmount = amountFor(each); // add frequent renter points frequentRenterPointers++; // add bonus for two day new release rental if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDayRented() > 1) { frequentRenterPointers++; } //show figures for this rental result += "\t" + each.getMovie().getTitle() + "\t" + thisAmount + "\n"; totalAmount += thisAmount; } // add footer lines result += "Amount owed is " + totalAmount + "\n"; result += "You earned " + frequentRenterPointers + " frequent renter points"; return result; } private double amountFor(Rental rental) { double result = 0; switch (rental.getMovie().getPriceCode()) { case Movie.REGULAR: result += 2; if (rental.getDayRented() > 2) { result += (rental.getDayRented() - 2) * 1.5; } break; case Movie.NEW_RELEASE: result += rental.getDayRented() * 3; break; case Movie.CHILDREN: result += 1.5; if (rental.getDayRented() > 3) { result += (rental.getDayRented() - 3) * 1.5; } break; } return result; } }
② 将 amountFor 移至合适的位置
可以发现 amountFor 函数使用了来自 rental 类的信息,却没有使用来自 Customer 中的任何信息,可以考虑将 amountFor 移至 Rental 中。用一个更适合的名字 getCharge()来命名这个 amountFor。 使用 idea 帮你完成:Refactor->Move(将可见性设置为 public),同时改一下名称。 修改类如下:
public class Rental { .... public double getCharge() { double result = 0; switch (getMovie().getPriceCode()) { case Movie.REGULAR: result += 2; if (getDayRented() > 2) { result += (getDayRented() - 2) * 1.5; } break; case Movie.NEW_RELEASE: result += getDayRented() * 3; break; case Movie.CHILDREN: result += 1.5; if (getDayRented() > 3) { result += (getDayRented() - 3) * 1.5; } break; } return result; } } public class Customer { ... public String statement() { double totalAmount = 0; int frequentRenterPointers = 0; Enumeration<Rental> rentalEnumeration = rentals.elements(); String result = "Rental Records for " + getName() + "\n"; while (rentalEnumeration.hasMoreElements()) { double thisAmount = 0; Rental each = rentalEnumeration.nextElement(); // determine amounts for each line thisAmount = each.getCharge(); // add frequent renter points frequentRenterPointers++; // add bonus for two day new release rental if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDayRented() > 1) { frequentRenterPointers++; } //show figures for this rental result += "\t" + each.getMovie().getTitle() + "\t" + thisAmount + "\n"; totalAmount += thisAmount; } // add footer lines result += "Amount owed is " + totalAmount + "\n"; result += "You earned " + frequentRenterPointers + " frequent renter points"; return result; } }
③ 清理变量 thisAmount
发现变量 thisAmount 只是接受了一下 getCharge(),后面没有作任何变化,可以采用以查询替换变量原则去除变量 thisAmount,暂时先不讨论两次计算带来的性能开销,这个完全可以优化掉。修改之后的如下:
public class Customer { ... public String statement() { double totalAmount = 0; int frequentRenterPointers = 0; Enumeration<Rental> rentalEnumeration = rentals.elements(); String result = "Rental Records for " + getName() + "\n"; while (rentalEnumeration.hasMoreElements()) { Rental each = rentalEnumeration.nextElement(); // add frequent renter points frequentRenterPointers++; // add bonus for two day new release rental if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDayRented() > 1) { frequentRenterPointers++; } //show figures for this rental result += "\t" + each.getMovie().getTitle() + "\t" + each.getCharge() + "\n"; totalAmount += each.getCharge(); } // add footer lines result += "Amount owed is " + totalAmount + "\n"; result += "You earned " + frequentRenterPointers + " frequent renter points"; return result; } }
④ 提炼“常客积分计算”代码
可以发现,积分计算逻辑只跟影片种类有关系,似乎有理由将计算责任移到 Rental 类中,可以先对计算积分部分采用Refactor->Extract->Method,然后采用 Refactor->Move 移至 Rental 中,后面采用一样的方式替换掉变量。重构之后的如下:
public class Customer { ... public String statement() { double totalAmount = 0; int frequentRenterPointers = 0; Enumeration<Rental> rentalEnumeration = rentals.elements(); String result = "Rental Records for " + getName() + "\n"; while (rentalEnumeration.hasMoreElements()) { Rental each = rentalEnumeration.nextElement(); frequentRenterPointers += each.getFrequentRenterPointers(); //show figures for this rental result += "\t" + each.getMovie().getTitle() + "\t" + each.getCharge() + "\n"; totalAmount += each.getCharge(); } // add footer lines result += "Amount owed is " + totalAmount + "\n"; result += "You earned " + frequentRenterPointers + " frequent renter points"; return result; } } public class Rental { ... public int getFrequentRenterPointers() { // add bonus for two day new release rental if ((getMovie().getPriceCode() == Movie.NEW_RELEASE) && getDayRented() > 1) { return 2; } return 1; } }
⑤ 去除totalAmount和frequentRenterPointers
经过上述重构之后,发现临时变量依旧是一个灾难(这里只是比较少而已,可以保留,为了演示重构手法,忽略过于重构的嫌疑!),就需要再使用用查询代替临时变量(Replace Temp With Query)的手法来清理这两个变量,也方便后面添加 htmlStatement。重构之后添加 htmlStatement 之后:
public class Customer { ... public String statement() { Enumeration<Rental> rentalEnumeration = rentals.elements(); String result = "Rental Records for " + getName() + "\n"; while (rentalEnumeration.hasMoreElements()) { Rental each = rentalEnumeration.nextElement(); // show figures for this rental result += "\t" + each.getMovie().getTitle() + "\t" + each.getCharge() + "\n"; } // add footer lines result += "Amount owed is " + getTotalCharge() + "\n"; result += "You earned " + getTotalFrequentRenterPointers() + " frequent renter points"; return result; } public String htmlStatement() { Enumeration<Rental> rentalEnumeration = rentals.elements(); String result = "<h1>Rental Records for " + getName() + "</h1>\n"; result += "<table border='1'><tr><td>Movie Name</td><td>Charge</tr>\n"; while (rentalEnumeration.hasMoreElements()) { Rental each = rentalEnumeration.nextElement(); // show figures for this rental result += "\t<tr><td>" + each.getMovie().getTitle() + "</td><td>" + each.getCharge() + "</td></tr>\n"; } // add footer lines result += "\t<tr><td colspan='2'>Total Charge:" + getTotalCharge() + "</td></tr>\n"; result += "\t<tr><td colspan='2'>Total Pointers:" + getTotalFrequentRenterPointers() + "</td></tr>\n"; result += "</table>"; return result; } private double getTotalCharge() { double result = 0; Enumeration<Rental> rentalEnumeration = rentals.elements(); while (rentalEnumeration.hasMoreElements()) { result += rentalEnumeration.nextElement().getCharge(); } return result; } private double getTotalFrequentRenterPointers() { double result = 0; Enumeration<Rental> rentalEnumeration = rentals.elements(); while (rentalEnumeration.hasMoreElements()) { result += rentalEnumeration.nextElement().getFrequentRenterPointers(); } return result; } } public class MainClass { ... public static void main(String[] args) { // 三部影片 Movie movie1 = new Movie("复仇者联盟", Movie.NEW_RELEASE); Movie movie2 = new Movie("超能陆战队", Movie.CHILDREN); Movie movie3 = new Movie("澳门风云", Movie.REGULAR); //两名顾客 Customer customer1 = new Customer("Jack"); Customer customer2 = new Customer("Stary"); //顾客租约 Rental rental1 = new Rental(movie1, 5); Rental rental2 = new Rental(movie2, 3); Rental rental3 = new Rental(movie3, 3); customer1.addRentals(rental1); customer1.addRentals(rental2); customer2.addRentals(rental2); customer2.addRentals(rental3); System.out.println("---------Console 形式展示--------------"); System.out.println(customer1.statement()); System.out.println("-----------------------"); System.out.println(customer2.statement()); System.out.println("----------HTML 形式展示-------------"); System.out.println(customer1.htmlStatement()); System.out.println(customer2.htmlStatement()); } }
修改之后类图如下:
好了,重构到现在可以方便地添加以 HTML 展示了,革命尚未成功,如果修改影片的分类规则该怎么做?添加一个新的分类规则就得修改费用计算方式和积分计算方式了,还需继续努力。