如何进行重构

1.1、起点

首先上一个简单的事例:

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

我用了几个类来表现这个例子中的元素。下图是一张UML类图,用以显示这些类。我会逐一列出这些类的代码。


1-1.jpg

Movie(影片)

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

/* .h文件 */ 
typedef NS_ENUM(NSInteger, SLMovieType) {
    SLMovieType_REGULAR,
    SLMovieType_CHILDRENS,
    SLMovieType_NEW_RELEASE,
};
@interface SLMovie : NSObject

@property (nonatomic, copy) NSString *title;
@property (nonatomic, assign) SLMovieType movieType;
@property (nonatomic, assign) NSInteger priceCode;

/// 构造方法
- (instancetype)initWithTitle:(NSString *)title priceCode:(NSInteger)priceCode;
@end

/* .m文件 */ 
static NSInteger const CHILDRENS   = 2;
static NSInteger const REGULAR     = 0;
static NSInteger const NEW_RELEASE = 1;

@implementation SLMovie

- (instancetype)initWithTitle:(NSString *)title priceCode:(NSInteger)priceCode {
    self = [super init];
    if (self) {
        self.title = title;
        self.priceCode = priceCode;
    }
    return self;
}
@end

Rental(租赁)

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

/* .h文件 */ 
@class SLMovie;

@interface SLRental : NSObject
@property (nonatomic, strong, readonly) SLMovie *movie;
@property (nonatomic, assign, readonly) NSInteger daysRented;

- (instancetype)initWithMovie:(SLMovie *)movie daysRented:(NSInteger)daysRented;

@end

/* .m文件 */ 
@interface SLRental ()

@property (nonatomic, strong, readwrite) SLMovie *movie;
@property (nonatomic, assign, readwrite) NSInteger daysRented;
@end

@implementation SLRental

- (instancetype)initWithMovie:(SLMovie *)movie daysRented:(NSInteger)daysRented {
    self = [super init];
    if (self) {
        self.movie = movie;
        self.daysRented = daysRented;
    }
    return self;
}
@end

Customer(顾客)

Customer类用来表示顾客。就像其他类一样,它也拥有数据和相应的访问函数:

/* .h文件 */ 
@class SLRental;

@interface SLCustomer : NSObject

@property (nonatomic, copy, readonly) NSString *name;

- (void)addRental:(SLRental *)rental;

/// 构造方法
- (instancetype)initWithName:(NSString *)name;

@end

/* .m文件 */ 
@interface SLCustomer ()
@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) NSMutableArray <SLRental *> *rentals;
@end

@implementation SLCustomer

- (instancetype)initWithName:(NSString *)name {
    self = [super init];
    if (self) {
        self.name = name;
    }
    return self;
}

- (void)addRental:(SLRental *)rental {
    if ([self.rentals containsObject:rental]) return;
    [self.rentals addObject:rental];
}

- (NSMutableArray<SLRental *> *)rentals {
    if (_rentals == nil) {
        _rentals = [NSMutableArray array];
    }
    return _rentals;
}
@end

Customer还提供了一个用于生成详单的函数,下图显示这个函数带来的交互过程。


1-2.jpg

完整代码如下:

