定义
重构是在不改变软件可观察行为的前提下改善其内部结构。当你面对一个最需要重构的遗留系统时,其规模之大,历史之久,代码质量之差,常会使得添加单元测试或者理解其逻辑都成为不可能的任务。
此时,你唯一能依靠的就是那些已经被证明是行为保持的重构手法:用绝对安全的手法从“焦油坑”中整理出可测试的接口,给它添加测试,以此作为继续重构的立足点。
重构不会改变软件可观察的行为—-重构之后软件功能一如既往。任何用户,不论最终用户或其他程序员,都不知道已经有东西发生了改变。
为什么要重构
重构不是包治百病的万灵丹,它绝对不是所谓的“银弹”,不过它的确很有价值,虽不是一颗银子弹却是一把“银钳子”,可以帮助你始终良好地控制自己的代码。
重构是个工具,它可以用于以下几个目的。
1:重构改进软件设计
如果没有重构,程序的设计会逐渐腐败变质,当人们只为短期目的,或是在完成理解整体设计之前,就贸然修改代码,程序将逐渐失去自己的代码结构,程序员越来越难通过阅读源码而理解原来的设计。
重构像是在整理代码,你所做的就是让所有东西回到应处的位置上。
代码结构的流失是累积性的。越难看出代码所代表的设计意图,就越难保护其中设计,于是该设计就腐败得越快。经常性的重构可以帮助代码维持自己该有的形态。
完成同样一件事,设计不良的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事,因此改进设计的一个重要方向就是消除重复代码,这个动作的重要性在于方便未来的修改。如果消除重复代码,你就可以确定所有事物和行为在代码中只表述一次。
2:重构使代码更容易理解
如果一个程序员花费一周时间来修改某段代码,那才要命呢—-如果他理解了你的代码,这个修改原来只要一个小时。
问题在于,当你努力让程序运转的时候,不会想到未来出现的那个开发者。是的,我们应该改变一下开发节奏,对代码做适当修改,让代码变得更容易理解。
重构可以帮助我们让代码更易读,一开始进行重构时,你的代码可以正常运行,但结构不够理想。在重构上花一点点时间,就可以让代码更好地表达自己的用途。这种编程的核心就是“准确说出我所要的”。
3:重构帮忙找到bug
对代码的理解,可以帮助我找到bug。我承认我不太擅长调试,有些人只要盯着一大段代码就可以找到里面的bug,我可不行。但我发现,如果对代码进行重构,我就可以深入理解代码的作为,并恰到好处的把新的理解反馈过去。搞清楚程序结构的同时,我也清楚了自己所做的一些假设,于是想不把bug揪出来都难。
4:提高编程速度
良好设计是维持软件开发速度的根本,重构可以帮助你更快速地开发软件,因为它防止系统腐败编制,它甚至可以提高设计指令,
良好的设计是快速开发的根本—-事实上,拥有良好设计才可能做到快速开发,如果没有良好设计,或者某一段时间内你的进展迅速,但恶劣的设计很快就让你的速度慢下来,你会把时间花在调试上面,无法添加新功能,修改时间越来越长,因为你必须花越来越多的时间去理解系统,寻找重复代码,随着你给最初程序打上一个又一个补丁,新特性需要更多代码才能实现,真是个恶性循环。
何时重构
讨论重构的时候,大家会有疑问,是不是需要专门拨出时间来进行重构。在我看来,重构本来就不是一件应该特别拨出时间做的事情。重构应该随时随地执行,不应该为了重构而重构。
重构的三大法则
- 添加功能时重构
- 复审代码时重构
- 修补错误时重构
1:添加功能时重构
最常见的重构时机就是我想给软件添加新特性的时候,因此,重构的直接原因往往是为了帮助我理解需要修改的代码—这些代码可能是别人写的,也可能是我自己写的。无论何时,只要我想理解代码所做的事,我就会问自己,是否能对这段代码进行重构,使我能更快地理解它。
2:修改错误时重构
调试过程中运用重构,多半是为了让代码更有可读性。当我看着代码并努力理解它的时候,我用重构帮助加深自己的理解。
3:复审代码时重构
代码复审对于编写清晰代码也很重要。我的代码也许对我自己来说很清晰,对其他人则不然。这是无法避免的,因为要让开发者设身处地为那些不熟悉自己所作所为的人着想,实在太困难了。
为什么重构有用
是什么让程序如此难以相与,目前我能想起下述四个原因,它们是:
1:难以阅读的程序,难以修改
2:逻辑重复的程序,难以修改
3:添加新行为时需要修改已有代码的程序,难以修改
4:带复杂条件逻辑的程序,难以修改
因此,我们希望程序(1):容易阅读(2):所有逻辑都只在唯一地点指定(3):新的改动不会危及现有行为 (4):尽可能简单表达条件逻辑。
重构就是这样一个过程:它在一个目前可运行的程序上进行,在不改变程序行为的前提下使其具备上述美好性质,使我们能够继续保持高速开发,从而增加程序的价值。
修改接口
如果重构手法改变了已发布接口,你必须同时维护新旧两个接口,直到所有用户都有时间对这个改变做出反应。幸运的是,这不太困难。你通常都有办法把事情组织好,让旧接口继续工作。请尽量这么做:让旧接口调用新接口,当你要修改某个函数名称时,请留下旧函数,让它调用新函数,千万不要复制函数实现,那会让你陷入重复代码的泥沼中难以自拔。
何时不该重构
有时候你根本不应该重构,例如当你应该编写所有代码的时候,有时候既有代码是在太混乱,重构它还不如重新写一个来的简单。做出这种决定很困难,我承认我也没有什么好准则可以判断何时应该放弃重构。
重写(而非重构)的一个清楚信号就是:现有代码根本不能正常工作,你可能只是试着做点测试,然后就发现代码中满是错误,根本无法稳定运作。记住, 重构之前,代码必须起码能够在大部分情况下正常运作。
另外,如果项目已近最后期限,你也应该避免重构。在此时机,从重构过程赢得的生产力只有在最后期限过后才能体现出来,而那个时候已经为之晚矣。
如果项目已经非常接近最后期限,你不应该再分心于重构,因为已经没有时间了。不过多个项目经验显示:重构的确能够提高生产力,如果最后你没有足够时间,通常就表示你其实早该进行重构。
重构与性能
关于性能,一件很有趣的事情是:如果你对大多数程序进行分析,就会发现它把大半时间都耗费在一小半代码身上。如果你一视同仁地优化所有代码,90%的优化工作都是白费劲的,因为被你优化的的代码很少被执行。你花时间做优化是为了让程序运行更快,但如果因为缺乏对程序的清楚认识而花费时间,那些时间就都浪费掉了。
代码的坏味道
1:重复代码
如果你在一个以上的地点看到相同的程序结构,那么可以肯定:设法将它们合而为一,程序会变得更好。
最简单的重复代码就是“同一个类的两个函数含有相同的表达式”,这时候你需要做的就是提炼出重复的代码,然后这这两个地点都调用被提炼出来的那一段代码。
另一个常见的情况是“两个互为兄弟的子类内含相同表达式”。要避免这种情况,只需对两个类的共同代码提取出来推入到超类内。如果代码之间只是类似,并非完全相同,那么就可以将相似部分和差异部分分割开,构成单独一个函数。
如果两个毫不相关的类出现重复代码,你应该考虑将代码提取到一个独立类中,然后两个类都调用这个新类。
2:过长函数
拥有短函数的对象会活的比较好,比较长。
早期的编程语言中,子程序调用需要额外开销,这使得人们不太乐意使用小函数。
现代OOP语言几乎已经完全免除了进程内的函数调用开销,不过代码阅读者还是得多费力气,因为他必须经常转换上下文去看看子程序做了什么。
某些开发环境允许用户同时看到两个函数,这可以帮助你省去部分麻烦,但是让小函数容易理解的真正关键在于一个好名字。如果你能给函数起个好名字,读者就可以通过名字了解函数的作用,根本就不必去看其中写了些什么。
最终的效果是:你应该更积极的分解函数。我们遵循这样一条规则:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。
我们可以做一组甚至短短一行代码做这件事。哪怕替换后的函数调动动作比函数自身还长,只要函数名称能够解释其用途,我们也该毫不犹豫地那么做。
关键不在于函数的长度,而在于函数“做什么”和“如何做”之间的语义距离。
3:过大的类
类中如果有太多代码,也是代码重复,混乱并最终走向死亡的源头。最简单的方法是把多余的东西消弭于类内部。
如果有5个“百行”函数,它们之中很多代码都相同,那么或许你可以把它们变成5个“十行函数”和10个提炼出来的“双行函数”。
4:发散式变化
如果某个类经常因为不同的原因在不同的方向上发生变化,那么就出现发散式变化了。当你看着一个类说:“如果新加入一个数据库,我必须修改这三个函数,如果新出现一种金融工具,我必须修改这四个函数”。那么此时将这个对象分成两个会更好,这样一来每个对象就可以只因一种变化而需要修改。
5:少用switch(或case)语句
面向对象的一个最明显特征是:少用switch(或case)语句。从本质上说,switch语句的问题在于重复,你常会发现同样的switch语句散布于不同地点。如果要为它添加一个新的case语句,就必须找到所有swtich语句并修改它们。面向对象中的多态概念可为此带来优雅的解决方法。
6:过多的注释
当你感觉需要编写注释时,请先尝试重构,试着让所有注释都变得多余。
如果你不知道该做什么,这才是注释的良好运用时机,除了用来记述将来的打算之外,注释还可以用来标记你并无十足把握的区域。你可以在注释里写下自己“为什么做某某事”。这类信息可以帮助将来的修改者,尤其是那些健忘的家伙。
重新组织函数
1:提炼函数
当我看见一个过长的函数或者一段需要注释才能让人理解用途的代码,我就会将这段代码代码放进一个独立的函数中。
有几个原因造成我喜欢简短而命名良好的函数。首先,如果每个函数的粒度都很小,那么函数被复用的机会就更大,其次,这会使高层函数读起来就像一系列注释,再次,如果函数都是细粒度,那么函数的覆写也应该更容易些。
2:内联函数
<?php $num = 100; function getRating(): int { return moreThanFiveLateDeliveries() ? 2 : 1; } function moreThanFiveLateDeliveries(): int { return $GLOBALS['num'] > 5; } echo getRating();
提炼成:
<?php
$num = 100;
function getRating(): int
{
return $GLOBALS['num'] > 5 ? 2 : 1;
}
echo getRating();
有时候你会遇到某些函数,其内部代码和函数名称同样清晰易读,也许你重构了该函数,使得其内容和其名称变得同样清晰,如果是这样的话,那么你应该去掉这个函数,直接使用其中的代码。
3:引入解释性变量
你有一个复杂的表达式,将该复杂表达式(或其中一部分)的结果放进一个临时变量,以此变量来解释表达式用途。
if((platform.toUpperCase().indexOf('Mac')>-1) && (browser.toUpperCase().indexOf("IE")>-1)){
}
改为
isMacOs=(platform.toUpperCase().indexOf('Mac')>-1); isIE=(browser.toUpperCase().indexOf("IE")>-1); if(isMacOs && isIE){ }
在条件逻辑中,使用这种操作特别有价值:你可以用这项重构将每个条件子句提炼出来,以一个良好命名的临时变量来解释对应条件子句的意义。
在对象之间搬移特性
1:提炼类
某个类做了应该由两个类做的事情,那么就需要建立一个新类,将相关的字段和函数从旧类搬移到新类。
一个类应该是一个清楚的抽象,处理一些明确的责任,但是在实际工作中,类会不断成长扩展,你会在这儿加入一些功能,在那儿加入一些数据。给某个类添加一项新责任时,你会觉得不值得为这项责任分离出一个单独的类,于是,随着责任不断增加,这个类会变得过分负责,很快,你的类就变成一团乱麻。
这样的类往往含有大量函数和数据,这样的类往往太大而不易理解,此时你需要考虑哪些部分可以分离出去,并将它们分离到一个单独的类中。
重新组织数据
1:以对象取代数组
你有一个数组,其中的元素各自代表不同的东西。以对象替换数组,对于数组中的每个元素,以一个字段来表示。
String[] row = new String[3];
row[0] = "liverpool";
row[1] = "15";
转换
Performance row = new Performance();
row.setName("liverpool");
row.setAge("15");
有时候你会发现,一个数组容纳了多种不同对象,这会给用户带来麻烦,因为他们很难记住“数组的第一个元素是人名”这样的规定,对象就不同了,你可以运行字段名称和函数名称来传达这样的信息,因此你无须死记,也无须依赖注释。
2:封装字段
面向对象的首要原则之一就是封装,或者称为“数据隐藏”,按此规则,你绝对不应该将数据声明为public,否则其他对象就有可能访问甚至修改这项数据,而拥有该对象的对象却毫无察觉,于是,数据和行为就被分开了。
数据声明为public被看作是一种不好的做法,因为这样会降低程序的模块化程序。数据和使用该数据的行为如果集中在一起,一旦情况发生变化,代码的修改就会比较简单,因为需要修改的代码都集中于同一个地方。
3:以类取代类型码
类之中有一个数值类型码,但它并不影响类的行为,那么这个时候我们可以以一个新的类替代该数值类型码。
简化条件表达式
1:分解条件表达式
程序之中,复杂的条件逻辑是最常导致复杂度上升的地点之一。你必须编写代码来检查不同的条件分支,根据不同的分支做不同的事情,然后,你很快就会得到一个相当长的函数。大型函数自身会使代码的可读性下降,而条件逻辑则会使代码更难阅读。
和任何大块头代码一样,你可以将它分解为多个独立函数,根据每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新建函数,从而更清楚地表达自己的意图。
简化函数调用
1:将查询函数和修改函数分离
某个函数既返回对象状态值,又修改对象状态。这个是不推荐的,应该建立两个不同的函数,其中一个负责查询,另一个负责修改。
2:令函数携带参数
你可能会发现这样的两个函数:它们做着类似的工作,但因少数几个值致使情况略有不同。在这种情况下,你可以将这些各自分离的函数统一起来,并通过参数来处理那些变化情况,用以简化问题。这样的修改可以去除重复的代码,并提高灵活性。
3:以异常取代错误码
某个函数返回一个特定的代码,用以表示某种异常情况,建议改用异常来代替。
当一个子程序发现错误时,它需要让它的调用者知道这个错误,而调用者也可能将这个错误继续沿着调用链传递上去,许多程序都使用特殊输出来表示错误。Unix系统的传统方式就是以返回值表示子程序的成功或失败。
Java有一种更好的错误处理方式:异常。这种方式之所以更好,是因为它清楚地将普通程序跟错误处理分开了,这使得程序更容易理解。
处理概括关系
1:字段上移
如果各子类是分别开发的,或者是在重构过程中组合起来的,你常会发现它们用于重复特性,特别是字段更容易重复。这样的字段有时拥有近似的名字,但也并非绝对如此。
判断字段是否重复,唯一的方法就是观察函数如何使用它们,如果他们被使用的方法很类似,你就可以讲它们归纳到超类去。
2:函数上移
避免行为重复是很重要的,尽管重复的两个函数也可以各自工作得很好,但重复自身只会成为错误的滋生地,此外别无价值。
无论何时,只要系统之内出现重复,你就会面临“修改其中一个却未能修改另一个”的风险,通常,找出重复也有一定困难。
如果某个函数在各子类的函数体都相同,那么可以将此函数移动到超类。
3:函数下移
超类中的某个函数只与部分(而非全部)子类有关,建议将这个函数移到相关的那些子类。
4:字段下移
超类中的某个字段只与部分(而非全部)子类有关,建议将这个字段移动到相关的那些子类。
5:提炼超类
重复代码的某种形式就是:两个类以相同的方式做类似的事情,或者以不同的方式做类似的事情。对象提供了一种简化这种情况的机制,那就是继承。
6:提炼接口
若干客户使用类接口的统一子集,或者两个类的接口有部分相同,建议将相同的子集提炼到一个独立接口中。
7:塑造模板函数
你有一些子类,其中相应的某些函数以相同顺序执行类似的操作,但各个操作上的细节上有所不同,建议将这些操作分别放到独立函数中,并保持它们都有相同的签名,于是原函数也变得相同了,然后将原函数移动到超类。
8:以委托代替继承
某个子类只使用超类接口中的一部分,或是根本不需要继承而来的数据。建议在子类中新建一个字段用来保存超类,调整子类函数,令它改为委托超类,然后去掉两者之间的继承关系。
参考资料
《重构》