读重构

读重构(改善既有代码的设计)一点心得

序言

距离我读重构这本书已经过去了很久,最近需要分享一下读这本书的感悟心得,可能不能详尽的阐述书中提到的所有细节,不过我会尽力而为。虽然这么说,当初的印象已经不深了,所以又快速回顾了一下,由于看的是中译本,不管是因为自身的原因,还是译者与原作者理解上的偏差,如果有纰漏还请见谅(有能力的可以看一下英文原版)

正式重构代码之前

作者在介绍重构代码的技巧或者说方法之前,先是使用一个简单的案例作为整本书的起始,虽然简单,但涵盖了大量的重构方法,有的方法也许看上去太过简单,是我们每天都在用的,不过在看后续分别介绍各种重构方法时,可以回过头来与这个案例结合,思考作者的重构思路,便是不小的收获了。

接着这个案例的后续三个章节,是对正式重构之前,一些概念、注意点的梳理,让我们来看一下:

首先需要给出的一点便是,什么是重构,在重构原则这一章中,作者在这里给出了两个定义,分别是对重构作为名词和动词时的概念。

重构作为名词时,其定义是:对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。

重构作为动词时,其定义是:使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

这是原作者的定义,不管作为动词或者名词,重构这一概念,其前提都是需要不改变软件可观察行为,而需要改变的是软件内部结构,这也是指导重构方法的核心原则。

作者在介绍重构概念时,单独写个一个部分,以两顶帽子为标题,作为重构的前提即不改变软件可观察行为的引申,也可以看做对这个概念进一步的阐述。两顶帽子比喻在开发过程中,程序员会进行两种类型的工作,重构以及添加新功能。当你在做其中一件事时,就好比戴上了对应的帽子,此时因为专注于这件事上,所以当你在重构时,不应该添加新功能,反之亦然,这也就是作者说的重构需要不改变软件可观察行为。同一时间只应该戴上一顶帽子,并且应该清楚自己现在是戴的那顶帽子。(如果你说同时戴两顶帽子也可以,那就祝你好运)

下面作者阐述了为何需要重构,这里不展开说明。接下来是何时重构,这个也许也是需要提一下的。作者反对专门抽出时间来做重构,他的观点中,重构不应该是目的,而是手段,是可以帮助你做好其他事的手段。

这里作者提到了一个准则,也许可以作为参考:

事不过三,三则重构。这也是对经典的DRY原则(Don't repeat yourself)的一种应用,当出现重复时,便是需要重构的时候。作者在后面还写了其他重构时机,这里不展开。

作者在重构的难题中,提到了一些需要注意的地方。不要过早的发布接口,注意管理自己负责的代码的可见性,也就是说,在团队协作时,如果其他团队成员越多的使用了你负责的代码,对于你修改这部分代码,就会越发麻烦,如果这些代码只有你自己在使用,修改起来就会相对轻松不少。更关键的是,这还是同公司一个开发团队中,如果这些代码需要继续向外开放,那么设计这部分代码的可见性时,需要更加的小心仔细。

关于何时不应该重构,作者的阐述中,可以感受到其对这一点的谨慎态度。如果可能的话,重构应该是一直需要的,也许关于这一点,影响程序员做出此时不应该重构的因素,很有可能不是技术上的。

在代码的坏味道这一章中,作者通过这个比喻,来说明什么样的代码需要进行重构。其中原书中写的非常详细,这里不做更多的说明了。

后面一章是构筑测试体系,作者在本书第一个例子开始,就强调了测试的重要性,这里作者强调的是单元测试,为的正是重构的前提,保证不改变软件的可观察行为。如果没有测试,那么在进行重构时,对于有没有破坏其他代码逻辑,有没有影响软件行为,心里并没有底,这里并不是测试可以完美的避免这种情况,只是想要强调,相比没有测试的环境,重构带有测试的代码,确保没有破坏软件行为的可能性大大提高。

同时,展开来说,可能在这里与重构这一主题关系不是很大,测试对于软件的设计也会产生正面的影响。为了测试代码,需要实现代码中待测试代码的可测试性,这也会驱动程序员写出与其他模块低耦合的代码,这也是测试驱动开发的真正价值所在。当然写测试因为会增加一时的工作量,所以有的时候会不受重视,这里可以给出几点建议:

1)针对逻辑复杂或者容易出错的代码编写测试,如果已经有bug出现,则需要编写测试复现此bug,以便以后在开发过程中不会再次引入这个bug