- (NSString *)statement {
    double totalAmount = 0;
    int frequentRenterPoints = 0;
    NSMutableArray *rentals = [NSMutableArray arrayWithArray:self.rentals];
    NSString *result = [NSString stringWithFormat:@"Rental record for %@\n",self.name];
   
    while (rentals.count > 0) {
        double thisAmount = 0;
        SLRental *each = (SLRental *)rentals.firstObject;
        
        switch (each.movie.movieType) {
            case SLMovieType_REGULAR:
                thisAmount += 2;
                if (each.daysRented > 2) {
                    thisAmount += (each.daysRented - 2) * 1.5;
                }
                break;
            
            case SLMovieType_NEW_RELEASE:
                thisAmount += each.daysRented * 3;
                break;
                
            case SLMovieType_CHILDRENS:
            thisAmount += 1.5;
            if (each.daysRented > 3) {
                thisAmount += (each.daysRented - 3) * 1.5;
            }
                break;
        }
        
        // add frequent renter points
        frequentRenterPoints ++;
        // add bonus for a two day new release rental
        if (each.movie.priceCode == SLMovieType_NEW_RELEASE && each.daysRented > 1) {
            frequentRenterPoints ++;
        }
        
        // show figures for this rental
        result = [NSString stringWithFormat:@"%@%@:%f",result ,each.movie.title, thisAmount];
        totalAmount += thisAmount;
    }
    // add footer lines
    result = [NSString stringWithFormat:@"%@ Amount owed is %f \n", result,totalAmount];
    result = [NSString stringWithFormat:@"%@ You earned %d frequent renter points", result,frequentRenterPoints];
    
    return result;
}

对此起始程序的评价

这个起始程序给你留下什么印象?我会说它设计得不好,而且很明显不符合面向对象精神。对于这样一个小程序,这些缺点起始没有什么大不了的。快速而随性地涉及一个简单的程序并没有错。但如果这是复杂系统中具有代表性的一段,那么我就真的要对这个程序的信心动摇了。Customer里头那个长长的statement做的事情实在太多了,它做了很多原本应该由其他类完成的事情。

即便如此,这个程序还是能正常工作。所以这只是美学意义上的判断,只是对丑陋代码的厌恶,是吗?如果不去修改这个系统,那么的确如此,编译器才不会在乎代码好不好看呢。但是当我们打算修改系统的时候,就涉及到了人,而人在乎这些。差劲的程序是很难修改的,因为很难找到修改点。如果很难找到修改点,程序员就很可有可能犯错,从而引入bug。

在这个例子里,我们的用户希望对系统做一点修改。首先他们希望以HTML格式输出详单,这样就可以直接在网页上显示,这非常符合时下的潮流。现在请你想一想,这个变化对带来什么影响。看看代码你就会发现,根本不可能在打印HTML报表的函数中复用目前statement的任何行为。你唯一可以做的就是编写一个全新的htmlStatement,大量重复statement的行为。当然,现在做这个还不太费力,你可以把statement复制一份然后按需要修改就是了。

但如果计费标准发生变化,又会如何?你必须同时修改statement和htmlStatement,并确保两处修改的一致性。当你后续还要再修改时,复制粘贴带来的问题就复现出来了。如果你编写的事一个永不需要修改的程序,那么剪剪贴贴就还好,但如果成功需要保存很长时间,而且可能需要修改,复制粘贴行为就会造成潜在的威胁。

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

为了应付分类规则和计费规则的变化,陈旭必须对statement做出修改。但如果我们把statement内的代码赋值到用以打印HTML详单的函数中,就必须确保将来的任何修改在两个地方保持一致。随着各种规则变得越来越复杂,适当的修改点越来越难找,不犯错的机会也越来越少。

你的态度也许倾向于尽量少修改程序:不管怎么说,它还运行的很好。你心里牢牢记着那句古老的工程谚语:“如果它没坏,就不要动它。”这个程序也许还没坏掉,但它造成了伤害。它让你的生活比较难过,因为你发现很难完成客户所需的修改。这时候,重构技术就该粉墨登场了。

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

1.2、重构的第一步

每当我要进行重构的时候,第一个步骤永远相同:我得为即将修改的代码建立一组可靠的测试环境。这些测试时必要的,因为尽管遵循重构收发可以使我避免绝大多数引入bug的情形,但我毕竟是人,毕竟有可能犯错。所以我需要可靠的测试。

由于statement的运作结果是个字符串,所以我首先假设一些顾客,让给他们每个个人各租几部不同的影片,然后产生报表字符串。然后我就可以拿新字符串和手上已经检查过的参考字符串做比较。我把所有测试都设置好,只要在命令行输入一条OC命令就把他们统统运行起来。运行这些测试只需要几秒钟,所以你会看到我经常运行它们。

