《重构 改善既有代码的设计》之重构,第一个案例详解

1 简介

《重构 改善既有代码的设计》这本书是Martin Fowler等人撰写的一本关于代码重构的十分精彩的书籍,使用Java语言进行阐明主题,是一本非常精彩,甚至要成为优秀程序员不容错过的专业书籍。自己是2019-01-01在浙江图书馆开始看这本书,到2019-01-20在MBA405教读完这本书,感觉收获匪浅,为了防止遗忘,现在开始对这本书进行抽丝拨茧,把在阅读过程中遇到的一些问题进行摘抄出来,即是一种回顾,也是一种记忆的加深。也期待能够加深自己的理解吧。

看完这个书之后,觉得第一个案例写的真的是精彩卓绝,令人赞叹,代码并不复杂,却阐述了在过去一年半工作生活中自己完全没有注意到的事情,很震撼,也很欣慰自己在19年第一个月就读完了这样的一本书。

在江宁麒麟科技园楼下的兰州拉面馆自己开始重读第一章,绝大部分的内容是在汉庭酒店中看完的,仍然叹为观止,现在自己重新梳理一下本章的思路和步骤。

2 起点

实例非常简单。这是一个影片出租店用的程序,计算每一位顾客的消费金额并打印详单。操作者告诉程序:顾客租了那些影片、租期多长,程序根据租赁事件和影片类型算出费用。影片分为三类:普通片、儿童片和新片。除了计算费用,还要为常客计算积分,积分会根据租片种类是否为新片而不同。

2.1 Movie类(影片)

package chapter01.ver01;

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;
   }

}

Movie类只是一个单纯的数据类

2.2 Rental(租赁)

package chapter01.ver01;

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;
   }
}

Rental表示某个顾客租了一部影片

2.3 Customer(顾客)

package chapter01.ver01;

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;
   }
}

Customer类用来表示顾客,与其他类一样,它也提供了访问用户姓名和增加租赁的方法。另外Customer类提供了一个用于生成详单的函数。

2.4 问题

这个程序被Martin Fowler评价设计的不好,而且很不符合面向对象精神。这主要是因为Customer类中的statement()函数做的工作太多了,既要打印foot line,也要统计总的金额,统计常积分,根据影片类型去计算单个影片应该计算的价格。Customer类里这个长长的statement()做的事情实在太多了,它做了很多原本应该由其他类完成的事情。
《重构 改善既有代码的设计》之重构,第一个案例详解_第1张图片
在例子中,我们的用户希望对用户做一点修改,首先它们希望以HTML格式输出详单,这样就可以直接在网页上显示,非常符合潮流。你会发现,根本不可能在打印HTML报表的函数中复用目前statement()的任何代码,你唯一可以做的事情就是重新编写一个htmlStatement()函数,大量重复statement()的行为。这便会造成duplicated code的代码坏味道。

第二个变化:用户希望改变影片分类规则,但是还没有决定怎么改,他们设想了几种方案,这些方案都会影响顾客消费和常客积分点的计算方式。但你要肯定:不论用户提出什么方案,你唯一能够获得的保证就是他们一定会在六个月内再次修改它。

为了应对这种变化,如果我们把statement函数中的代码复制到打印HTML详单的函数中,就必须确保将来的任何修改在两个地方保持一致,而这就是最不好的一种编程行为,因为它增加了未来开发的复杂度和难度,相当于给后来挖坑。

如果你发现自己需要为程序添加一个特性,而代码结构使你无法很方便的达成目的,那就先重构那个程序,使特性的添加比较容易进行,然后再添加特性。

3 重构的第一步

第一个步骤永远相同:我得为即将修改的代码建立一组可靠的测试环境。好的测试是重构的根本,花时间建立一个优良的测试机制是完全值得的,因为当你修改程序时,好测试会给你必要的安全保障。
重构之前,首先检查自己是否有一套可靠的测试机制。这些测试必须要有自我检验能力。
IDEA中整合JUnit4过程详细步骤

4 分解并充足statement()

重构大刀阔斧,第一个目标就是长的离谱的statement函数。
每当看到这样的函数,我就想要把它大卸八块。要知道,代码块越小,代码的功能越容易管理,代码的处理和移动也就越轻松。