2)针对变化不大的模块优先编写测试。在程序开发中,ui肯定是最容易变化的,可以放在最后考虑,而系统的核心逻辑,相对来说变化的频率更低,同时也是第一条里提到的可能逻辑比较复杂的部分,相对的需要更全的测试

关于如何写好单元测试,这里只是一个引子,可以作为开始测试的第一步。

开始重构

1、提炼函数

当代码中存在过长的函数,或者需要使用注释来阐明一段代码的用途时,应该考虑可以从中提炼出函数。过长的函数,很容易让人望而生畏,并且包含多个职责,这个时候,就应该把各个职责的代码,提炼成函数;另一种情况,当代码需要注释来说明的时候,就应该将其提炼成一个函数,并且函数名需要仔细斟酌,以便表达原先的注释索要表达的意思。

这里顺便说一点对注释的理解。当你想要对函数,类,接口添加注释时,需要考虑到,注释也是需要与时俱进的更新的,函数,类,接口随着不断开发,其行为和内在的意义也会随着变化,很多时候,代码更新了,然而注释却依然留在那里,给以后的维护者带来困扰,这个时候,注释并没有起到其应有的作用,相反增加了维护的成本。所以,如果可以的话,让函数名,类名, 接口名直接表达其行为,并且在内在逻辑行为更新时,更新函数名,类名,接口名,表达当前的行为。考虑如下代码:

void printOwing{
    
    double outstanding = 0.0;
    
    //print banner
    System.out.println("******");
    System.out.println("***Customer Owes***");
    System.out.println("******");
    
}

其可以重构为

void printOwing{
    
    double outstanding = 0.0;
    
    printBanner();
    
}

void printBanner(){
    System.out.println("******");
    System.out.println("***Customer Owes***");
    System.out.println("******");
}

2、分解临时变量

临时变量有很多用途,很多时候,它们被用来保存一段代码的运算结果,以便稍后使用,这种临时变量应该只被赋值一次,如果它被赋值了多次,说明它承担了多个责任,而这会导致代码阅读者产生困惑,此时它就应该被分解为多个临时变量。(当然常用的循环变量不需要)

考虑如下代码:


```java
double getDistance(int time){
    double result;
    double acc = getPrimaryForce()/getMass();
    int primaryTime = Math.min(time,getDelay());
    result = 0.5 * acc * getPrimaryTime() * getPrimaryTime();
    int secondaryTime = time - getDelay();
    if(secondaryTime > 0){
        double primaryVel = acc * getDelay();
        acc = (getPrimaryForce() + getSecondForce())/getMass();
        result += primaryVel * getPrimaryTime() + 0.5 * acc * secondaryTime 
            * secondaryTime;
    }
    return result;
    
}

其中acc被赋值了两次,并且保存的值的含义是不同的,可以考虑重构为:

double getDistance(int time){
    double result;
    final double primaryAcc = getPrimaryForce()/getMass();
    int primaryTime = Math.min(time,getDelay());
    result = 0.5 * primaryAcc * getPrimaryTime() * getPrimaryTime();
    int secondaryTime = time - getDelay();
    if(secondaryTime > 0){
        double primaryVel = primaryAcc * getDelay();
        double acc = (getPrimaryForce() + getSecondForce())/getMass();
        result += primaryVel * getPrimaryTime() + 0.5 * acc * secondaryTime 
            * secondaryTime;
    }
    return result;
    
}

3、搬移字段

在开发过程中,有的时候,你会遇到这种情况,对于一个字段,在其所驻类之外的另一个类中有更多的函数使用了它,就应该考虑搬移这个字段。上述所谓使用,除了直接使用此字段(如果可以的话),也包括调用其赋值函数和取值函数。

考虑如下代码:


```java
class Account{
    private double interestRate;
    
