1. 前言
重构,可以理解为一种帮助你改进已有代码设计的一种方法。若直接对这种方法下一个定义,那么很容易陷入形式,读完后还是不理解重构是啥。因为它是在你不断设计,不断改进过程中归纳出来的一些比较通用的手法,这些手法会因你遇到的场景及上下文的不同而发生变化,而代码设计过程中的场景是千变万化,异常复杂的。所以,以一个简单案例入手,帮助我们熟悉一般简单场景重构的手法,然后一步步组合变化,去满足我们对大型系统重构的需求是一个很好的思路。
2. 初始需求的描述
该程序用于影片出租店,出租店目前有三种类型的影片出租:普通片、儿童片、新片。需要记录顾客租了哪些影片,租期,并计算租金,统计顾客积分。
2.1 第一版设计
设计三个类:Movie, Rental, Consumer,其中顾客可以查看自己的租金以及积分,在Consumer类增加一个statement接口,用于打印该顾客的租用详单,其设计类图如下:
图一 第一版设计类图
设计代码如下:
#include <iostream>
using namespace std;
enum MovieType {
CHILDRENS, //儿童片
REGULAR, // 普通片
NEW_RELEASE, //新片
}
class Movie
{
public:
MovieType GetMovieType() {
return type;
}
string GetMovieTitle() {
return name;
}
private:
string name;
double price;
MovieType type;
};
class Rental
{
public:
Movie GetMovie() {
return mv;
}
int GetRentDays() {
return days;
}
private:
int days;
Movie mv;
};
class Consumer
{
public:
string statement();
string GetName() {
return name;
}
private:
string name;
vector<Rental> rentals;
};
// 打印详单,包含计算总价格及总积分
string Consumer::statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
Enumeration rentals = _rentals.elements();
string result = "Rental Record for " + GetName() + "\n";
int i = 0;
while (i < rentals.size()) {
double thisAmount = 0;
//determine amounts for each line
switch (rentals[i].GetMovie().GetMovieType()) {
case REGULAR:
{
thisAmount += 2;
if (rentals[i].GetRentDays() > 2) {
thisAmount += (rentals[i].GetRentDays()- 2)* 1.5;
}
}
break;
case NEW_RELEASE:
{
thisAmount += rentals[i].GetRentDays()* 3;
}
break;
case CHILDRENS:
{
thisAmount += 1.5;
if (rentals[i].GetRentDays() > 3) {
thisAmount += (rentals[i].GetRentDays()- 3)* 1.5;
}
}
break;
default:
break;
}
// add frequent renter points
frequentRenterPoints ++;
// add bonus for a two day new release rental
if ((rentals[i].GetMovie().GetMovieType()== Movie.NEW_RELEASE) &&
rentals[i].GetRentDays() > 1) {
frequentRenterPoints ++;
}
//show figures for this rental
result += "\t" + rentals[i].GetMovie().GetMovieTitle()+ "\t" +
to_string(thisAmount)+ "\n";
totalAmount += thisAmount;
i++;
}
//add footer lines
result += "Amount owed is " + to_string(totalAmount)+ "\n";
result += "You earned " + to_string(frequentRenterPoints)+
" frequent renter points"
return result;
}
int main() {
cout << "test\n";
return 0;
}
分析以上代码,发现可以实现计算租金及积分,并打印出详单的基本功能,但明显存在很多不足:
- statement函数太长,里面做的工作太多
- 若需要以HTML形式打印表单,那么statement接口无法复用
- 可以复制代码重新增加htmlStatement接口,但若计费标准发生变化,需要同时修改两个接口
- 若影片种类增加或计费规则改变,那么需要不断修改两个接口,无法确保两处修改一致
- 难以满足不断变化的客户需求
2.2 第二版设计
首先,针对statement函数过长,功能点集中的缺点进行改善;
第一步: 识别statement函数中较小的代码块,将其提取成一个函数分离出来。经过观察发现,可以将计算每一次租赁的价格的代码块提取出来,不变的租赁实例作为入参,唯一可变的本次租赁价格作为返回值,提取如下:
double Consumer::CalOneRentalPrice(Rental each)
{
double thisAmount = 0;
//determine amounts for each line
switch (each.GetMovie().GetMovieType()) {
case REGULAR:
{
thisAmount += 2;
if (each.GetRentDays() > 2) {
thisAmount += (each.GetRentDays()- 2)* 1.5;
}
}
break;
case NEW_RELEASE:
{
thisAmount += each.GetRentDays()* 3;
}
break;
case CHILDRENS:
{
thisAmount += 1.5;
if (each.GetRentDays() > 3) {
thisAmount += (each.GetRentDays()- 3)* 1.5;
}
}
break;
default:
break;
}
return thisAmount;
}
// 打印详单,包含计算总价格及总积分
string Consumer::statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
Enumeration rentals = _rentals.elements();
string result = "Rental Record for " + GetName() + "\n";
int i = 0;
while (i < rentals.size()) {
double thisAmount = CalOneRentalPrice(rentals[i]);
// add frequent renter points
frequentRenterPoints ++;
// add bonus for a two day new release rental
if ((rentals[i].GetMovie().GetMovieType()== Movie.NEW_RELEASE) &&
rentals[i].GetRentDays() > 1) {
frequentRenterPoints ++;
}
//show figures for this rental
result += "\t" + rentals[i].GetMovie().GetMovieTitle()+ "\t" +
to_string(thisAmount)+ "\n";
totalAmount += thisAmount;
i++;
}
//add footer lines
result += "Amount owed is " + to_string(totalAmount)+ "\n";
result += "You earned " + to_string(frequentRenterPoints)+
" frequent renter points"
return result;
}
int main() {
cout << "test\n";
return 0;
}
注意: 重构完务必利用写好的单元测试进行测试,所有测试用例通过才能进行下一步改善
第二步: 检查提取出来的功能块放在此类中是否最佳。经观察发现CalOneRentalPrice接口只用到了Rental类的信息,并没有用到Consumer的信息,将其放在Rental类中更合适。因此,可以去掉函数入参,更改函数名称,将程序中所有引用旧函数的地方全部更改为新函数。若不想修改其他调用的地方,可保留旧函数。
同时, 临时变量会导致大量参数被传来传去,容易跟丢 ,因此可以将statement中的临时变量thisAmount 直接去掉,用rentals[i].GetCharge()替换,虽然很可能会造成多次调用,带来性能或内存空间的损耗,但这是可以继续优化的。
重构后,代码如下:
double Rental::GetCharge()
{
double thisAmount = 0;
//determine amounts for each line
switch (mv.GetMovieType()) {
case REGULAR:
{
thisAmount += 2;
if (days > 2) {
thisAmount += (days - 2)* 1.5;
}
}
break;
case NEW_RELEASE:
{
thisAmount += days * 3;
}
break;
case CHILDRENS:
{
thisAmount += 1.5;
if (days > 3) {
thisAmount += (days - 3)* 1.5;
}
}
break;
default:
break;
}
return thisAmount;
}
// 打印详单,包含计算总价格及总积分
string Consumer::statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
Enumeration rentals = _rentals.elements();
string result = "Rental Record for " + GetName() + "\n";
int i = 0;
while (i < rentals.size()) {
// add frequent renter points
frequentRenterPoints ++;
// add bonus for a two day new release rental
if ((rentals[i].GetMovie().GetMovieType()== Movie.NEW_RELEASE) &&
rentals[i].GetRentDays() > 1) {
frequentRenterPoints ++;
}
//show figures for this rental
result += "\t" + rentals[i].GetMovie().GetMovieTitle()+ "\t" +
to_string(rentals[i].GetCharge())+ "\n";
totalAmount += rentals[i].GetCharge();
i++;
}
//add footer lines
result += "Amount owed is " + to_string(totalAmount)+ "\n";
result += "You earned " + to_string(frequentRenterPoints)+
" frequent renter points"
return result;
}
第三步: 进一步观察statement函数,发现里面还保留着积分计算的逻辑,这一块代码中 包含临时变量frequentRenterPoints和rentals[i],若提取这一块逻辑,那么rentals[i]可作为入参,frequentRenterPoints可作为返回值,重构如下:
// 打印详单,包含计算总价格及总积分
string Consumer::statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
Enumeration rentals = _rentals.elements();
string result = "Rental Record for " + GetName() + "\n";
int i = 0;
while (i < rentals.size()) {
// add frequent renter points
frequentRenterPoints += GetFrePointsForEachRental(rentals[i]);
//show figures for this rental
result += "\t" + rentals[i].GetMovie().GetMovieTitle()+ "\t" +
to_string(rentals[i].GetCharge())+ "\n";
totalAmount += rentals[i].GetCharge();
i++;
}
//add footer lines
result += "Amount owed is " + to_string(totalAmount)+ "\n";
result += "You earned " + to_string(frequentRenterPoints)+
" frequent renter points"
return result;
}
int Consumer::GetFrePointsForEachRental(const Rantal& rent)
{
if ((rent.GetMovie().GetMovieType()== Movie.NEW_RELEASE) &&
rent.GetRentDays() > 1) {
return 2;
}
else {
return 1;
}
}
第四步: 移动GetFrePointsForEachRental函数块只合适的类中,发现GetFrePointsForEachRental中只与Rental类有关,与Consumer类无关,因此可将GetFrePointsForEachRental移至Rental类中,重构代码如下:
int Rental::GetFrePoints()
{
if ((mv.GetMovieType()== Movie.NEW_RELEASE) &&
days > 1) {
return 2;
}
else {
return 1;
}
}
// 打印详单,包含计算总价格及总积分
string Consumer::statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
Enumeration rentals = _rentals.elements();
string result = "Rental Record for " + GetName() + "\n";
int i = 0;
while (i < rentals.size()) {
// add frequent renter points
frequentRenterPoints += rentals[i].GetFrePoints();
//show figures for this rental
result += "\t" + rentals[i].GetMovie().GetMovieTitle()+ "\t" +
to_string(rentals[i].GetCharge())+ "\n";
totalAmount += rentals[i].GetCharge();
i++;
}
//add footer lines
result += "Amount owed is " + to_string(totalAmount)+ "\n";
result += "You earned " + to_string(frequentRenterPoints)+
" frequent renter points"
return result;
}
以上修改主要使用了Extract Method, Move Method 和 Replace Temp With Query方法,经过以上修改,其类图结构发生变化,如下:
图二: 第二版设计类图
图三 第二版设计序列图
2.3 第三版设计
目前,已经分别将租金计算和积分计算的功能模块从statement函数中提取出来了,继续看statenment函数,已经简单了很多,但是否还有改善的空间呢?答案当然是有。
针对2.1中提出的缺陷2,若需要增减HTML格式的详单打印,那么在新增htmlStatement()接口时,是否有更多基本模块可以与statement共用呢?经过观察,发现statement中还存在两个临时变量totalAmount 和frequentRenterPoints ,这两个变量都是要根据Consumer中的Renteals对象获取的总量,statement需要获取,htmlStatement也需要获取,因此可进一步将这两个变量的计算逻辑也提取出来,具体如下:
double Consumer::GetTotalAmount()
{
double result = 0;
for(int i = 0; i < rentals.size(); i++){
result += rentals[i].GetCharge();
}
return result;
}
int Consumer::GetTotalFrePoints()
{
int points = 0;
for(int i = 0; i < rentals.size(); i++){
points += rentals[i].GetFrePoints();
}
return points ;
}
// 打印详单,包含计算总价格及总积分
string Consumer::statement() {
string result = "Rental Record for " + GetName() + "\n";
int i = 0;
while (i < rentals.size()) {
//show figures for this rental
result += "\t" + rentals[i].GetMovie().GetMovieTitle()+ "\t" +
to_string(rentals[i].GetCharge())+ "\n";
i++;
}
//add footer lines
result += "Amount owed is " + to_string(GetTotalAmount())+ "\n";
result += "You earned " + to_string(GetTotalFrePoints())+
" frequent renter points"
return result;
}
string Consumer::htmlStatement(){
string result = "<H1>Rentals for <EM>" + getName()+ "</EM></H1><P>\n";
int i = 0;
while (i < rentals.size()){
// show figures for each rental
result += rentals[i].GetMovie().GetMovieTitle()+ ": "+
to_string(rentals[i].GetCharge()+ "<BR>\n";
}
// add footer lines
result += "<P>You owe <EM>" + String.valueOf(GetTotalAmount())+
"</EM><P>\n";
result += "On this rental you earned <EM>"+
String.valueOf(GetTotalFrePoints())
+ "</EM> frequent renter points<P>";
return result;
}
分析: 可以看到这次重构后,代码函数反而增多了,而且statement中由一次循环变成了三次循环。但要注意,循环虽然增多了,但只有在n规模较大时,性能才会收到较大影响,重构完成后再去根据实际情况优化性能,或许更有利。
目前来看,statement函数已经非常简洁了,这时,如果想增加一个statementHtml接口,工作量会大大降低,且如果计算积分和计算租金的规则发生变化,改动量也非常有限。
重构后的类图及序列图
图四 第三版设计类图
图五 第三版设计序列图
2.4 第四版设计
第一步: 下面可以继续观察Rental类中的GetCharge函数,里面包含一个switch语句,而switch的条件是基于另一个对象Movie的属性,通常建议不要在另一个对象属性的基础上使用switch,若必须使用,可以在对象属性所在的类中去使用,那么可以将switch中的逻辑搬到Movie类中,同样的,GetFrePoints函数也有基于Movie的属性,为了保持与GetCharge一致的风格,也可以搬移至Movie类中,重构如下:
double Movie::GetCharge(const int& days)
{
double thisAmount = 0;
switch (type) {
case REGULAR:
{
thisAmount += 2;
if (days > 2) {
thisAmount += (days - 2)* 1.5;
}
}
break;
case NEW_RELEASE:
{
thisAmount += days * 3;
}
break;
case CHILDRENS:
{
thisAmount += 1.5;
if (days > 3) {
thisAmount += (days - 3)* 1.5;
}
}
break;
default:
break;
}
return thisAmount ;
}
double Rental::GetCharge()
{
return mv.GetCharge(days);
}
int Movie::GetFrePoints(const in& days)
{
if ((type== Movie.NEW_RELEASE) &&
days > 1) {
return 2;
}
else {
return 1;
}
}
int Rental::GetFrePoints()
{
return mv.GetFrePoints(days);
}
第二步: 现在着眼于Movie类,它包含多种不同的影片类型,每种类型的影片都有不同的计价方式,看起来可以用多态来替换switch,集成类图如下:
图六 Movie继承类图
重构代码如下
class Movie
{
public:
MovieType GetMovieType() {
return type;
}
string GetMovieTitle() {
return name;
}
virtual double GetCharge(const int& days);
int GetFrePoints(const int& days);
protected:
string name;
double price;
MovieType type;
};
class RegularMovie : public Movie
{
public:
double GetCharge(const int& days) {
double thisAmount = 0;
thisAmount += 2;
if (days > 2) {
thisAmount += (days - 2)* 1.5;
}
return thisAmount ;
}
}
class ChildrMovie : public Movie
{
public:
double GetCharge(const int& days) {
double thisAmount = 0;
thisAmount += 1.5;
if (days > 3) {
thisAmount += (days - 3)* 1.5;
}
return thisAmount ;
}
}
class NewMovie : public Movie
{
public:
double GetCharge(const int& days) {
double thisAmount = 0;
thisAmount += days * 3;
return thisAmount ;
}
}
double Movie::GetCharge(const int& days)
{
return 0 ;
}
这一步的重构有个小问题,就是在其他类中构造movie对象时,需要根据影片类型构建子类对象,这样会导致在其它类中增加大量代码逻辑,而目的仅仅是为了区分不同类型影片,其定价不一致。其实可以仅仅在movie类中针对price字段进行分类继承,而不影响其他属性。
第三步: 这里可以使用state模式或Strategy模式来解决问题,可以理解为将每一种类型的影片当做一种状态或策略来处理
class Price
{
public:
virtual double GetCharge() = 0;
}
class ChildPrice : public Price
{
public:
double GetCharge(const int& days) {
double thisAmount = 0;
thisAmount += 1.5;
if (days > 3) {
thisAmount += (days - 3)* 1.5;
}
return thisAmount ;
}
}
class RegularPrice : public Price
{
public:
double GetCharge(const int& days) {
double thisAmount = 0;
thisAmount += 2;
if (days > 2) {
thisAmount += (days - 2)* 1.5;
}
return thisAmount ;
}
}
class NewPrice : public Price
{
public:
double GetCharge(const int& days) {
double thisAmount = 0;
thisAmount += days * 3;
return thisAmount ;
}
}
class Movie
{
public:
Movie(MovieType type_, double sPrice)
{
type = type_;
SetPrice(sPrice);
}
MovieType GetMovieType() {
return type;
}
void SetPrice(double sPrice)
{
switch (type) {
case REGULAR:
{
price = new RegularPrice();
}
break;
case NEW_RELEASE:
{
price = new NewPrice();
}
break;
case CHILDRENS:
{
price = new ChildPrice();
}
break;
default:
{
price = nullptr;
}
break;
}
}
string GetMovieTitle() {
return name;
}
virtual double GetCharge(const int& days);
int GetFrePoints(const int& days);
protected:
string name;
Price* price;
MovieType type;
};
double Movie::GetCharge(const int& days)
{
return price->GetCharge(days) ;
}
分析
以上重构可以认为是针对每一种类型的影片,都可以在Price子类中处理计价方式,即每一种影片对应一种计价策略,这样做的好处是,后期需要更改计价方式或者增加影片种类,处理起来会很方便。当然也可以看做是一种状态,区别在于,我可以同时将积分因影片类型不同而不同的逻辑也搬到Price子类中,但这里其实没有必要。
我个人理解state和strtegy的区别主要在于state可以将一类因某个变化量而变化的属性全部合并到一个子类进行处理,而strategy更倾向于集中处理某一种属性。
重构后其类图和序列图变化如下:
图七 Price继承序列图
图八 Price继承类图
总结
本次重构基本结束,但重构没有尽头,只要有不断变化的需求,重构就需要一直进行。