测试过程中很重要的一部分,就是测试程序对于结果的报告方式。它们要么说“OK”,标识所有新字符串都和参考字符串一样,要么就列出失败清单,显示问题字符串的出现行号。这些测试都能够自我检验。是的,你必须让测试有能力自我检验,否则就得耗费大把时间来回对比,这会降低你的开发速度。

进行重构的时候,我们需要依赖测试,让它告诉我们是否引入了bug。好的测试是重构的根本。花时间建立一个优良的测试机制是完全值得的,因为当你修改程序时,好测试会给你必要的安全保障。测试机制在冲狗狗领域的地位实在太重要了。

重构之前,首先检查自己是否有一套可靠的测试机制。这些测试必须有自我检验能力。

1.3、分解并重组statement

第一个明显会引起注意的就是长的离谱的statement。每当看到这样长长的函数,我就想把它大卸八块。要知道,代码块越小,代码的功能就越容易管理,代码的处理和移动也就越轻松。

我们重构过程的第一阶段中,我将说明如何把长长的函数切开,并把较小块的代码移至更合适的类。我希望降低代码重复量,从而使新的(打印HTML详单用的)函数更容易撰写。

第一个步骤是找出代码的逻辑泥团并运用Extract Method。本例一个m影响的逻辑泥团就是switch语句,把它提炼到独立函数中似乎比较好。

和任何重构手法一样,当我提炼一个函数时,我必须知道可能出什么错。如果提炼的不好,就可能给程序引入bug。所以重构之前我需要先想出安全做法。由于之前我已经进行过数次这类重构,所以我已经把安全步骤记录在后面的重构列表中了。

首先我得再这段代码里找出函数内的局部变量和参数。我找到两个,each和thisAmount,前者并未被修改,后者会被修改。任何不会被修改的变量都可以被我当成参数传入新的函数,至于会被修改的变量就需要格外小心。如果只有一个变量会被修改,我可以把它当做返回值。thisAmount是个临时变量,其值在每次循环起始处被设为0,并且在switch语句之前不会改变,所以我可以直接把新函数的返回值赋给它。

下面展示了重构前后的代码。重构前的代码在首位,重构后的代码在末尾。凡是从函数提炼出来的代码,以及新代码所做的任何修改,只要我觉得不是明显到一眼看出,就以/**/标示出来特别提醒你。

重构前:

- (NSString *)statement {
    double totalAmount = 0;
    int frequentRenterPoints = 0;
    NSMutableArray *rentals = [NSMutableArray arrayWithArray:self.rentals];
    NSString *result = [NSString stringWithFormat:@"Rental record for %@\n",self.name];
   
    while (rentals.count > 0) {
        double thisAmount = 0;
        SLRental *each = (SLRental *)rentals.firstObject;
        
       /**************即将开始修改***************/
        switch (each.movie.movieType) {
            case SLMovieType_REGULAR:
                thisAmount += 2;
                if (each.daysRented > 2) {
                    thisAmount += (each.daysRented - 2) * 1.5;
                }
                break;
            
            case SLMovieType_NEW_RELEASE:
                thisAmount += each.daysRented * 3;
                break;
                
            case SLMovieType_CHILDRENS:
                thisAmount += 1.5;
                if (each.daysRented > 3) {
                thisAmount += (each.daysRented - 3) * 1.5;
                }
                break;
        }
        /**************即将结束修改***************/
        
        // add frequent renter points
        frequentRenterPoints ++;
        // add bonus for a two day new release rental
        if (each.movie.priceCode == SLMovieType_NEW_RELEASE && each.daysRented > 1) {
            frequentRenterPoints ++;
        }
        
        // show figures for this rental
        result = [NSString stringWithFormat:@"%@%@:%f",result ,each.movie.title, thisAmount];
        totalAmount += thisAmount;
    }
    // add footer lines
    result = [NSString stringWithFormat:@"%@ Amount owed is %f \n", result,totalAmount];
    result = [NSString stringWithFormat:@"%@ You earned %d frequent renter points", result,frequentRenterPoints];
    
    return result;
}