    public double getInterestRate(){
        return interestRate;
    }
    
    public void setInterestRate(double interestRate){
        this.interestRate = interestRate;
    }
    
}

class AccountType{
    
    private Account account;
    
    public void interestForAmountDays(double amount,int days){
        return account.getInterestRate() * amount * days /365;
    }
        
}


可以考虑重构为:

class AccountType{
    private double interestRate;
    
    public double getInterestRate(){
        return interestRate;
    }
    
    public void setInterestRate(double interestRate){
        this.interestRate = interestRate;
    }
    
    public void interestForAmountDays(double amount,int days){
        return getInterestRate() * amount * days /365;
    }
        
}


4、重构魔法数

在软件行业中,魔法数(magic number)大概是历史最悠久的不良现象之一了。所谓魔法数是指拥有特殊意义,却又不能明确表现出这种意义的数字。许多语音都允许声明常量,以指代魔法数。不过,在这里,我们应该观察魔法数是如何被使用的。如果魔法数是一个类型码,考虑使用类代替类型码(这个在稍后介绍);如果魔法数代表的是一个数组的长度,则可以使用Array.length替代;如果都不是的话,再考虑使用常量替代。

5、使用类代替类型码

在使用类型码替代魔法数的时候,有一个问题,类型码只是这个数值的别名,任何使用类型码作为参数的函数中,所期望的实际是一个数值,编译器无法施加强制检查以保证只使用类型码。这个时候,如果使用类替代类型码,编辑器就可以进行类型检查,使得调用更加安全。但是,只是类型码是纯数值,而不会对程序行为有影响时,才可以考虑使用类替代。

考虑如下代码,Person类中有作为其血型的类型码:


```java
class Person{
    public static final int O = 0;
    public static final int A = 1;
    public static final int B = 2;
    public static final int AB = 3;
    
    private int bloodGroup;
    
    public Person(int bloodGroup){
        this.bloodGroup = bloodGroup;
    }
    
    public int getBloodGroup(){
        return bloodGroup;
    }
    
}

可以考虑如此重构,首先建立一个血型类:

enum BloodGroup{
    O,A,B,AB 
}

然后修改Person类,使用枚举,然后将针对血型的函数放到枚举类中。

6、封装集合

我们常常会在一个类中使用集合来保存一组实例,同时会提供针对此集合的取值/赋值函数。但是,集合的处理方式,是应该有别于其他种类的数据的。首先,取值函数不应该返回集合本身,因为这会让集合使用者有意或意外的修改集合内容,同时集合拥有者对此却一无所知。如果集合使用者只需要查询集合内容,则应该只提供集合的不可变的只读副本,这也额外提供了线程安全性。如果集合使用者还需要可以修改集合内容,那么也不应该提供集合的赋值函数,而是提供添加或删除集合元素的函数,这可以提供集合拥有者更精细的控制能力。

考虑如下代码:


```java
class Person{
    
    private List<Course> courses;
    
    public List<Course> getCourses(){
        return courses;
    }
    
    public setCourser(List<Course> courses){
        this.course = courses;
    }
    
}

可以重构为如下版本:

class Person{
    
    private List<Course> courses = new ArrayList<Course>;
    
    public List<Course> getCourses(){
        return Collections.unmodifiedList(courses);
    }
    
    public addCourse(List<Course> courses){
        this.courses.addAll(courses);
    }
    
    public removeCourse(List<Course> courses){
        this.courses.removeAll(courses);
    }
    
}

上述重构只是针对集合的取值/赋值,原书中这一部分还结合了其他重构方法,大家可以参阅。

7、将查询函数和修改函数分离

书中提到了一个很好的原则:任何有返回值的函数,都不应该有看得到的副作用。这样做的好处是,可以任意调用此函数,重构调用此函数的函数,也会轻松很多。当然这里使用了“看得到的副作用”这一说法,要解释这点,考虑这种情况:查询函数将查询结果缓存起来,以便加快后续查询的速度,这种修改对于调用者来说是感知不到的,因为不论何时查询,都会返回相同结果(当然可以设计逻辑使缓存失效,这里不多做说明)。

