跳至主要內容

重构

zheng大约 26 分钟设计模式重构

1、什么是重构

	在百度百科里给出的定义是:在不改变软件系统外部行为的前提下,改善它的内部结构。通过调整程序代码改善软件的质量、性能,使其程序的设计模式和架构更趋合理,提高软件的扩展性和维护性。

	也许有人会问,为什么不在项目开始时多花些时间把设计做好,而要以后花时间来重构呢?

	首先要知道一个完美得可以预见未来任何变化的设计,或一个灵活得可以容纳任何扩展的设计是不存在的。系统设计人员对即将着手的项目往往只能从大方向予以把控,而无法知道每个细枝末节。

	其次永远不变的就是变化,提出需求的用户往往要在软件成型后,才开始"品头论足",系统设计人员毕竟不是先知先觉的神仙,功能的变化导致设计的调整再所难免。

	所以"测试为先,持续重构"作为良好开发习惯被越来越多的人所采纳,测试和重构像黄河的护堤,成为保证软件质量的法宝

2、软件质量因素的定义

正确性(Correctness):系统满足规格说明和用户目标的程度,即在预定环境下能正确地完成预期功能的程度

健壮性(Robustness):在硬件发生故障、输入的数据无效或操作错误等意外环境下,系统能做出适当响应的程度

效率(Efficiency):为了完成预定的功能,系统需要的计算资源的多少

完整性(Efficiency)或安全性(Security):对未经授权的人使用软件或数据的企图,系统能够控制(禁止)的程度

可用性(Usability):系统在完成预定应该完成的功能时令人满意的程度

风险(Risk):按预定的成本和进度把系统开发出来,并且为用户所满意的概率

可理解性(Comprehensibility):理解和使用该系统的容易程度

可维修性(Maintainability):诊断和改正在运行现场发现的错误所需要的工作量的大小

灵活性(Maintainability)或适应性(Adaptability):修改或改进正在运行的系统需要的工作量的多少

可再用性(Reusability):在其他应用中该程序可以被再次使用的程度(或范围)

可移植性(Portability):把程序从一种硬件配置和(或)软件系统环境转移到另一种配置和环境时,需要的工作量多少。有一种定量度量的方法是:用原来程序设计和调试的成本除移植时需用的费用

互运行性(Interoperability):把该系统和另一个系统结合起来需要的工作量的多少

重构的目的就是为了保证软件满足以上特性。

3、重构的意义

	在不改变系统功能的情况下,改变系统的实现方式。为什么要这么做?投入精力不用来满足客户关心的需求,而是仅仅改变了软件的实现方式,这是否是在浪费客户的投资呢?

	重构的重要性要从软件的生命周期说起。软件不同与普通的产品,他是一种智力产品,没有具体的物理形态。一个软件不可能发生物理损耗,界面上的按钮永远不会因为按动次数太多而发生接触不良。那么为什么一个软件制造出来以后,却不能永远使用下去呢?

	对软件的生命造成威胁的因素只有一个:需求的变更。一个软件总是为解决某种特定的需求而产生,时代在发展,客户的业务也在发生变化。有的需求相对稳定一些,有的需求变化的比较剧烈,还有的需求已经消失了,或者转化成了别的需求。在这种情况下,软件必须相应的改变,考虑到成本和时间等因素,当然不是所有的需求变化都要在软件系统中实现。但是总的说来,软件要适应需求的变化,以保持自己的生命力。

	软件产品最初制造出来,是经过精心的设计,具有良好架构的。但是随着时间的发展、需求的变化,必须不断的修改原有的功能、追加新的功能,还免不了有一些缺陷需要修改。为了实现变更,不可避免的要违反最初的设计构架。经过一段时间以后,软件的架构就千疮百孔了。bug越来越多,越来越难维护,新的需求越来越难实现,软件的构架对新的需求渐渐的失去支持能力,而是成为一种制约。最后新需求的开发成本会超过开发一个新的软件的成本,这就是这个软件系统的生命走到尽头的时候。重构就能够最大限度的避免这样一种现象。系统发展到一定阶段后,使用重构的方式,不改变系统的外部功能,只对内部的结构进行重新的整理。通过重构,不断的调整系统的结构,使系统对于需求的变更始终具有较强的适应能力。

4、重构实例演示

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

为了实现这个功能。我们编写出了以下代码(三个类:Movie、Rental、Consumer):

1618675966507
1618675966507
1618675987731
1618675987731
1618676025236
1618676025236

存在的问题:

(1)对于consumer里面的statement方法。
这个方法做的事情太多,如果用户希望对系统做一点修改,首先他们希望以html格式 输出详单,这样可在网页上直接显示。这个变化的影响是:根本不可能在打印html报表的函数中复用目前statement的任何行为。唯一可以做的就是编写一个全新的htmlStatement 大量重复statement的行为。如果计费标准发生变化必须同时修改statement和htmlstatement, 不断的修改和不断的复制粘贴,在程序要保存很长时间时,造成潜在的威胁.

(2)如果用户希望改变影片分类规则,但还未决定怎么改,他们设想几种方案。 这些方案都会影响消费和积分的计算方式。为了应付分类规则和计费规则的变化,程序不得不对statement做出修改,但是如果我们把statement内的代码,复制到htmlstatement函数中,就必须确保将来的任何修改在两个地方保持一致,随着各种规则变得愈来愈复杂,适当的修改点越来越难找,不犯错的机会也越来越少。

(3)你的态度也行倾向于尽量少修改程序,不管怎么说。它运行的很好。你心里牢牢记着那句古老的工程谚语:如果它没坏。就不要动它 也行这个程序还没坏,但是它造成了伤害,它让你的生活比较难过,因为你发现很难完成客户所需的修改。你发现自己需要为程序添加一个特性。而代码结构使你无法方便的达到目的,那就先重构那个程序。 重构,真的是可以锻炼自己思维和代码的编写能力

Step1、重构第一步-可靠的测试

01 进行重构时,我们需要依赖测试,让它告诉我们是否引入bug。好的测试是重构的根本

02 重构的前提是要有一个可靠的测试,这个测试必须有自我检验能力

Step2、重构第二步-分解重组statement 找出代码的逻辑泥团并运用Extract Method

这个函数太长了,代码块越小,代码的功能越好管理。代码的处理和移动也越轻松
代码重构目标:希望将长长的函数切开,把较小的块移动到更合适的类中,最终能够降低代码重复和扩展
将 switch这段逻辑泥团抽离为函数。
在分析函数内的局部变量和参数,其中statement() while循环中有两个: thisAmount、each, thisAmount会被修改,each不会被修改。
任何不会被修改的变量都可以被当成参数传入新的函数
注意每次调整都要编译测试
重构技术就是以 微小 的步伐修改程序,如果你犯下错误,很容易发现它

1618676190337
1618676190337

Step3、重构第三步-更改amountFor中的变量名

好的代码应该清楚表达出自己的功能,变量名称是代码清晰的关键。
任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。
代码应该表现自己的目的
随着对程序的理解逐渐加深,我也就不断的把这些理解嵌入到代码中,这么一来才不会遗忘我曾经理解的东西

1618676232016
1618676232016

Step4、重构第四步-搬移金额计算代码

观察amountFor时,发现这个函数没有使用来自Consumer类的信息,使用了来自Rental类的信息。
所以应该改这段代码搬移到Rental类。并且做相应的调整:更改方法名、参数等 更改调用处
运用Replace Temp with Query把thisAmount除去

1618676280257
1618676280257

第一步:先将计算金额代码搬移到Rental类中

Rental类中添加方法 getCharge

第二步:针对搬移后的代码,调整Consumer类

Step5、重构第五步-运用Extract Method 参考抽取计算金额,来抽取积分

1618676358941
1618676358941

第一步:将积分计算方法搬移到Rental类中

第二步:2、更改consumer类中获取积分

Step6、重构第六步-去掉临时变量

临时变量只在自己所属的函数中有效,所以它们会助长冗长而复杂的函数。
运用Replace Temp with Query,并利用查询函数(query method)来取代totalAmount和frequentRentalPoints这两个临时变量。
由于类中的任何函数都可以调用上述查询函数,所以它能够促成较干净的设计,而减少冗长复杂的函数。

1618676411155
1618676411155

第一步:totalAmount和frequenRenterPoint两个临时变量

第二步:使用查询函数来替代

Step7、重构第七步-运用多态取代与价格相关的条件逻辑

对于switch语句,最好不要在另一个对象的属性基础上运用switch语句。如果不得不使用。也应该在对象自己的数据上使用而不是在别人的数据上使用。 这暗示getCharge应该移动到Movie中
租期的长度来自Rental对象,计算费用的时候需要两项数据:租期长度和影片类型
为什么选择租期长度呢。因为本系统可能发生变化是加入新影片类型。这种变化带有不稳定性倾向
如果影片类型发生变化,我希望尽量控制它造成的影响。所以在Movie对象中计算费用

