首页 > 代码库 > 再论重构

再论重构

  重构是一个热门的话题,很多人也许知道,但是并不会实践,如果尝试一下的话,你会觉得代码真的不一样了,现在决定改变一下吧?!

 

重构的动机

  重构不是无目的的,重构是一种不改变代码行为的前提下,改善代码可读性,可扩展性的过程。

  了解重构之前,我们需要了解一下代码为什么需要重构。

1. 软件代码是会腐烂的
  代码是会腐烂的,而且是逐渐的,不知不觉的就从好变烂了。

  毫无疑问,并不是所有的烂代码都是一次写成的,也许最初的代码设计的是很好的,但是一旦被多个人修改过以后,就变坏了,我想这个大家肯定是深有体会的。代码总是在所有人的共同“努力”下写烂的。

  从代码变烂的那个时刻开始,当我们需要在其中工作的时候,就会不自觉的添加上自己的一堆烂代码,觉得毫无羞愧,因为代码已经烂了。

  当我们对这种现象习以为常的时候,代码就越来越烂了。
2. 破窗效应
  对于已经开始腐烂的代码我们通常会不去关注和改善,而是继续选择让其更烂,就好像我们对于好多玻璃已经破损的窗户来说,继续打破一块玻璃丝毫不以为意的现象一样,这就是心理学上的“破窗效应”,说白了就是破罐子破摔。就像下面这幅图描述的这样:


3. 技术债务
  这种已经腐烂的代码对于后来的团队来说,就是上一代团队所欠的债务,因为当软件的规模渐渐稳定下来以后,收入增加就很慢了,而后来的团队维护或添加新功能的成本却存在,相对于渐渐减少的收入来说,现有的代码就是一种债务,后面团队的工作也就是在为前人留下的腐烂代码还债的过程。

  综上所述,为了自己,为了他人,让我们力所能及的重构吧!

 

重构的难题

  好了,看了代码变烂的过程,再回味一下修改烂代码的过程,你是不是想试试重构了?实际上,要实施重构,是会遇到很多难题的。

1. 技术上的难题
  技术上的难题其实是最简单的问题,因为所有技术上的问题,都可以通过学习和练习来解决,比如学习什么是好的代码,学习如何辨别不良代码,如何组织单元测试,如何设计和架构等。
2. 管理上的难题
  事实上,管理上的难题比技术上的难题更加难以处理。

  下面这幅图大家肯定非常熟悉了:


  这幅图显示的含义大概就是“冰山一角”了。对于我们的项目来说,其实存在相同的结构,那就是用户看的见的功能只是水面上面的那一点,而用于支持这些功能的代码却有那么一大坨。

  通常我们的Manager,特别是一线的Manager,关注的都是水面上那一点,他们从来不看代码,从来不关注代码质量,只关注项目进度。他们会说:“什么?拿出时间重构?免谈!”“你这个Sprint都忙什么了?我没看到功能有一点变化啊?”...每当大伙听到这种话的时候,是不是当场晕菜?

  长期这样下去,冰山下面那一坨慢慢就垮了,真的到了那个时候,难道上面那一点还会存在吗?

  所以,在管理上,所有人都应该重视代码的内在质量。
  软件工程并不只是程序员的事,参与软件研发的所有人员,包括管理人员,测试人员,开发人员等,都应该了解软件的质量不仅仅包括面上所表现出来的那些业务流程,而且包括面上没有表现出来的易读性,扩展性。这些非功能性需求有时候更能决定项目的成败。

3. 个人难题 - 程序员心理学
  程序员总是宽以律己,严于待人。
  自以为是,总喜欢给别人挑刺,给自己找借口,这是很多程序员的通病。这种情况下,再怎么培训也是不行的,我们总是需要先放下身段,谦虚学习,做好自己,守住自己的职业道德底线。
  守住程序员的底线,保持最起码的职业道德,这是一切的关键。如果自己做不到怎么办?那就通过行政手段来强制执行吧,哈哈,开个玩笑。

 

重构的目标

  当你们克服了上面的那些难题,准备大展身手的时候,且慢,让我们先来看一下重构的目标。重构的目标是改善代码,或者说是改出美的代码。