4.1 switch部分提炼函数

提炼函数是最常用的重构手法之一。当看到一个过长的函数或者一段需要注释才能理解用途的代码,就要把这段代码放进一个独立的函数。
检查被提炼代码段,看看是否有任何局部变量的值被它改变。

如果一个临时变量值被修改了,看看是否可以将被提炼代码处理为一个查询,并将结果赋值给修改变量。如果很难这样做,或如果被修改的变量不止一个,你就不能仅仅将这段代码原封不动提炼出来。你可能需要先使用 Split Temporary Variable (分解临时变量),然后再尝试提炼。也可以使用 Replace Temp with Query (以查询取代临时变量)将临时变量消灭掉。

关键是在于函数名称和函数本地之间的语义距离所以函数多长不是问题。

重构技术就是以微小的步伐修改程序。如果你犯下错误,很容易便可发现它
重构的本质就是小改动,测试,小改动,测试,由于每次修改的幅度都很小,

所以任何错误都很容易发现。你不必耗费大把时间调试,哪怕你和我一样粗心

注意:任何一个傻瓜都能写出计算机可以理解的代码,唯有写出人类容易理解的代码,才是优秀的程序员。

代码应该表现自己的目的,所以阅读代码的时候应该不断的进行重构,不断的把理解嵌入代码。

4.2 搬移“金额计算”代码

这部分即对应了依恋情节。

class Customerprivate 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()函数内容,让它委托调用新函数即可。

class Customer...
   private double amountDor(Rental aRental) {
      return aRental.getCharge();
   }

4.3 提炼”常客积分计算”代码

class Rentalpublic int getFrequentRenterPoints() {
   if ((getMovie().getPriceCode() == Movie.NEW_RELEASE)
         && getDaysRented() > 1)
      return 2;
   else
      return 1;
}

4.4 去除临时变量

尽量除去这一类临时变量。临时变量往往会引发问题,它们会导致大量参数被传来传去,而其实完全没有这种必要。你很容易跟丢它们,尤其在长长的函数之中更是如此。当然这么做需要付出性能上的代价,例如本例的费用就被计算了两次。
此前的临时变量有两个totalAmount, frequentRenterPoints。使用查询函数query method来取代这两个临时变量。由于totalAmount在循环内部被赋值,不得不把循环复制到查询函数中

class Customer// 译注:此即所谓query method
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循环耗时很多,就可能大大降低程序的性能。单单为了这个原因,很多程序员就不愿进行这个重构动作。但是请注意我的用词,”如果”和“可能”。除非我进行评测,否则我无法确定循环的执行时间,也无法知道这个循环是否被经常使用以至于影响系统的整体性能。重构时你不必担心这些,优化时你才需要担心它们,但那时候你已经处于一个比较有利的位置,有更多的选择可以完成有效优化。

4.5 添加htmlStatement

至此,脱下“重构”的帽子,戴上“添加功能”的帽子,可以如下添加htmlStatement函数

class Customerpublic String htmlStatement() {
   Enumeration rentals = _rentals.elements();
   String result = "

Rentals for " + getName() + "

\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 运用多态取代与价格相关的条件逻辑

在这一步起始地状态如下:

4.6.1 起初

这个问题的第一部分是switch语句。最好不要再另一个对象的属性基础上运用switch语句,因为如果在另一个对象使用switch,如果该类型发生了变化,需要同时修改两种类型。

class Rentalpublic 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;
}

4.6.2 搬移函数

getMovie().getPriceCode()暗示应该把getCharge()函数移到Movie类里去。修改成如下

class Moviepublic 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(),让它调用这个新函数:

class Rentaldouble getCharge() {
   return _movie.getCharge(_daysRented);
}

Move Method(搬移函数)应用场景是你的程序中,有个函数与其所驻类之外的另一个类进行更多交流:调用后者,或被后者调用。

主要思想:在该函数最常引用的类中建立一个有着类似行为的新函数。将旧函数变成一个单纯的委托函数,或是将旧函数完全移除。
搬移函数是重构理论的支柱。如果一个类有太多行为,或如果一个类与另外一个类有太多合作而形成高度耦合就可以使用搬移函数。