8、移除对参数的赋值

在函数签名中有参数声明是非常常见的情况,同时很多时候还会对参数赋值,不过,这里所说的对参数赋值,不是指在参数对象上进行什么操作,而是改变这个参数,是它引用另一个对象。这里我为什么要提到这点,是因为原书在解释这一节时,讨论了Java的传参方式,Java只采用按值传递的方式,这点我之前一直是以为传参方式是区分引用对象和原始数据类型。

原书中作者提到了这么几个例子,

class Param{
    
    public static void main(String[] args){
        int x = 5;
        triple(x);
        System.out.println("x after tripple: " + x);
    }
    
    private static void tripple(int arg){
        arg = arg * 3;
        System.out.println("arg in tripple: " + arg);
    }
    
}

上述例子使用原始数据类型会产生这样的输出:

arg int tripple : 15
x after tripple: 5

而如果参数传递的是对象,那情况就会有所不同,下面代码中传递代表日期的Date对象:

class Param{
    public static void main(String[] args){
        Date d1 = new Date("1 Apr 98");
        nextDateUpdate(d1);
        System.out.println("d1 after nextDay: " + d1);
        
        Date d2 = new Date("1 Ar 98");
        nextDateReplace(d2);
        System.out.println("d2 after nextDay: " + d2);
    }
    
    private static void nextDateUpdate(Date arg){
        arg.setDate(arg.getDate() + 1);
        System.out.println("arg in nextDay: " + arg);
    }
    
    private static void nextDateReplace(Date arg){
        arg = new Date(arg.getYear(),arg.getMonth(),arg.getDate + 1);
        System.out.println("arg in nextDay: " + arg);
    }
    
    
}

上述程序产生的输出:

arg in nextDay: Thu Apr 02 00:00:00 EST 1998
d1 after nextDay: Thu Apr 02 00:00:00 EST 1998
arg in nextDay: Thu Apr 02 00:00:00 EST 1998
d2 after nextDay: Wed Apr 01 00:00:00 EST 1998

之所以会产生这样的结果,是因为对象的引用也是按值传递的,因此修改对象内部状态没什么问题,但对参数重新赋值,就需要仔细斟酌。

先写到这里

重构这本书里还有大量重构手法,以及一个专门讲解大型重构的篇章,不过我这里先写到这里。读者如果仔细思考的话,可以发现,很多重构的核心思想,在其他书籍里也提到,就比如将查询和修改函数分离,就体现了软件开发的经典思想:单一职责原则,其实SOLID原则之一,当然其他的原则(开放封闭原则,里氏替代原则,接口隔离原则,依赖倒置原则)也都有体现,而且这些原则不仅仅指导重构技巧,其贯穿整个软件生命周期,良好的应用这些原则,可以带来很多好处。

另外,原书中的最后一张总结部分,提到了其他关于重构的部分。上述的重构技巧或者称为方法,只是进入重构世界的敲门砖,真正的困难在于,你需要知道何时开始重构,何时停止,应该以什么节奏进行重构。毕竟今天重构之后让你满意的代码,明天你就会觉得这么重构是完全有问题的,所以原书的作者提醒我们,每一步重构都应该是可以返回到原始状态的,你是可以走回头路的,而且书中大量的重构方法,很多是对于另一种方法的翻转,使你不至于陷入窘境。

原书的作者最后再次重申永远不要忘记“两顶帽子”。毕竟重构时,你会发现某些代码不正确,并且你有绝对相信自己的判断,因此想马上修改这处代码。稍等,如果你现在在重构,那么久应该先做好重构,而重构并不是修改代码功能,对于这些修改点,也许你可以使用TODO记录下来,重构完再来修改它,毕竟很多时候,当你同时重构和修改代码功能bug,然后发现程序出问题了,这个时候你却无法判断是重构还是修改代码功能导致的,也许你想调试一下很快就解决了,不过原作者的经验告诉你,调试所花的时间也许只有几分钟,也许就要几小时了。

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

推荐阅读更多精彩内容