重构后:

- (NSString *)statement {
    double totalAmount = 0;
    int frequentRenterPoints = 0;
    NSMutableArray *rentals = [NSMutableArray arrayWithArray:self.rentals];
    NSString *result = [NSString stringWithFormat:@"Rental record for %@\n",self.name];
   
    while (rentals.count > 0) {
        SLRental *each = (SLRental *)rentals.firstObject;
        /**************开始修改***************/
        double thisAmount = [self amountFor:each];
        /**************结束修改***************/
        // add frequent renter points
        frequentRenterPoints ++;
        // add bonus for a two day new release rental
        if (each.movie.priceCode == SLMovieType_NEW_RELEASE && each.daysRented > 1) {
            frequentRenterPoints ++;
        }
        
        // show figures for this rental
        result = [NSString stringWithFormat:@"%@%@:%f",result ,each.movie.title, thisAmount];
        totalAmount += thisAmount;
    }
    // add footer lines
    result = [NSString stringWithFormat:@"%@ Amount owed is %f \n", result,totalAmount];
    result = [NSString stringWithFormat:@"%@ You earned %d frequent renter points", result,frequentRenterPoints];
    
    return result;
}

- (double)amountFor:(SLRental *)each {
    double thisAmount = 0;
    switch (each.movie.movieType) {
        case SLMovieType_REGULAR:
            thisAmount += 2;
            if (each.daysRented > 2) {
                thisAmount += (each.daysRented - 2) * 1.5;
            }
            break;
            
        case SLMovieType_NEW_RELEASE:
             thisAmount += each.daysRented * 3;
            break;
            
        case SLMovieType_CHILDRENS:
         thisAmount += 1.5;
            if (each.daysRented > 3) {
                thisAmount += (each.daysRented - 3) * 1.5;
            }
            break;
        
    }
    return thisAmount;
}

现在,我已经把原来的函数分为两块,可以分别处理他们。我不喜欢amountFor内的某些变量名称,现在正是修改它们的时候。

改名之前的代码如下:

- (int)amountFor:(SLRental *)each {
    double thisAmount = 0;
    switch (each.movie.movieType) {
        case SLMovieType_REGULAR:
            thisAmount += 2;
            if (each.daysRented > 2) {
                thisAmount += (each.daysRented - 2) * 1.5;
            }
            break;
            
        case SLMovieType_NEW_RELEASE:
             thisAmount += each.daysRented * 3;
            break;
            
        case SLMovieType_CHILDRENS:
         thisAmount += 1.5;
            if (each.daysRented > 3) {
                thisAmount += (each.daysRented - 3) * 1.5;
            }
            break;
        
    }
    return thisAmount;
}

下面是改名后的代码:

- (double)amountFor:(SLRental *)aRental {
    double result = 0;
    switch (aRental.movie.movieType) {
        case SLMovieType_REGULAR:
            result += 2;
            if (aRental.daysRented > 2) {
                result += (aRental.daysRented - 2) * 1.5;
            }
            break;
            
        case SLMovieType_NEW_RELEASE:
             result += aRental.daysRented * 3;
            break;
            
        case SLMovieType_CHILDRENS:
         result += 1.5;
            if (aRental.daysRented > 3) {
                result += (aRental.daysRented - 3) * 1.5;
            }
            break;
        
    }
    return result;
}

改名之后,我需要重新编译并测试,确保没有破坏任何东西。

更改变量名称是值得的行为吗?绝对值得。好的代码应该清楚的表达出自己的功能,变量名称是代码清晰的关键。如果为了提高代码的清晰度,需要修改某些东西的名字,那么就大胆去做吧。只要有良好的查找/替代工具,更改名称并不困难。语言所提供的强类型检查以及你自己的测试机制会指出任何你遗漏的东西。记住:任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350