在搬移getCharge()之后,以相同手法处理常客积分计算,这样就把根据影片类型而变化的所有东西,都放到了影片类型所属的类中。

5 去除switch

在这一部分,主要是回答了2.4问题中的第二个问题,如何添加新的类型码的问题。

5.1 继承

我们有数种影片类型,它们以不同的方式回答相同的问题。这听起来很像子类的工作,因此我们可以使用如下的结构图,建立三个子类,每个都有自己的计费方法
《重构 改善既有代码的设计》之重构,第一个案例详解_第2张图片
但遗憾的是这里有个小问题,一部影片可以在生命期内修改自己的分类,一个对象却不能在生命周期内修改自己所属的类。此时可以使用State模式。
《重构 改善既有代码的设计》之重构,第一个案例详解_第3张图片
加入这一层间接层,就可以在Price对象内进行子类化动作,于是可以在任何必要时刻修改价格。此时需要三种重构手法

Replace Type code with State/Strategy 将与类型相关的行为banyi 到State模式内
使用Move Method将switch语句移到Price类
最后使用Replace conditional with Polymorphism去掉switch子句

5.2 用State/Strategy取代类型码

针对类型代码使用Self Encapsulate Field,确保任何时候都能通过取值函数和设值函数来访问类型代码。多数访问操作来自其他类,它们已经在使用取值函数,但构造函数仍然直接访问价格代码。

5.2.1 添加Price继承体系

现在建立一个新类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;
   }
}

5.2.2 重构priceCode相关访问函数

修改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");
      }
   }
}

5.3 将getCharge()移动到Price类

对getCharge()实施Move Method,因为getCharge()函数中有switch,因此要推入Price基类中。

class Moviepublic 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;
   }

}

5.4 去掉switch子句

搬移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 Priceabstract double getCharge(int daysRented);

5.5 处理getFrequentRenterPoints

以同样的手法处理getFrequentRenterPoints()
重构前

class Moviepublic int getFrequentRenterPoints(int daysRented) {
//if-else可以理解为switch结构,因此可以同样的手法处理该函数
   if ((getPriceCode() == Movie.NEW_RELEASE) && daysRented > 1)
      return 2;
   else
      return 1;
}

首先把这个函数移到Price类中:

class Moviepublic 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;
   }
}

这么做有值得吗?这么做的收获是:如果我要修改任何与价格相关的行为,或是添加新的定价标准,或是加入其他取决于价格的行为,程序的修改会容易的多。
至此,已经完成了第二个重要的重构行为,从此,修改影片分类结构,或是改变费用计算规则,改变常客积分计算规则,都容易多了。
《重构 改善既有代码的设计》之重构,第一个案例详解_第4张图片

6 结语

在上述的重构过程中,我们可以体会到重构的威力,所有这些行为都使的责任的分配更加合理,代码的维护更加轻松。重构后的程序风格将迥异于过程话风格,一旦你习惯了这种风格,就很难再满足于结构化风格了。
这个例子给Martin Fowler的最大的启发是重构的节奏:

测试、小修改、测试、小修改、测试、小修改

正是这种节奏让重构得以快速而安全的前进。

7 总结

之前一直赞叹Martin Fowler在撰写这本书的用心,尤其是第一章给了我深刻的印象,所以花了两个晚上整理出来这篇博客,一来希望能够梳理完整的思路,二来希望能够更多的人了解到这本《重构 改善既有代码的设计》的好处,确实能够让一个程序员在编写代码时有长足的进步。

8 参考

重构手法01:Extract Method (提炼函数)
《重构 改善既有代码的设计》之重构原则
《重构 改善既有代码的设计》之重构,第一个案例详解
《重构 改善既有代码的设计》之JUnit测试框架以及IDEA与JUnit整合
《重构 改善既有代码的设计》之代码的坏味道
《重构 改善既有代码的设计》之重构列表

9 下载

《重构 改善既有代码的设计》第一个案例详解
《重构》第一章代码和重构.pdf

你可能感兴趣的:(读后感)