What:何为重构
使用一系列重构准则,在不改变「软件之可察行为」前提下,优化代码结构。
软件开发的时间会分配给两种行为:添加新功能和重构。
在添加新功能时,不应该修改既有代码,直观添加新功能,通过测试。
而重构时,你就不能再添加新功能,只管改进程序结构,而尽量不修改测试case。
重构是这样一个过程:它在一个目前可运行的程序上进行,企图在「不改变程序行为」的情况下赋予上述美好性质,使我们能够继续保持高速开发,从而增加程序的价值。
Why:为何重构
缺乏对过程的精心设计与必要投入,只抱着对结果的美好憧憬提刀上阵,遇到困难就靠“奋斗精神”和加班解决,这种“刀劈斧砍”不止发生在缺乏审慎的“重构”现场,又何尝不是我们这个行业的缩影?
程序有两面价值:「今天可以为你做什么」和「明天可以为你做什么」。大多数时候,我们都只关注自己今天想要程序做什么。不论是修复错误或是添加特性,我们都是为了让程序力更强,让它在今天更有价值。对于今天的工作,我了解得很充分:对于明天的工作,我了解得不够充分。但如果我纯粹只是为今天工作,明天我将完全无法工作。好代码的检验标准是人们能否轻而易举修改它。
「重构」改进软件设计
「重构」使软件更易被理解
「重构」助你找到Bug
「重构」助你提高编程速度
为什么开发者不愿意重构他们的程序?有几个可能的原因:
你不知道如何重构。
如果这些利益是长远才展现的,何必现在付出这些努力呢?长远看来,说不定当项目收获这些利益时,你已经不在职位上了。
代码重构是一项额外工作,老板付钱给你,主要是让你编写新功能。
重构可能破坏现有程序。
重构的重要性
拿我们都非常熟悉的一件事来做个比喻吧:我们的身体健康状况。
从很多角度来说,重构就好像运动、吃适当的食物。许多人都知道:我们应该多锻炼身体,应该注意均衡饮食。有些人的生活文化中非常鼓励这些习惯,有些人没有这些好习惯也可以混过一段时间,甚至看不出有什么影响。我们可以找各种借口,
但如果一直忽视这些好习惯,那么我们只是在欺骗自己。 有些人之运动和均衡饮食,动机着眼于短期利益(例如精力更充沛、身体更灵活、
自尊心增强……等等)。几乎所有人都知道这些短期利益非常真实。许多人都时断时续做过一些努力,另一些人则是不见棺材不掉泪,不到关键时刻不会有足够动力去做点什么事。
When:何时重构
三次法则:事不过三,三则重构
添加功能时一并重构
修补错误吋一并重构
复审代码吋一并重构
何吋不该重构?
如果还没有彻底理解既有的代码,丑陋的代码能被隐藏在一个API之下,我就可以容忍它继续保持丑陋。只有当我需要理解其工作原理时,对其进行重构才有价值。
另一种情况是,如果重写比重构还容易,就别重构了。
另外,如果项目已近最后期限,你也应该避免重构。在此时机,从重构过程赢得的生产力只有在最后期限过后才能体现出来,而那个时候已经时不我予。把未完成的重构工作形容为「债务」。很多公司都需要借债来使自己更有效地运转。但是借债就得付利息,过于复杂的代码所造成的「维护和扩展的额外开销」就是利息。你可以承受一定程度的利息,但如果利息太高你就会被压垮。把债务管理好是很重要的,你应该随时通过重构来偿还一部分债务。
如果项目己经非常接近最后期限,你不应该再分心于重构,因为己经没有时间了。不过多个项目经验显示:重构的确能够提高生产力。如果最后你没有足够时间,通常就表示你其实早该进行重构。
HOW:如何重构
我们希望程序:
(1)容易理解;
(2)所有逻辑都只在唯一地点指定;
(3)新的改动不会危及现有行为;
(4)尽可能简单表达条件逻辑(conditional logic)。
详见《重构-改善既有代码的设计》第一版,第二版。
重构中常见的重要问题
「该怎么跟经理说重构的事?」
如果这位经理懂技术,那么向他介绍重构应该不会很困难。如果这位经理只对质量感兴趣,那么问题就集中到了「质量」上面。此时,在复审过程中使用重构,就是一个不错的办法。
大量研究结果显示,「技术复审」是减少错误、提高开发速度的一条重要途径。随便找一本关于复审、审査或软件开发程序的书看看,从中找些最新引证,应该可以让大多数经理认识复审的价值。然后你就可以把重构当作「将复审意见引入代码内」的方法来使用,这很容易。
当然,很多经理嘴巴上说自己「质量驱动」,其实更多是「进度驱动」。这种情况下我会给他们一个较有争议的建议:不要告诉经理!
「接口迭代问题」
原则:不要过早发布(publish)接口。修改代码拥有权政策,使重构更顺畅。
很多重构手法不仅会影响一个模块内部,还会影响该模块与系统其他部分的关系。比如我想给一个函数改名,并且我也能找到该函数的所有调用者,那么我只需运用改变函数声明,在一次重构中修改函数声明和调用者。但即便这么简单的一个重构,有时也无法实施:调用方代码可能由另一支团队拥有,而我没有权限写入他们的代码库;这个函数可能是一个提供给客户的API,这时我根本无法知道是否有人使用它,至于谁在用、用得有多频繁就更是一无所知。这样的函数属于已发布接口:接口的使用者(客户端)与声明者彼此独立,声明者无权修改使用者的代码。
代码所有权的边界会妨碍重构,因为一旦我自作主张地修改,就一定会破坏使用者的程序。这不会完全阻止重构,我仍然可以做很多重构,但确实会对重构造成约束。为了给一个函数改名,我需要使用函数改名,但同时也得保留原来的函数声明,使其把调用传递给新的函数。这会让接口变复杂,但这就是为了避免破坏使用者的系统而不得不付出的代价。我可以把旧的接口标记为“不推荐使用”(deprecated),等一段时间之后最终让其退休;但有些时候,旧的接口必须一直保留下去。
由于这些复杂性,我建议不要搞细粒度的强代码所有制。有些组织喜欢给每段代码都指定唯一的所有者,只有这个人能修改这段代码。我曾经见过一支只有三个人的团队以这种方式运作,每个程序员都要给另外两人发布接口,随之而来的就是接口维护的种种麻烦。如果这三个人都直接去代码库里做修改,事情会简单得多。我推荐团队代码所有制,这样一支团队里的成员都可以修改这个团队拥有的代码,即便最初写代码的是别人。程序员可能各自分工负责系统的不同区域,但这种责任应该体现为监控自己责任区内发生的修改,而不是简单粗暴地禁止别人修改。
这种较为宽容的代码所有制甚至可以应用于跨团队的场合。有些团队鼓励类似于开源的模型:B团队的成员也可以在一个分支上修改A团队的代码,然后把提交发送给A团队去审核。这样一来,如果团队想修改自己的函数,他们就可以同时修改该函数的客户端的代码;只要客户端接受了他们的修改,就可以删掉旧的函数声明了。对于涉及多个团队的大系统开发,在“强代码所有制”和“混乱修改”两个极端之间,这种类似开源的模式常常是一个合适的折中。
PS1: 这也是3.0Plus项目初期和中期架构组一直坚持源码依赖的原因,大部分接口变动的情况,是依靠架构组同学直接修改提交使用者的代码来完成的接口迭代,保证快速迭代的同时避免编译报错。
PS2: 这也是3.0Plus项目后期,底层库全部切换为Maven依赖。上ViewFocus功能的时候,被项目要求1天定好ViewFocus的最终接口并宣讲,之后再开发ViewFocus功能。索幸接口之后没有没有大的变动。
「分支问题」
很多团队采用这样的版本控制实践:
每个团队成员各自在代码库的一条分支上工作,进行相当大量的开发之后,才把各自的修改合并回主线分支(这条分支通常叫master或trunk),从而与整个团队分享。常见的做法是在分支上开发完整的功能,直到功能可以发布到生产环境,才把该分支合并回主线。这种做法的拥趸声称,这样能保持主线不受尚未完成的代码侵扰,能保留清晰的功能添加的版本记录,并且在某个功能出问题时能容易地撤销修改。
这样的特性分支有其缺点。在隔离的分支上工作得越久,将完成的工作集成(integrate)回主线就会越困难。为了减轻集成的痛苦,大多数人的办法是频繁地从主线合并(merge)或者变基(rebase)到分支。但如果有几个人同时在各自的特性分支上工作,这个办法并不能真正解决问题,因为合并与集成是两回事。如果我从主线合并到我的分支,这只是一个单向的代码移动——我的分支发生了修改,但主线并没有。而“集成”是一个双向的过程:不仅要把主线的修改拉(pull)到我的分支上,而且要把我这里修改的结果推(push)回到主线上,两边都会发生修改。假如另一名程序员正在他的分支上开发,我是看不见他的修改的,直到他将自己的修改与主线集成;此时我就必须把她的修改合并到我的特性分支,这可能需要相当的工作量。其中困难的部分是处理语义变化。现代版本控制系统都能很好地合并程序文本的复杂修改,但对于代码的语义它们一无所知。如果我修改了一个函数的名字,版本控制工具可以很轻松地将我的修改与他的代码集成。但如果在集成之前,他在自己的分支里新添调用了这个被我改名的函数,集成之后的代码就会被破坏。
分支合并本来就是一个复杂的问题,随着特性分支存在的时间加长,合并的难度会指数上升。集成一个已经存在了4个星期的分支,较之集成存在了2个星期的分支,难度可不止翻倍。
所以很多人认为,应该尽量缩短特性分支的生存周期,比如只有一两天。还有一些人(比如我本人)认为特性分支的生命还应该更短,我们采用的方法叫作持续集成(Continuous Integration,CI),也叫“基于主干开发”(Trunk-Based Development)。在使用CI时,每个团队成员每天至少向主线集成一次。这个实践避免了任何分支彼此差异太大,从而极大地降低了合并的难度。不过CI也有其代价:你必须使用相关的实践以确保主线随时处于健康状态,必须学会将大功能拆分成小块,还必须使用特性开关(feature toggle,也叫特性旗标,feature flag)将尚未完成又无法拆小的功能隐藏掉。 CI的粉丝之所以喜欢这种工作方式,部分原因是它降低了分支合并的难度,不过最重要的原因还是CI与重构能良好配合。重构经常需要对代码库中的很多地方做很小的修改(例如给一个广泛使用的函数改名),这样的修改尤其容易造成合并时的语义冲突。采用特性分支的团队常会发现重构加剧了分支合并的困难,并因此放弃了重构,这种情况我们曾经见过多次。CI和重构能够良好配合,所以Kent Beck在极限编程中同时包含了这两个实践。 我并不是在说绝不应该使用特性分支。如果特性分支存在的时间足够短,它们就不会造成大问题。(实际上,使用CI的团队往往同时也使用分支,但他们会每天将分支与主线合并。)对于开源项目,特性分支可能是合适的做法,因为不时会有你不熟悉(因此也不信任)的程序员偶尔提交修改。但对全职的开发团队而言,特性分支对重构的阻碍太严重了。即便你没有完全采用CI,我也一定会催促你尽可能频繁地集成。而且,用上CI的团队在软件交付上更加高效,我真心希望你认真考虑这个客观事实。