代码重构技术学习
重构之前,首先检查自己是否有一套可靠的测试机制。这些测试必须有自我检验(self-checking)能力。
分解并重组函数·代码区块愈小,代码的功能就愈容易管理,代码的处理和搬移也都愈轻松。分解重组后可利用率也高。
重构技术系以微小的步伐修改程序,一步一步重构。如果你犯错误,很容易便可发现它。
好的代码应该清楚表达出自己的功能,变量名称是代码清晰的关键。
绝大多数情况下,函数应该放在它所使用的数据的所属object(或说class)内,
有时候我会保留旧函数,让它调用新函数。如果旧函数是一个public函数,而我又不想修改其它class的接口,这便是一种有用的手法。
「重构」改进软件设计
同样完成一件事,设计不良的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事。
代码数量减少并不会使系统运行更快,因为这对程序的运行轨迹几乎没有任何明显影响。然而代码数量减少将使未来可能的程序修改动作容易得多。
「重构」使软件更易被理解
重构可以帮助我们让代码更易读。一开始进行重构时,你的代码可以正常运行,但结构不够理想。在重构上花一点点时间,就可以让代码更好地表达自己的用途。这种编程模式的核心就是「准确说出你的意思」
我是个很懒惰的程序员,我的懒惰表现形式之一就是:总是记不住自己写过的代码。事实上对于任何立可查阅的东西我都故意不去记它,因为我怕把自己的脑袋塞爆。
一开始我所做的重构都像这样停留在细枝末节上。随着代码渐趋简洁,我发现自己可以看到一些以前看不到的设计层面的东西。
RalphJohnson把这种「早期重构」描述为「擦掉窗户上的污垢,使你看得更远」。研究代码时我发现,重构把我带到更高的理解层次上。如果没有重构,我达不到这种层次。
「重构」助你找到臭虫(bugs)
「重构」助你提高编程速度
良好设计是快速软件开发的根本。事实上拥有良好设计才可能达成快速的开发。如果没有良好设计,或许某一段时间内你的进展迅速,但恶劣的设计很快就让你的速度慢下来。你会把时间花在调试上面,无法添加新功能。修改时间愈来愈长,因为你必须花愈来愈多的时间去理解系统、寻找重复代码。随着你给最初程序打上一个又一个的补㆜(patch),新特性需要更多代码才能实现。真是个恶性循环。
几乎任何情况下我都反对专门拨出时间进行重构。在我看来,重构本来就不是一件「特别拨出时间做」的事情,重构应该随时随地进行。你不应该为重构而重构,你之所以重构,是因为你想做别的什么事,而重构可以帮助你把那些事做好。
程序有两面价值:「今天可以为你做什么」和「明天可以为你做什么」。大多数时候,我们都只关注自己今天想要程序做什么。不论是修复错误或是添加特性,我们都是为了让程序能力更强,让它在今天更有价值。
如果你「为求完成今天任务」而采取的手法使你不可能在明天完成明天的任务,那么你还是失败。但是,你知道自己今天需要什么,却不一定知道自己明天需要什么。也许你可以猜到明天的需求,也许吧,但肯定还有些事情出乎你的意料。
难以阅读的程序,难以修改。逻辑重复(duplicated logic)的程序,难以修改。添加新行为时需修改既有代码者,难以修改。带复杂条件逻辑(complex conditional logic)的程序,难以修改。
但是,间接层是一柄双刃剑。每次把一个东西分成两份,你就需要多管理一个东西。如果某个对象委托(delegate)另一对象,后者又委托另一对象,程序会愈加难以阅读。基于这个观点,你会希望尽量减少间接层。
简言之,如果重构手法改变了已发布接口(published interface),你必须同时维护新旧两个接口,直到你的所有用户都有时间对这个变化做出反应
让旧接口继续工作。请尽量这么做:让旧接口调用新接口。当你要修改某个函数名称时,请留下旧函数,让它调用新函数。千万不要拷贝函数实现码,那会让你陷入「重复代码」(duplicated code)的泥淖中难以自拔。你还应该使用Java提供的deprecation(反对)设施,将旧接口标记为"deprecated"。这么一来你的调用者就会注意到它了。
不要过早发布(published)接口。请修改你的代码拥有权政策,使重构更顺畅。
发布接口指的是 封装代码成库 无法修改,如C# DLL
我们很难(但还是有可能)将「无安全需求(nosecurity requirements)情况下构造起来的系统」重构为「安全性良好的(good security)系统」。
何时不该重构?
有时候你根本不应该重构一例如当你应该重新编写所有代码的时候。有时候既有代码实在太混乱,重构它还不如重新写一个来得简单。
重写(而非重构)的一个清楚讯号就是:现有代码根本不能正常运作。你可能只是试着做点测试,然后就发现代码中满是错误,根本无法稳定运作。记住,重构之前,代码必须起码能够在大部分情况下正常运作。
另外,如果项目已近最后期限,你也应该避免重构。在此时机,从重构过程赢得的生产力只有在最后期限过后才能体现出来,而那个时候已经时不我予。
很多公司都需要借债来使自己更有效地运转。但是借债就得付利息,过于复杂的代码所造成的「维护和扩展的额外开销」就是利息。你可以承受一定程度的利息,但如果利息太高你就会被压垮。把债务管理好是很重要的,你应该随时通过重构来偿还一部分债务。
人都把设计看作软件开发的关键环节,而把编程(programming)看作只是机械式的低级劳动。他们认为设计就像画工程图而编码就像施工。但是你要知道,软件和真实器械有着很大的差异。软件的可塑性更强,而且完全是思想产品。正如AlistairCockburn所说:『有了设计,我可以思考更快,但是其中充满小漏洞。』
有一种观点认为:重构可以成为「预先设计」的替代品。这意思是你根本不必做任何设计,只管按照最初想法开始编码,让代码有效运作,然后再将它重构成型。事实上这种办法真的可行。运用重构也能收到效果,但这并不是最有效的途径。是的,即使极限编程(Extreme Programming)爱好者也会进行预先设计。他们会使用CRC卡或类似的东西来检验各种不同想法,然后才得到第一个可被接受的解决方案,然后才能开始编码,然后才能重构。
由于变更设计的代价非常高昂,所以我希望建造一个足够灵活、足够强固的解决方案,希望它能承受我所能预见的所有需求变化。问题在于:要建造一个灵活的解决方案,所需的成本难以估算。灵活的解决方案比简单的解决方案复杂许多,所以最终得到的软件通常也会更难维护一虽然它在我预先设想的方向上的确是更加灵活。
如果在所有可能的变化出现地点都建立起灵活性,整个系统的复杂度和维护难度都会大大提高。当然,如果最后发现所有这些灵活性都毫无必要,这才是最大的失败。
哪怕你完全了解系统,也请实际量测它的性能,不要臆测。臆测会让你学到一些东西,但十有八九你是错的
重构与性能(Performance)。
我看过㆔种「编写快速软件」的方法。其中最严格的是「时间预算法」给每个组件预先分配一定资源一包括时间和执行轨迹(footprint)。每个组件绝对不能超出自己的预算,就算拥有「可在不同组件之间调度预配时间」的机制也不行。这种方法高度重视性能,对于心律调节器一类的系统是必须的,因为在这样的系统中迟来的数据就是错误的数据。
第㆓种方法是「持续关切法」(constant attention)。这种方法要求任何程序员在任何时间做任何事时,都要设法保持系统的高性能。这种方式很常见,感觉上很有吸引力,但通常不会起太大作用。任何修改如果是为了提高性能,通常会使程序难以维护,因而减缓开发速度。
性能优化阶段一那通常是在开发后期。在性能优化阶段中,你首先应该以一个量测工具监控程序的运行,让它告诉你程序中哪些地方大量消耗时间和空间。集中关切这些性能热点,如果没能提高性能,就应该撤销此次修改。
坏代码的味道
重复代码-炼出重复的代码,然后让这两个地点都调用被提炼出来的那一段代码。须决定这个函数放在哪儿最合适,并确保它被安置后就不会再在其它任何地方出现。
LongMethod(过长 函数)
-愈长愈难理解,应该由各个小型函数组拼成一个函数,早期的编程语言中,「子程序调用动作」需要额外开销,这使得人们不太乐意使用small method。现代OO语言几乎已经完全免除了进程(process)内的「函数调用动作额外开销」
最终的效果是:你应该更积极进取地分解函数。我们遵循这样一条原则:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。
关键不在于函数的长度,而在于函数「做什么」和「如何做」之间的语义距离。
如何确定该提炼哪一段代码呢?一个很好的技巧是:寻找注释。它们通常是指出「代码用途和实现手法间的语义距离」的信号。如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名。就算只有一行代码,如果它需要以注释来说明,那也值得将它提炼到独立函数去。
Large Class(过大类)
一个类文件如果代码过于多,也不好阅读 可以将类中代码按照模块 分成多个文件,或者提炼到其他类或子类中
LongParameterList(过长参数列)
可以构建类,实例化类对象作为参数传递 有了对象,就不必把函数需要的所有东西都传递给它了,只需传给它足够的东西、函数自己找需要的所有东西就行了。
Data Clumps(数据泥团)-Introduce Parameter Object(引入参数对象)
对于函数参数列多的 我们可以把这些参数放进一个类对象中,将类对象替换参数列作为形参传递 。本项重构的价值在于缩短参数列,因为过长的参数总是难以理解。新对象所定义的访问函数还可以使代码更具一致性
Replace Data Value with Object (以对象取代数据值)
有时候我们的数据值可能是一堆信息组拼的,比如一个string字符串包含了 地址 电话 姓名,而我们想单独取出这三个东西,如果取出的操作不知在一处调用,那就造成了代码的重复。使用ReplaceDataValueWithObject可以很好的解决这个问题,只要创建新类,封装这个string,然后在这个类中定义取出各个信息操作的函数。这样各个高层模块依赖的对象类而不是string数据,对象类控制公开想要高层模块知道的知识。
Replace Array with Object(以对象取代数组)
有时候在项目中存在用数组表达各个不同数据项的用法,但是这样的问题就造成了我们的项目的可读性下降,开发者想要取得所要的数据项就要得先知道每个数据项对于的数字索引,这就发生了数据与索引(顺序)强依赖的问题,闻到了臭虫的味道。
Message Chains(过度耦合的消息链)
如果你看到用户向一个对象索求(request)另一个对象,然后再向后者索求另一个对象,然后再索求另一个对象……这就是Message Chain。
可以使用Hide Delegete 隐藏委托关系手段
做法:
使用一个服务类, 服务类去委托低层模块 服务类依赖调用低层模块 开放给客户端,这样一来 如果低层模块发生变化 可能会发生变化的只有一个服务类。
上面这个做法应该叫做门面模式(外观模式)
Middle Man(中间转手人)
就是值=指函数封装某处逻辑,MiddleMan要合理运用 ,人们可能过度运用它。
个人觉得一个函数如果封装逻辑简短,内部代码看起来就非常易懂,比看函数名称同样清晰易读 且认为未来其他的地方不会也要这样同样的代码逻辑,未来不会出现逻辑过多的重复的话,就可能可以使用内联函数的手段,删除该函数,直接将代码写在调用处。
尽量降低两个类之间的亲密关系
Change Bidirectional Association to Unidirectional(去除不必要的关联)让其中一个class对另一个斩断情思。
双向关联(bidirectional associations)很有用,但你也必须为它付出代价,那就是[维护双向链接,确保对象被正确创建和删除]而增加的复杂度.而且,由于很多程序员并不习惯使用双向关联,它往往成为错误之源.
大量的双向连接(two-way links)也很容易引发[僵尸对象]:某个对象本来已经该死亡了,却仍然保留在系统中,因为对它的各项引用还没有完全清除
如果两个classes实在是情投意合,可以运用Extract Class(149)把两者共同点提炼到一个安全地点,让它们坦荡地使用这个新class。或者也可以尝试运用Hide Delegate(157)让另一个class来为它们传递相思情。
继承(inheritance)往往造成过度亲密,因为subclass对superclass的了解总是超过superclass的主观愿望。如果你觉得该让这个孩子独自生活了,请运用ReplaceInheritance with Delegation(352)让它离开继承体系。
IncompleteLibraryClass(不完美的程序库类)
如果你只想修改library classes内的一两个函数,可以运用Introduce Foreign Method(162);如果想要添加一大堆额外行为,就得运用Introduce Local Extension(164)
重构手法16:Introduce Foreign Method (引入外加函数)
你需要为提供服务的类增加一个函数,但你无法修改这个类。在客户类中建立一个函数,并以第一参数形式传入一个服务类实例。
动机:这种事情发生了太多次了,你正在使用一个类,它真的很好,为你提供了需要的所有服务。而后,你又需要一项新服务,这个类却无法供应。于是你开始咒骂“为什么不能做这件事?”如果可以修改源码,你便可以自行添加一个新函数;如果不能,你就得在客户端编码,补足你要的那个函数。
如果客户类只使用这项功能一次,那么额外编码工作没什么大不了,甚至可能根本不需要原本提供服务的那个类。然而,如果你需要多次使用这个函数,就得不断重复这些代码。重复代码是软件万恶之源。这些重复代码应该被抽出来放进一个函数中。进行本项重构时,如果你以外加函数实现一项功能,那就是一个明确信号:这个函数原本应该在提供服务的类中实现。
如果你发现自己为一个服务类建立了大量外加函数,或者发现有许多类需要同样的外加函数,就不应该再使用本项重构,而应该使用 Introduce Local Extension (引入本地扩展)。
但是不要忘记:外加函数终归是权宜之计。如果有可能,你仍然应该将这些函数搬移到它们的理想家园。如果由于代码所有权的原因使你无法做这样的搬移,就把外加函数交给服务类的提供者,请他帮你在服务类中实现这个函数。
做法:1、在客户类中建立一个函数,用来提供你需要的功能。这个函数不应该调用客户类的任何特性。如果它需要一个值,把该值动作参数传给它。
2、以服务类实例作为该函数的第一个参数。
3、将该函数注释为:”外加函数“,应在服务类中实现。这么一来,如果将来有机会将外加函数搬移到服务类时,你便可以轻松找出这些外加函数。。
Introduce Local Extension (引入本地扩展)。
技术一子类化(subclassing)和包装(wrapping)是显而易见的办法。这种情况下,把子类化和包装类统称为本地扩展。
所谓本地扩展是一个独立的类,但也是被扩展类的字类型:它提供源类的一切特性,同时额外添加新特性。在任何使用源类的地方,你都可以使用本地扩展取而代之。
数据类
Encapsulate Field
Encapsulate Collection
Data Class就像小孩子。作为一个起点很好,但若要让它们像「成年(成熟)」的对象那样参与整个系统的工作,它们就必须承担一定责任。
Refused Bequest(被拒绝的遗赠)
subclasses应该继承superclass的函数和数据。但如果它们不想或不需要继承,又该怎么办呢?它们得到所有礼物,却只从中挑选几样来玩!
按传统说法,这就意味继承体系设计错误。你需要为这个subclass新建一个兄弟(sibling class),再运用Push Down Method(328)和Push Down Field(329)把所有用不到的函数下推给那兄弟。
如我们的子类不想继承父类已有的某个接口,我们需要考虑继承体系设计是否错误,该类该不该继承,我们应该考虑委托代替继承 。
继承代替委托
受托对象被不止一个其他对象共享,而且受托对象是可变的。在这种情况下,你就不能将委托关系替换为继承关系,因为这样就无法再共享数据了。数据共享是必须由委托关系承担的一种责任,你无法把它转给继承关系。如果受托对象是不可变,数据共享就不成问题,因为你大可以放心赋值对象。
class应该包含它们自己的测试代码。
每个class都应该有一个测试函数,并以它来测试自己这个class
确保所有测试都完全自动化,让它们检查自己的测试结果。
Split Temporary Variable(分解临时变量)
这种临时变量应该只被赋值一次。如果它们被赋值超过一次,就意味着它们在函数中承担了一个以上的责任。如果临时变量承担多个责任,它就应该被替换(分解)为多个临时变量,每个变量只承担一个责任。同一个临时变量承担两件不同的事情,会使代码阅读者糊涂。
Replace Temp with Query(120)
我的理解就是将原函数中临时变量的计算过程 用单独函数封装,需要时候就调用这个函数得到计算结果。
关于性能问题解决的想法:是不是可以构建一个结构块 动手写下代码后 发现需要定义多个泛型参数的类,会有最多的类型个数限制。
class Query<T,T1~T6,TResult>:
Func<T,T1~T6,TResult> getResultFunc;
TResult? result;
public T GetResult(T,T1~T6)
{
if(result?.hasValue==false)
result=getResultFunc(arg);
return result;
}
在Query函数中 定义Query类对象 声明泛型 并调用GetResult函数 得到计算结果。
Remove Assignments to Parameters
首先,我要确定大家都清楚「对参数赋值」这个说法的意思。如果你把一个名为foo的对象作为参数传给某个函数,那么「对参数赋值」意味改变foo,使它引用(参考、指涉、指向)另一个对象。如果你在「被传入对象」身上进行什么操作,那没问题,我也总是这样干。我只针对「foo被改而指向(引用)完全不同的另一个对象」这种情况来讨论:void aMethod(Object foo) {foo.modifyInSomeWay();// that's OKfoo = anotherObject;// trouble and despair will follow you我之所以不喜欢这样的作法,因为它降低了代码的清晰度,而且混淆了pass by value(传值)和pass by reference(传址)这两种参数传递方式。Java只采用pass by value传递方式(稍后讨论),我们的讨论也正是基于这一点。
Replace Method with Method Object(135)
会将所有局部变量都变成函数对象(method object)的值域(field)。然后你就可以对这个新对象使用Extract Method(110)创造出新函数,从而将原本的大型函数拆解变短。
作法(Mechanics)
我厚着脸皮从Kent Beck [Beck]那里偷来了下列作法:
建立一个新class,根据「待被处理之函数」的用途,为这个class命名。在新class中建立一个final值域,用以保存原先大型函数所驻对象。我们将这个值域称为「源对象」。同时,针对原(旧)函数的每个临时变量和每个参数,在新class中建立一个个对应的值域保存之。在新class中建立一个构造函数(constructor),接收源对象及原函数的所有参数作为参数。在新class中建立一个compute()函数。将原(旧)函数的代码拷贝到compute()函数中。如果需要调用源对象的任何函数,请以「源对象」值域调用。编译。将旧函数的函数本体替换为这样一条语句:「创建上述新class的一个新对象,而后调用其中的compute()函数」。现在进行到很有趣的部分了。由于所有局部变量现在都成了值域,所以你可以任意分解这个大型函数,不必传递任何参数。