1618676461026
1618676461026

Step8、重构第八步-运用多态取代与价格相关的条件逻辑

用多态替换Switch,如果创建三个子类继承Movie,调用方就必须创建具体的子类对象(违反依赖倒置原则)。
一个对象具有状态,并且不同状态下有不同的行为,引入State模式:
创建接口Price作为Movie的属性,接口方法getCharge(int daysRented),再创建三个实现类,把Switch分支的逻辑移至具体的实现类
依赖倒置原则,调用方应该依赖抽象类或接口,不要依赖具体实现类

创建price接口 将getPriceCode、getCharge getFrequentRenterPoint抽象出来

1618676506490
1618676506490
1618676526483
1618676526483

创建子类继承Price 实现具体的实现

5、总结

1、每个方法只做一件事,每个方法抽象层级不能多于两层,根据这个原则抽取方法。

2、根据类的职责和对象之间的依赖关系,把方法移至对应的类。

3、应该调用对象的接口方法,不要直接操作对象的属性。

4、尽量减少方法中的临时变量,简化逻辑,增加可读性。

6、重构的时机

(1)、什么时候重构

三次法则:事不过三,三则重构
添加功能时重构(New Feature)
代码的设计无法帮助我轻松的添加我所需要的特性,如果用某种方式来设计,添加特性会简单的多。一旦完成重构,新特性的添加会更快速,更流畅
修补错误时重构(Bug Fix)
调试过程中,运用重构,多半是为了让代码更具有可读性
复审代码时重构(Code Review)重构可以帮助我们复审代码

(2)、什么时候不重构

既有代码太混乱,且不能正常工作,需要重写而不是重构。
项目接近最后期限时,应该避免重构。

7、重构的手段

(1)、改善重复代码

	重复的代码是坏味道中出现频率最高的情形非其莫属。如果在一个的以上地方看到相同的代码,那么就可以肯定:想办法将它们合而为一,代码会变得更好。最单纯的重复代码就是“同一个类的两个函数含有相同的表达式”,这时候可以采用抽取方法提炼出重复的代码,然后让这两个地点都调用被提炼出的那一段代码。

	另一种常见情况就是“两个互为兄弟的子类内含相同的表达式”,这时候只需对两个类抽取方法,然后将提炼出的代码推入到超类中。如果代码之间只是类似而并非完全相同,那么就需要通过抽取方法将相似部分和差异部分分开,构成单独一个函数。如果有些函数以不同的算法做相同的事,可以使用比较清晰的一个替换掉其余的。

(2)、改善过长的函数、过大的类、 过长的参数列

	程序员都喜欢简短的函数。拥有短函数的对象会活的比较好、比较长。不熟悉面向对象技术的人,常常觉得对象程序中只有无穷无尽的委托,根本没有进行任何计算。和此类程序共同生活数年后,你才会知道这些小小函数的价值。

	应该积极地分解函数,将长长的函数变为多个短小的函数。一般会遵循这样的原则:每当感觉需要用注释来说明点什么的时候,就把需要说明的东西写进一个独立函数中,并以其用途命名。不要嫌麻烦。可以对一组甚至短短一行代码做这件事,哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释其用途,也应毫不犹豫地那么做。关键不在于函数的长度,而在于函数“做什么”和“如何做”之间的语义距离。
	如果想利用单个的类做太多的事情,其内往往会出现太多实例变量。一旦如此,重复的代码就接踵而来。可以将几个变量一起提炼至新类内。提炼时应该选择类内彼此相关的变量,将它们放在一起。通常如果类内的数个变量有着相同的前缀或字尾,这就意味有机会把它们提炼到某个组件内。和“太多实例变量”一样,类内如果有太多代码,也是代码重复、混乱并最终走向死亡的源头。最简单的方案是把多余的东西消弭于类内部。如果有五个“百行函数”,它们之中很多代码都相同,那么或许你可以把它们变成五个“十行函数”和十个提炼出的“双行函数”。

	刚开始学编程的时候,或许都是“把函数所需的所有东西都以参数传递进去”。这样也是可以理解的,因为除此之外就只能选择全局数据,而全局数据是邪恶的东西。对象技术告诉我们,如果你手上没有所需的东西,总可以叫一个对象给你。有了对象,你就不必要把函数所需的所有东西都以参数传递给它,只需传给它足够的、让函数能从中获得自己的东西就行。太长的的参数列难以理解,太多参数会造成前后不一致、不易使用,而且一旦需要更多数据,就不得不修改它。如果将对象传递给函数,大多数修改都将没有必要,因为很可能只需增加一两条请求,就能得到更多的数据。

