无论做什么事情呢,都要善始善终呢。前边连续发表了5篇关于重构的博客,其中分门别类的介绍了一些重构手法。今天的这篇博客就使用一个完整的示例来总结一下之前的重构规则,也算给之前的关于重构的博客画一个句号。今天的示例借鉴于《重构,改善既有代码的设计》这本书中的第一章的示例,在其基础上做了一些修改。今天博客从头到尾就是一个完整的重构过程。首先会给出需要重构的代码,然后对其进行分析,然后对症下药,使用之前我们分享的重构规则对其进行一步步的重构。
先来聊一下该示例的使用场景(如果你有重构这本书的话,可以参加第一章中的示例,不过本博客中的示例与其有些出入)。就是一个客户去DVD出租的商店里进行消费,下方的程序是给店主用的,来根据用户所借的不同的DVD种类和数量来计算该用户消费的金额和积分。需求很简单而且也不难理解。今天博客会给出原始的代码,也是需要进行重构的代码。当然原始代码完全符合需求,并且可以正确执行。废话少说,先看示例吧。
一、需要重构的代码
在本篇博客的第一部分,我们先给出完成上述需求需要重构的代码。然后在此基础上进行分析,使用之前我们提到过的重构手法进行重构。首先我们给出了电影类的实现。在Movie类中有电影的种类(静态常量):普通电影、儿童电影、新电影,然后有两个成员变量/常量是priceCode(价格代码)、title(电影名称),最后就是我们的构造方法了。该Movie类比较简单,在此就不做过多的赘述了。
实现完Movie类接下来就是租赁类Rental,这个Rental类的职责就是负责统计某个电影租赁的时间。下方就是这个租赁类,该类也是比较简单的,其中有两个字段,一个是租了的电影,另一个就是租赁的时间了。
接下来要实现我们的消费者类了,也就是Customer类。在Customer类中有消费者的名字name和一个数组,该数组中寸的就是租赁电影的集合。其中的statement()方法就是结算该客户的结算信息的方法,并将结果进行打印。在此我们需要了解的需求是每种电影的计价方式以及积分的计算规则。
电影价格计算规则:
普通片儿--2天之内含2天,每部收费2元,超过2天的部分每天收费1.5元
新片儿--每天每部3元
儿童片--3天之内含3天,每部收费1.5元,超过3天的部分每天收费1.5元
积分计算规则:
每借一步电影积分加1,新片每部加2
statement()函数中所做的事情就是根据上面的计算规则,根据用户所租赁的电影的不同来进行金额的计算和积分的计算的。
如果你看代码不太直观的话,下面我使用了startUML简单的画了一个UML的类图来说明上述三个类中的依赖关系。具体如下所示:
在对上面代码重构之前呢,我们还必须有上述代码的测试用例。因为在每次重构之前,我们修改的是代码的内部结构,而代码模块对外的调用方式不会变的。所以我们所创建的测试用例可以帮助验证我们重构后的程序是否可以正常的工作,是否重构后还符合我们的需求。下方就是我们创建的测试用例(当然,在iOS开发中你可以使用其他的测试框架来进行单元测试,重构时,单元测试是少不了的)。在本篇博客中重构后的代码仍然使用下方的测试用例。
//测试用例--------------------------------------------------------------------
//创建用户
let customer = Customer(name: "ZeluLi")
//创建电影
let regularMovie:Movie = Movie(title: "《老炮儿》", priceCode: Movie.REGULAR)
let newMovie:Movie = Movie(title: "《福尔摩斯》", priceCode: Movie.NEW_RELEASE)
let childrenMovie:Movie = Movie(title: "《葫芦娃》", priceCode: Movie.CHILDRENS)
//创建租赁数据
let rental1:Rental = Rental(movie: regularMovie, daysRented: 5)
let rental2:Rental = Rental(movie: newMovie, daysRented: 8)
let rental3:Rental = Rental(movie: childrenMovie, daysRented: 2)
customer.rentals.append(rental1)
customer.rentals.append(rental2)
customer.rentals.append(rental3)
let result = customer.statement()
print(result)
针对上述案例,上面测试用例的输出结果如下。在每次重构后,我们都会执行上述测试代码,然后观察结果是否与之前的相同。当然如果你的是单元测试的话,完全可以把对结果检查的工作交给单元测试中的断言来做。
二、重构1:对较statement函数进行拆分
1.对statement()函数使用“Extract Method”原则
在上面的案例中,最不能容忍的,也就是最需要重构的首先就是Customer中的statement()函数。statement()函数最大缺点就是函数里边做的东西太多,我们第一步需要做的就是对其进行拆分。也就是使用我们之前提到过的“Extract Method”(提炼函数)原则对该函数进行简化和拆分。将statement()中可以独立出来的模块进行提取。经过分析后的,我们不难发现下方红框当中的代码是一个完整的模块,一个是进行单价计算的,一个是进行积分计算的,我们可以将这两块代码进行提取并封装成一个新的方法。在封装新方法时,要给这个新的方法名一个恰当的函数名,见名知意。
下方这块代码就是我们对上面这两个红框中的代码的提取。在提取时,将依赖于statement()函数中的数据作为新函数的参数即可。封装后的方法如下,在statement函数中相应的地方调用下方的方法即可。下方就是我们封装的计算当前电影金额和计算积分的函数。这两个函数都需要传入一个Rental的对象。
//根据租赁订单,计算当前电影的金额
func amountFor(aRental: Rental) -> Double {
var result:Double = 0 //单价变量
switch aRental.movie.priceCode {
case Movie.REGULAR:
result += 2
if aRental.daysRented > 2 {
result += Double(aRental.daysRented - 2) * 1.5
}
case Movie.NEW_RELEASE:
result += Double(aRental.daysRented * 3)
case Movie.CHILDRENS:
result += 1.5
if aRental.daysRented > 3 {
result += Double(aRental.daysRented - 3) * 1.5
}
default:
break
}
return result
}
//计算当前电影的积分
func getFrequentRenterPoints(rental: Rental) -> Int {
var frequentRenterPoints: Int = 0 //用户积分
frequentRenterPoints++
if rental.movie.priceCode == Movie.NEW_RELEASE &&
rental.daysRented > 1{
frequentRenterPoints++
}
return frequentRenterPoints
}
经过上面的重构步骤,我们会运行一下测试用例或者执行一下单元测试,看是否我们的重构过程引起了新的bug。
三、重构2:将相应的方法移到相应的类中
经过上面的重构,我们从statement()函数中提取了两个方法。观察这两个重构后的方法我们不难看出,这两个封装出来的新的方法都需要一个参数,这个参数就是Rental类的对象。也就是这两个方法都依赖于Rental类,而对该函数所在的当前类不太感冒。出现这种情况的原因就是这两个函数放错了地方,因为这两个函数放在Customer类中不依赖与Customer类而依赖于Rental类,那就足以说明这两个方法应该放在Rental类中。
经过我们简单的分析后,我们就可以决定要将我们新提取的方法放到Rental类中,并且函数的参数去掉。因为函数在Rental类中,所以在函数中直接使用self即可。将计算金额的方法和计算积分的方法移到Rental类中后,我们的Rental类如下所示。在我们的Customer中的statement()方法中在计算金额和计算积分时,直接调用Rental中的方法即可。经过这一步重构后,不要忘记执行一下你的测试用例,监测一下重构的结果是否正确。
四、使用“以查询取代临时变量”再次对statement()函数进行重构
经过第二步和第三步的重构后,Customer中的statement()函数如下所示。在计算每部电影的金额和积分时,我们调用的是Rental类的对象的相应的方法。下方的方法与我们第一部分的方法相比可谓是简洁了许多,而且易于理解与维护。
不过上面的代码仍然有重构的空间,举个例子,如果我们要将结果以HTML的形式进行组织的话,我们需要将上面的代码进行复制,然后修改result变量的文本组织方式即可。但是这样的话,其中的好多临时变量也需要被复制一份,这是完全相同的,这样就容易产生重复的代码。在这种情况下,我们需要使用“Replace Temp with Query”(已查询取代临时变量)的重构手法来取出上面红框中的临时变量。
上面红框中的每个临时变量我们都会提取出一个查询方法,下方是使用“Replace Temp with Query”(已查询取代临时变量)规则重构后的statement()函数,以及提取的两个查询函数。
经过上面这些步骤的重构,我们的测试用例依然不变。在每次重构后我们都需要调用上述的测试用例来检查重构是否产生了副作用。现在我们的类间的依赖关系没怎么发生变化,只是相应类中的方法有些变化。下方是现在代码所对应的类图,因为在上述重构的过程中我们主要做的是对函数的重构,也就是对函数进行提取,然后将提取的函数放到相应的类中,从下方的简化的类图中就可以看出来了。
五. 继续将相应的函数进行移动(Move Method)
对重构后的代码进行观察与分析,我们任然发现在Rental类中的getCharge()函数中的内容与getFrequentRenterPoints()函数中的内容对Movie类的依赖度更大。因为这两个函数都只用到了Rental类中的daysRented属性,而多次用到了Movie中的内容。因此我们需要将这两个函数中的内容移到Movie类中更为合适。所以我继续讲该部分内容进行移动。
移动的方法是保留Rental中这两个函数的声明,在Movie中创建相应的函数,将函数的内容移到Movie中后,再Rental中调用Movie中的方法。下方是我们经过这次重构后我们Movie类中的内容。其中红框中的内容是我们移过来的内容,而绿框中的参数需要从外界传入。
将相应的方法体移动Movie类中后,在Rental中我们需要对其进行调用。在调用相应的方法时传入相应的参数即可。下方就是经过这次中国Rental类的代码,绿框中的代码就是对Movie中新添加的方法的调用。
经过上面的重构,我们的方法似乎是找到了归宿了。重构就是这样,一步步来,不要着急,没动一步总是要向着好的方向发展。如果你从第一部分中的代码重构到第五部分,似乎有些困难。经过上面这些间接的过程,感觉也是挺愉快的蛮。下方是经过我们这次重构的类图。
六、使用“多态”取代条件表达式
在我们之前的博客中对条件表达式进行重构时,提到了使用类的多态对条件表达式进行重构。接下来我们就要使用该规则对Movie类中的getCharge()与getFrequentRenterPoints()函数进行重构。也就是使用我们设计模式中经常使用的“状态模式”。在该部分我们不需要对Rental类和Customer类进行修改,只对Movie类修改,并且引入相应的接口和继承关系。
我们对Movie类中的getCharge()方法中的Switch-Case结构观察时,我们很容易发现,此处完全可以使用类的多态来替代(具体请参见《代码重构(四):条件表达式重构规则(Swift版)》)。具体实现方式是将不通的价格计算方式提取到我们新创建的价格类中,每种电影都有自己价格类,而这些价格类都实现同一个接口,这样一来在Movie中就可以使用多态来获取价格了。积分的计算也是一样的。下方是我们要实现结构的类图。下方红框中是在原来基础上添加的新的接口和类,将条件表达式所处理的业务逻辑放在了我们新添加的类中。这样我们就可以使用类的多态了,而且遵循了“单一职责”。
下方代码就是上面大的红框中所对应的代码实现。Price是我们定义好的协议,在协议中规定了遵循该协议的类要实现的方法。而在每个具体实现类中实现了相同的接口,但是不同的类中相同的方法做的事情不同。在不同的类中的getCharge()中要做的事情就是Switch-Case语句中所处理的数据。
添加上上面的结构以后,在么我们的Movie中就可以使用多态了,在Movie中添加了一个Price声明的对象,我们会根据不同的priceCode来给price变量分配不同的对象。而在getCharge()中只管调用price的getCharge()函数即可,具体做法如下。
今天的博客到这儿也就差不多了,其实上面的代码仍然有重构的空间,如果我们想把Switch-Case这个结构去掉的话,我们可以在上面代码的基础上创建多个工厂方法即可。在此就不过赘述了。
如果看完今天的博客的内容不够直观的话,那么请放心。本篇博客中每次重构过程的完整实例会在github上进行分享。对每次重构的代码都进行了系统的整理。今天博客中的代码整理的结果如下。