1. 什么是美的代码?
  1). 沟通
  易于读懂的代码就是好代码,比如白居易为什么能写出流传千古,妇孺皆知的好诗,那是因为他总是把写好的诗词念给老奶奶听,如果她听不懂,就“重构”直到她能听懂。美的代码必须是易于沟通的代码。
  2). 简单
  简单就是美,对称就是美嘛,这个就不用多说了。
  3). 灵活
  易于扩展,易于修改的代码当然就是好代码了。一个几千行的代码,你能轻易的扩展吗?是美的代码吗?
2. 什么是不美的代码?
  所有不简单,不直接,不易读懂,不易扩展的代码就是不美的代码,比如重复,杂糅,易混淆,强耦合,复杂表达式等。当然了很多人认为:写出别人看不懂,复杂诡异的代码才是高人。对此我认为:如果是个人玩的项目,或者是永远不会有其他人去维护的项目,那么写是无可厚非的;但是如果是多人合作的项目,那么写有点不太厚道。

 

重构的手段
  好了,知道了重构的目标,我们下面就可以去了解如何重构代码。重构的手段多种多样,并且通常某一种手段能达到多个目的。下面是我总结的几种手段:
1. 命名
  该手段常用于通过重新给目标一个新的易于理解,清晰的表达其用途或属性的名称来解决难于理解,难懂的问题。
  比如给某个表达式起个易于理解的名字表示表达式的意图。看下面的代码:

int salary = getSalary();int level = getLevel();bool authorized = isAuthorized();if (salary > 5000 || level > 10 || (salary > 4000 && level > 8 && authorized)){    //...}

这是一个判断条件并执行一定逻辑的代码,你觉得怎么样?

  再看重构以后的代码:

int salary = getSalary();int level = getLevel();bool authorized = isAuthorized();bool isHightSalary = salary > 5000;bool isHightLevel = level > 10;bool isMiddleButAllowed = salary > 4000 && level > 8 && authorized;if (isHightSalary || isHightLevel || isMiddleButAllowed){    //...}

大家觉得那种容易理解?

  有时也为了表达不同的目的,来起一个别名来达到易于理解的目的。看下面的例子:

static void Main(string[] args){    // 1. scrubber move    onScrubberMove();    // 2. action move    onActionMove();}static void extendTime() { }static void onScrubberMove() { extendTime(); }static void onActionMove() { extendTime(); }

这是一个简化的模型,在这个程序中,我们有两种对象移动的时候要去扩展时间线,同样的行为包装在含义更加明显的函数中更容易理解。

2. 拆分
  该手段常用于通过简化目标的逻辑结构来解决复杂的表述问题。
  比如将复杂的表达式拆开成多个子表达式分别求解后合成。还看上面的那个例子:

static void Main(string[] args){    int salary = getSalary();    int level = getLevel();    bool authorized = isAuthorized();    if (salary > 5000 || level > 10 || (salary > 4000 && level > 8 && authorized))    {        doWork();    }}

再看重构后的例子:

static void Main(string[] args){    int salary = getSalary();    int level = getLevel();    bool authorized = isAuthorized();    bool isHightSalary = salary > 5000;    if (!isHightSalary)    {        return;    }    bool isHightLevel = level > 10;    if (!isHightLevel)    {        return;    }    bool isMiddleButAllowed = salary > 4000 && level > 8 && authorized;    if (!isMiddleButAllowed)    {        return;    }    doWork();}

是否更加容易理解一点。

  比如将复杂的函数拆开成多个子函数来简化主函数的条理,使得主函数抽象层次一致。针对上面的例子,再重构一步如何?看代码:

static void Main(string[] args){    if(isAllowed())    {        doWork();    }}private static bool isAllowed(){    int salary = getSalary();    int level = getLevel();    bool authorized = isAuthorized();    bool isHightSalary = salary > 5000;    if (!isHightSalary)    {        return false;    }    bool isHightLevel = level > 10;    if (!isHightLevel)    {        return false;    }    bool isMiddleButAllowed = salary > 4000 && level > 8 && authorized;    if (!isMiddleButAllowed)    {        return false;    }    return true;}

Main函数的逻辑是否变得更加容易理解了,这样的子函数是不是也更容易维护和扩展?

3. 归类
  该手段常用于通过将具有类似功能或逻辑的目标放到一起来解决代码凌乱,难于阅读,难于查找,难于修改的问题。

  如最简单的代码归类:

static void Main(string[] args){    List<int> salaryList = new List<int>();    List<int> levelList = new List<int>();    List<int> scoreList = new List<int>();    collectHighSalary(salaryList);    collectHighLevel(levelList);    collectHighScore(scoreList);    collectMiddleSalary(salaryList);    collectMiddleLevel(levelList);    collectLowSalary(salaryList);    collectLowlevel(levelList);}

这段代码已经很好了,再看看下面这样归类如何:

static void Main(string[] args){    List<int> salaryList = new List<int>();    collectHighSalary(salaryList);    collectMiddleSalary(salaryList);    collectLowSalary(salaryList);    List<int> levelList = new List<int>();    collectHighLevel(levelList);    collectMiddleLevel(levelList);    collectLowlevel(levelList);    List<int> scoreList = new List<int>();    collectHighScore(scoreList);    }

每一段代码的内聚性是不是更高了?!修改后的代码是不是满足了变量最小作用域的原则?!

  归类最多的场景就是上面的每个collect方法都分散在多个类中,这个时候把这些方法收集放到一个辅助类中是不是更能满足高内聚的原则。

  归类对于方法太多,顺序太乱,包内类太繁杂仍然是行之有效的处理方法。


4. 转移
  该手段通常用于将目标转移到其他的目的地,比如基类,子类,外部类,内部类来解决类功能不单一的问题。该手段仍然是以构造职责单一的,高内聚的实体(如类,方法)为目标。

  看我实际项目中的一个场景的简化代码:

public class OuterBox{    public void doWork1()    {        operateMiddleBox();        operateInnerBox();    }}public class MiddleBox{    public void doWork2()    {        operateOuterBox();        operateInnerBox();    }}public class InnerBox{    public void doWork3()    {        operateMiddleBox();    }}

这个场景是关于界面的,我们的界面套了多层,这里简化成了3层:OuterBox套着MiddleBox,MiddleBox套着InnerBox。这3层之间项目联系,有时需要调用对方的方法。时间一长,这里的结构嵌套的越多,代码就月复杂的,修改起来相当麻烦。如何重构这个场景呢?

  首先,为了解耦合,我尝试了观察者模式,实现起来太麻烦了,事件和挂接的地方太多。

  然后,我尝试了中介者模式,把每个UI互相调用的部分都移到了中介者中,觉得轻松了不少,这是修改后的代码:

public class OuterBox{    public void doWork1()    {        Mediator.Instance.OperateMiddleBox();        Mediator.Instance.OperateInnerBox();    }}public class MiddleBox{    public void doWork2()    {        Mediator.Instance.OperateOuterBox();        Mediator.Instance.OperateInnerBox();    }}public class InnerBox{    public void doWork3()    {        Mediator.Instance.OperateMiddleBox();    }}public class Mediator{    public void RegisterOuterBox() { }    public void RegisterMiddleBox() { }    public void RegisterInnerBox() { }    public void OperateOuterBox() { }    public void OperateMiddleBox() { }    public void OperateInnerBox() { }    public static Mediator Instance = new Mediator();}

我个人觉得这次重构还是不错的,重构后各个Box之间解耦了,而且交互的逻辑集中转移到了Mediator中,方便处理。

5. 封装
  不要暴露不必要的细节是封装的目的,封装的结果通常是得到新的函数,类或者组件。封装细节可以体现为:
1). 封装复杂性
  该手段通过封装复杂的难以理解但无法修改,或者不用关注的细节问题,来降低变化的修改难度。

  你是不是遇到过这种代码:

static void Main(string[] args){    // 此处是使用一坨丑陋不堪,复杂,但是永远也不用去修改的代码去与另外一个组件交互    // 省略100行    DoSometing();}

  那100行无比复杂的代码是与别的组件交互的,不出意外从来不用去修改,这个时候把这个代码封装到函数中,是不是更好?

static void Main(string[] args){    Communicate();    DoSometing();}private static void Communicate(){    // 此处是使用一坨丑陋不堪,复杂,但是永远也不用去修改的代码去与另外一个组件交互    // 省略100行}

2). 封装变化点

  该手段通过封装目标中变化点来稳定目标的职责。这种情况其实是封装不同变化率的一个特例,就是代码一部分不变,一部分会改变。这个例子太多了,大部分的设计模式都是解决这种问题的。此处个人觉得不需要例子了,如果想看的同学搜一下“策略模式”来看一下即可。

6. 抽象
  该手段通过抽取出目标的可复用的部分(可能是逻辑,可能是数据,甚至可能是抽象的行为)转变为接口来重用部分的逻辑。根据重用粒度的大小依次可以分为:抽取成抽象类,抽取接口,抽取方法。看个简化后的例子:

static void Main(string[] args){    A a = new A();    B b = new B();    Say(a);    Say(b);}static void Say(A a) { a.Say(); }static void Say(B b) { b.Say(); }public class A{    public void Say() { }}public class B{    public void Say() { }}

  下面是抽取接口后的实现:

static void Main(string[] args){    A a = new A();    B b = new B();    Say(a);    Say(b);}static void Say(ISay sayable) { sayable.Say(); }public interface ISay{    public void Say();}public class A: ISay{    public void Say() { }}public class B: ISay{    public void Say() { }}

这个简单的例子也许表达不出大量重复的行为这层意思,但是由于空间有限,也只好如此简化了。

 

重构的实施
  做好了一切的技术准备,下面就是实施重构了。通常来说,重构总是持续的,小步骤的改动。这些细微的改动,通过单元测试保证重构的正确性,是可控制的。不要总是盲目相信自己的能力,要靠数据说话。
1. 持续重构
  重构是持续进行的,对于代码中的坏味道,只要发现并可以立即处理(指的是有资源去处理,比如人力,时间等),就应该立即去重构。
2. 小步骤重构
  程序员大多数有时是盲目自信的,现实的代码常常是看上去很简单,但是下面的坑却很深,有这种感觉或者经历的同学请“点赞”,呵呵。

  大伙还记得这位为了赢1美元而跳浅水坑的美国青年吗:


  够坑爹吧?所以不要盲目自信,还是重构提倡小步骤进行,以防大步走扯着蛋。
3. 单元测试
  重构是在不改变软件表象的情况下改善代码可读性,扩展性的一项活动,所以需要保证重构前后代码的功能应该是一致的,这个通常是要通过单元测试保证的。
4. 重构的渐进过程
  重构的坏味道有很多,每个人的改法也不尽相同,这个并没有一个统一的标准,所以在实际的工作中,由于存在很多老的代码,所以很多组织实际进行重构的时候,都不会选择重构老的代码,除非这些代码出问题或有其它不得不改的理由。此外由于重构开始的时候培养这个习惯比较困难,所以很多的公司都是优先选择几个比较有代表性的坏味道来要求重构,等大家都习惯这个方式以后再加入其他的重构目标,这样大家有一个接受的过程。


重构的质量

  重构实施完了以后,如何去保证施工的质量是一个问题。通常来说,需要依靠下面几个手段。

1. 技术手段: 单元测试
  这个上面说过了,单元测试是保证重构质量的主要手段,这个对于不同的语言有不同的工具,需要的同学自己google一下吧。
2. 行政手段: 强制Review
  单元测试保证的只是代码功能没变,但是不能检查代码美不美,所以要保证重构质量,还需要一些行政手段和工具。
 1). Review的工具
  这个不用多少了,现代的代码管理工具都带版本对比功能,足够Rreview的时候用了。
 2). 代码检查工具
  这个现在也有不少的工具可以检查代码的风格,比如SourceMonitor,PMD等经典工具。
  很多注重代码质量的公司都会要求每个员工提交代码之前都要运行一下这些工具,保证代码符合开发规范。

再论重构