(3)、发散式变化

	我们希望软件能够容易被修改——毕竟软件再怎么说本来就该是“软”的。一旦需要修改,我们希望能够跳到系统某一点,只在该处做修改。如果不能做到这点,你就会嗅出两种紧密相关的刺鼻味道中的一种。如果某个类经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。其主要指“一个类受多种变化的影响”。当你看着一个类说:“呃,如果新加入一个数据库,就必须修改这三个函数;如果新出现一种工具,就必须修改这四个函数。”那么此时也许将这个对象分成两个会更好,这样对每个对象就可以只因一种变化而需要修改因为不同的原因,在不同的方向上,修改同一个类。应该分解成更小的类,每个类只因一种原因而修改。多层结构系统,开发人员往往容易把全部逻辑都放在Service层,导致Service类非常庞大且不断被修改。

(4)、霾弹式修改

	如果每遇到变化,都必须在许多不同的类内做出许多小修改,你所面临的坏味道就是霾弹式修改。其主要指“一种变化引发多个类相应修改”。如果需要修改的代码散布四周,不但很难找到它们,也很容易忘记某个重要的修改。这种情况可以把所有需要的代码放进同一个类。如果眼下没有合适的类可以安置这些代码,就创造一个。通常可以运用内联类把一系列相关行为放进同一个类。

(5)、 依恋情节

	众所周知,对象技术的全部要点在于:其是一种“将数据和对数据的操作行为包装在一起”的技术。有一种经典的气味:函数对于某个类的兴趣高过对自己所处类的兴趣。在很多情况下,都能够看到:某个函数为了计算某个值,从另一个对象那儿调用几乎半打的取值函数。疗法也显而易见:把这个函数移至另一个地点,移到它该去的地方。‘有时候一个函数往往会用到几个类的功能,那么它究竟该被置于何处呢?处理原则通常为:判断哪个类拥有最多被此函数使用的数据,然后就把这个函数和那些数据摆在一起。

(6)、数据泥团

	如果用比较形象的事物来形容数据项,我想“小孩子”是一个不错的选择,数据项就像小孩子,喜欢成群结队地呆在一块儿。常常可以在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。这种情况可以先找出这些数据以字段形式出现的地方,将它们提炼到一个独立对象中,然后将注意力转移到函数签名上,运用参数对象为它减肥。这样做的直接好处是可以将很多参数列缩短,简化函数调用。一个比较好的评判方法是:删掉众多数据中的一项。这么做其它数据有没有因而失去意义?如果它们不再有意义,这就是一个明确的信号:应该为它们产生一个新对象。

(7)、基本类型偏执

	大多数编程环境都有两种数据:结构类型允许你将数据组织成有意义的形式;基本类型则是构成结构类型的积木块。但是请记住:结构总是会带来一定的额外开销。它们可能代表着数据库中的表,如果只为做一两件事而创建结构类型也可能显得很麻烦。 对象的一个极大价值在于:它们模糊甚至打破横亘于基本数据和体积较大的类之间的界限。如果你有一组应该总是被放在一起的字段,可以将其抽取为一个独立的类。如果你在参数列中看到基本型数据,可以引入参数对象进行处理。如果你发现自己正从数组中挑选数据,可以运用以对象取代数组进行处理。 由一个起始值和一个结束值组成的range类:如果你有大量的基本数据类型字段,就有可能将其中部分存在逻辑联系的字段组织起来,形成一个类。更进一步的是,将与这些数据有关联的方法也一并移入类中 如果你发现自己正从数组中挑选数据,可运用 以对象取代数组。

(8)、Switch惊悚现身

	面向对象程序的一个较明显特征是:少用switch语句。从本质上说,switch语句的问题在于重复。你常会发现同样的switch语句散布于不同的地方。如果要为它添加一个新的case语句,就必须找到所用switch语句并修改它们。面向对象中的多态概念可为此带来优雅的解决办法。大多数时候,一看到switch语句,那就应该考虑以多态来替换它。switch语句常常根据类型码进行选择,你要的是“与该类型码相关的函数或类”,所以应该将switch语句提炼到一个独立函数中,再将它搬移到需要多态性的那个类里。

(9)、平行继承体系

	平行继承体系其实是霾弹式修改的特殊情况。在这种情况下,每当为某个类增加一个子类,必须也为另一个类增加一个子类。如果发现某个继承体系的类名称前缀和另一个继承体系的类名称前缀完全相同,这种坏味道就会被嗅出。
	消除这种重复性的一般策略是:让一个
	继承体系的实例引用另一个继承体系的实例。
