一、第一个案例
如果你发现自己需要为程序添加一个特性,而代码结构使你无法很方便地达成目的,那就先重构这个程序。
在重构前,需要先构建好可靠的测试环境,确保安全地重构。
重构需要以微小的步伐修改程序,如果重构过程发生错误,很容易就能发现错误。
案例分析
影片出租店应用程序,需要计算每位顾客的消费金额。
包括三个类: Movie、Rental(租赁) 和 Customer。
- 一个客户能租赁多部电影
最开始的实现是把所有的计费代码都放在 Customer 类中。可以发现,该代码没有使用 Customer 类中的任何信息,更多的是使用 Rental 类的信息,因此第一个可以重构的点就是把具体计费的代码移到 Rental 类中,然后 Customer 类的 getTotalCharge() 方法只需要调用 Rental 类中的计费方法即可。
class Customer { private List<Rental> rentals = new ArrayList<>(); void addRental(Rental rental) { rentals.add(rental); } double getTotalCharge() { double totalCharge = 0.0; for (Rental rental : rentals) { switch (rental.getMovie().getMovieType()) { case Movie.Type1: totalCharge += rental.getDaysRented(); break; case Movie.Type2: totalCharge += rental.getDaysRented() * 2; break; case Movie.Type3: totalCharge += rental.getDaysRented() * 3; break; } } return totalCharge; } } class Rental { private int daysRented; private Movie movie; Rental(int daysRented, Movie movie) { this.daysRented = daysRented; this.movie = movie; } Movie getMovie() { return movie; } int getDaysRented() { return daysRented; } } class Movie { static final int Type1 = 0, Type2 = 1, Type3 = 2; private int type; Movie(int type) { this.type = type; } int getMovieType() { return type; } } public class App { public static void main(String[] args) { Customer customer = new Customer(); Rental rental1 = new Rental(1, new Movie(Movie.Type1)); Rental rental2 = new Rental(2, new Movie(Movie.Type2)); customer.addRental(rental1); customer.addRental(rental2); System.out.println(customer.getTotalCharge()); } }
使用 switch 的准则是: 只使用 switch 所在类的数据。解释如下: switch 使用的数据通常是一组相关的数据,例如 getTotalCharge() 代码使用了 Movie 的多种类别数据。当这组类别的数据发生改变时,例如增加 Movie 的类别或者修改一种 Movie 类别的计费方法,就需要修改 switch 代码。如果违反了准则,就会有多个地方的 switch 使用了这部分的数据,那么这些 swtich 都需要进行修改,这些代码可能遍布在各个地方,修改工作往往会很难进行。上面的实现违反了这一准则,因此需要重构。
以下是继承 Movie 的多态解决方案,这种方案可以解决上述的 switch 问题,因为每种电影类别的计费方式都被放到了对应 Movie 子类中,当变化发生时,只需要去修改对应子类中的代码即可。
有一条设计原则指示应该多用组合少用继承,这是因为组合比继承具有更高的灵活性。
- 可以参考这篇文章:JAVA设计模式第一讲:设计原则
- 第3节
- 例如上面的继承方案,一部电影要改变它的计费方式,就要改变它所属的类,但是对象所属的类在编译时期就确定了,无法在运行过程中动态改变。(运行时多态可以在运行过程中改变一个父类引用指向的子类对象,但是无法改变一个对象所属的类。)
策略模式就是使用组合替代继承的一种解决方案。引入 Price 类,它有多种实现。Movie 组合了一个 Price 对象,并且在运行时可以改变组合的 Price 对象,从而使得它的计费方式发生改变。
- 策略模式可以参考这篇文章:JAVA设计模式第四讲:行为型设计模式
- 第10.3节
重构后整体的类图和时序图如下:
重构后的代码:
class Customer { private List<Rental> rentals = new ArrayList<>(); void addRental(Rental rental) { rentals.add(rental); } double getTotalCharge() { double totalCharge = 0.0; for (Rental rental : rentals) { totalCharge += rental.getCharge(); } return totalCharge; } } class Rental { private int daysRented; private Movie movie; Rental(int daysRented, Movie movie) { this.daysRented = daysRented; this.movie = movie; } double getCharge() { return daysRented * movie.getCharge(); } } interface Price { double getCharge(); } class Price1 implements Price { @Override public double getCharge() { return 1; } } class Price2 implements Price { @Override public double getCharge() { return 2; } } class Price3 implements Price { @Override public double getCharge() { return 3; } } class Movie { private Price price; Movie(Price price) { this.price = price; } double getCharge() { return price.getCharge(); } } class App { public static void main(String[] args) { Customer customer = new Customer(); Rental rental1 = new Rental(1, new Movie(new Price1())); Rental rental2 = new Rental(2, new Movie(new Price2())); customer.addRental(rental1); customer.addRental(rental2); System.out.println(customer.getTotalCharge()); } }
二、重构原则
整体内容如下:
要点列表:
2.1、定义
重构是对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
2.2、为何重构
- 改进软件设计
- 使软件更容易理解
- 帮助找到 Bug
- 提高编程速度
2.3、三次法则
第一次做某件事时只管去做;第二次做类似事情时可以去做;第三次再做类似的事,就应该重构。
2.4、间接层与重构
计算机科学中的很多问题可以通过增加一个间接层来解决,间接层具有以下价值:
- 允许逻辑共享
- 分开解释意图和实现
- 隔离变化
- 封装条件逻辑
重构可以理解为在适当的位置插入间接层以及在不需要时移除间接层。
2.5、修改接口
如果重构手法改变了已发布的接口,就必须维护新旧两个接口。可以保留旧接口,让旧接口去调用新接口,并且使用 Java 提供的 @Deprecated 将旧接口标记为弃用。
可见修改接口特别麻烦,因此除非真有必要,否则不要发布接口,并且不要过早发布接口。
2.6、何时不该重构
当现有代码过于混乱时,应当重写而不是重构。
一个折中的办法是,将代码封装成一个个组件,然后对各个组件做重写或者重构的决定。
2.7、重构与设计
软件开发无法预先设计,因为开发过程有很多变化发生,在最开始不可能都把所有情况考虑进去。
重构可以简化设计,重构在一个简单的设计上进行修修改改,当变化发生时,以一种灵活的方式去应对变化,进而带来更好的设计。
2.8、重构与性能
为了软代码更容易理解,重构可能会导致性能减低。
在编写代码时,不用对性能过多关注,只有在最后性能优化阶段再考虑性能问题。
应当只关注关键代码的性能,并且只有一小部分的代码是关键代码。
三、代码的坏味道
本章主要介绍一些不好的代码,也就是说这些代码应该被重构。
1、重复代码 Duplicated Code
同一个类的两个函数有相同表达式,则用 Extract Method 提取出重复代码;
两个互为兄弟的子类含有相同的表达式,先使用 Extract Method,然后把提取出来的函数 Pull Up Method 推入父类。
如果只是部分相同,用 Extract Method 分离出相似部分和差异部分,然后使用 Form Template Method 这种模板方法设计模式。
- 模版设计模式可以参考这篇文章:JAVA设计模式第四讲:行为型设计模式
- 第10.2节
如果两个毫不相关的类出现重复代码,则使用 Extract Class 方法将重复代码提取到一个独立类中。
2、过长函数 Long Method
函数应该尽可能小,因为小函数具有解释能力、共享能力、选择能力。
分解长函数的原则:当需要用注释来说明一段代码时,就需要把这部分代码写入一个独立的函数中。
Extract Method 会把很多参数和临时变量都当做参数,可以用 Replace Temp with Query 消除临时变量,Introduce Parameter Object 和 Preserve Whole Object 可以将过长的参数列变得更简洁。
条件和循环语句往往也需要提取到新的函数中。
3、过大的类 Large Class
应该尽可能让一个类只做一件事,而过大的类做了过多事情,需要使用 Extract Class 或 Extract Subclass。
先确定客户端如何使用该类,然后运用 Extract Interface 为每一种使用方式提取出一个接口。
4、过长的参数列表 Long Parameter List
太长的参数列表往往会造成前后不一致,不易使用。
面向对象程序中,函数所需要的数据通常能在宿主类中找到。
5、发散式变化 Divergent Change
设计原则: 一个类应该只有一个引起改变的原因。也就是说,针对某一外界变化所有相应的修改,都只应该发生在单一类中。
针对某种原因的变化,使用 Extract Class 将它提炼到一个类中。
6、散弹式修改 Shotgun Surgery
一个变化引起多个类修改。
使用 Move Method 和 Move Field 把所有需要修改的代码放到同一个类中。
7、依恋情结 Feature Envy
一个函数对某个类的兴趣高于对自己所处类的兴趣,通常是过多访问其它类的数据,使用 Move Method 将它移到该去的地方,如果对多个类都有 Feature Envy,先用 Extract Method 提取出多个函数。
8、数据泥团 Data Clumps
有些数据经常一起出现,比如两个类具有相同的字段、许多函数有相同的参数,这些绑定在一起出现的数据应该拥有属于它们自己的对象。
使用 Extract Class 将它们放在一起。
9、基本类型偏执 Primitive Obsession
使用类往往比使用基本类型更好,使用 Replace Data Value with Object 将数据值替换为对象。
10、switch 惊悚现身 Switch Statements
具体参见第一章的案例。
11、平行继承体系 Parallel Inheritance Hierarchies
每当为某个类增加一个子类,必须也为另一个类相应增加一个子类。
这种结果会带来一些重复性,消除重复性的一般策略:让一个继承体系的实例引用另一个继承体系的实例。
12、冗余类 Lazy Class
如果一个类没有做足够多的工作,就应该消失。
13、夸夸其谈未来性 Speculative Generality
有些内容是用来处理未来可能发生的变化,但是往往会造成系统难以理解和维护,并且预测未来可能发生的改变很可能和最开始的设想相反。因此,如果不是必要,就不要这么做。
14、令人迷惑的暂时字段 Temporary Field
某个字段仅为某种特定情况而设,这样的代码不易理解,因为通常认为对象在所有时候都需要它的所有字段。把这种字段和特定情况的处理操作使用 Extract Class 提炼到一个独立类中。
15、过度耦合的消息链 Message Chains
一个对象请求另一个对象,然后再向后者请求另一个对象,然后…,这就是消息链。采用这种方式,意味着客户代码将与对象间的关系紧密耦合。
改用函数链,用函数委托另一个对象来处理。