1618677203153
1618677203153

(10)、冗赘类

	你所创建的每一个类,都得有人去理解它、维护它,这些工作都是需要花钱的。如果一个类的所得并不值其身价,他就应该消失。项目中经常会出现这样的情况:某个类原本对得起自己的价值,但重构使它身形缩水,不再做那么多工作;或开发者事先规划了某些变化,并添加一个类来应付这些变化,但变化实际没有发生。不管是哪种原因,都应该让这个类庄严赴义吧。如果某些子类并没有做足够的工作,我们可以尝试“折叠继承体系”,将超类和子类合为一体,那样就会减少维护时间。对于那些几乎没用的组件,就应该将这个类的所有特性搬移到另一个类中,然后移除原类。

(11)、夸夸其谈未来性

	我们经常会说:“我想总有一天需要做这事”,并因而企图以各样的钩子和特殊情况来处理一些非必要的事情。一旦这样,坏味道就浮现出来了。夸夸其谈未来的结果往往会造成系统更加难以理解和维护。如果所有的装置都被用到了,那就值得那么做;如果用不到,就不值得。用不上的装置只会阻挡你的路,给你添乱,那就搬开它吧。如果某个抽象类其实没有太大作用,可以将超类和子类合为一体。将不必要的委托转移到另一个类中,并消除原先的类。如果函数的某些参数未被用上,那么就将参数移走。如果函数名称带有多余的抽象意味,就应该对它重命名,让它现实一些。

(12)、令人迷惑的暂时字段

	有时候你会发现:类中的某个实例变量仅为某种特定情况而设。这样的代码让人难以理解,因为你通常认为对象在所有时候都需要它的所有变量。当变量在未被使用的情况下去猜测其当初设置的目的,会让你发疯的。可以使用提炼新类为这个可怜的孤儿创造一个家,然后把所有和这个变量相关的代码都放进这个新家。也许还可以使用“将Null值替换为Null对象”在“变量不合法”的情况下创建一个Null对象,从而避免写出条件式代码。

(13)、 过度耦合的消息链

	如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象.....这就是消息链。这种方式意味着客户代码将与某些功能函数中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。 这时候我们可以隐藏“委托关系”,并在服务类上建立客户所需要的所有函数。你可以在消息链的不同位置进行这种重构手法。理论上是可以重构消息链上的任何一个对象,但是这样做往往会把一系列对象都变成“中间人”。通常更好的选择是:先观察消息链最终得到的对象是用来干什么的,再看看能否通过抽取方法把使用该对象的代码提炼到一个独立函数中,然后再将这个函数推入消息链。 
String result=Class.getFile().getFileChannel().getFileSource().getFileName() 

String result=Class.getFile().getFileName(); 
	常常是因为数据结构的层次很深,需要层层调用getter获取内层数据。 个人认为Message Chains如果频繁出现,考虑这个字段是否应该移到较外层的类,或者把调用链封装在较外层类的方法。

(14)、中间人

	我们都知道对象的基本特征之一就是封装——对外部世界隐藏其内部细节。封装往往伴随着委托。比如你对Boss说是否有时间参加一个会议,他把这个消息“委托”给他的记事本,然后才能回答你。但是,你没有必要知道Boss到底使用传统记事本或电子记事本亦或秘书来记录自己的约会。人们可能会过度使用委托。你也许会看到某个类接口中有一半的函数都委托给其它类,这样就是过度委托。这时候就应该移除中间人,直接和真正的负责人打交道。如果这样“不干实事”的函数只有少数几个,可以将它们放进调用端。如果中间人还有其它行为,可以把它变成实责对象的子类,这样你既可以扩展原对象的行为,又不必负担那么多的委托动作。

(15)、狎昵关系

	有时候你会看到两个类过于亲密,花费太多时间去探究彼此的private成分。如果这发生在两个“人”之间,我们无比做卫道士;但对于类,我们就希望它们严守清规。也许就像古代的恋人一样,过分狎昵的类必须拆散。可以通过“移动方法”和“移动字段”帮它们划清界限,从而减少狎昵行径。如果两个类实在是情投意合,可以把两者共同点提炼到一个安全地点,让它们坦荡地使用这个新类。或者通过隐藏“委托关系”让另一个类来为它们传递相思情。将双向关联改为单向关联提炼类,将两个类的共同点提炼到新类中,让它们共同使用新类
上次编辑于:
贡献者: 郑天祺