佳星辰 的个人博客

记录精彩的程序人生

努力、自律、上进
早睡、锻炼、读书
  menu
9 文章
0 浏览
1 当前访客
ღゝ◡╹)ノ❤️

为什么要重构以及如何发现应该重构的坏代码

重构原则

重构:对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本

重构的目的是使软件更容易被理解和修改。你可以在软件内部做很多修改,但必须对软件可观察的外部行为只造成很小的变化,或甚至不造成变化。与之形成对比的是性能优化,和重构一样,性能优化通常不会改变组件的行为(除了执行速度),但会改变其内部结构。但两者的出发点不同:性能优化往往使代码比较难理解,但为了得到所需的性能你不得不那么做。

1.为何重构

重构可以帮助你始终良好地控制自己的代码,重构是个工具,它可以并且应该用于以下几个目的。

1.1重构改进软件设计

如果没有重构,程序的设计会逐渐腐败变质。当人们只为短期目的,或是在完全理解整体设计之前旧贸然改动代码,程序将逐渐失去自己的结构,程序员愈来愈难以通过阅读代码而理解原来的设计。重构很像是在整理代码,你所作的就是让所有东西回到应处的位置上。代码结构的流失是累积性的,愈难看出代码所代表的设计意图,就愈难保护其中的设计,于是该设计就腐败的愈快。经常性的重构可以帮助代码维持自己该有的形态。

完成同样一件事,设计不良的程序往往需要更多的代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事。因此改进设计的一个重要的方向就是消除重复代码,这个动作的重要性在于方便未来的修改。代码量的减少并不会使系统运行更快,因为这对程序的运行轨迹几乎没有任何明显影响。然而代码量减少将使未来可能的程序修改动作容易得多。代码愈多,正确的修改就愈难,因为有更多代码需要理解。你在这做了点修改,系统却不如预期的那样工作,是因为你没有修改另一处--那儿的代码做着几乎完全一样的事情,只是所处环境略有不同。如果消除重复代码,你就可以确定所有事情和行为在代码中只表述一次,这正是优秀设计的根本。

1.2.重构使软件更容易理解

所谓程序设计,很大程度上就是与计算机交谈:你编写代码告诉计算机做什么事,它的响应则是精准按照你的指示行动。你得及时填补"想要它做什么和"告诉它做什么"之间的缝隙。这种编程模式的核心就是"准确的说出我所要的"。

除了计算机外,你的源码还有其他读者:几个月之后可能会有另一位程序员尝试读懂你的代码并做一些修改。我们很容易忘记第二位读者,但他才是最重要的。计算机是否多花了几个小时来编译,又有什么关系呢?但如果一个程序员花费一周时间来修改这段代码,而如果他理解了你的代码,这个修改原本只需一个小时。

问题在于,当你努力让程序运转的时候,不会想到未来出现的那个开发者。我们应该改变一下开发节奏,对代码做适当的修改,让代码变得更容易理解。重构可以帮助我们让代码更易懂。一开始进行重构时,你的代码可以正常运行,但结构不够理想。在重构上花一点时间,就可以让代码更好的表达自己的用途。

关于这一点,我们没必要表现得如此无私。因为很多时候那个未来的开发者就是我们自己,此时重构就显得尤其重要了。

这种可理解性还有另一方面的作用。利用重构来协助我们理解不熟悉的代码。每当看到不熟悉的代码,我们可以真正动手修改代码,让它更好地反映出我们的理解,然后重新执行,看它是否仍然正常运作,以此检验我们的理解是否正确。

1.3重构提高编程速度

终于,前面的一切都归结到了这最后一点:重构帮助你更快速地开发程序。听起来有点违反直觉。当我们谈到重构,人们很容易看出它能够提高质量、改善设计、提升可读性、减少错误,这些都是提高质量。但这难道不会降低开发速度吗?

良好的设计是快速开发的根本--事实上,拥有良好设计才可能做到快速开发。如果没有良好设计,或许某一段时间内你的进展迅速,但恶劣的设计很快就让你的速度慢下来。你会把时间花在调试上面,无法添加新功能。修改时间愈来愈长,因为你必须花愈来愈多的时间去理解系统、寻找重复代码。随着你给最初程序打上一个又一个的补丁,新特性需要更多代码才能实现,这是一个恶性循环。

良好设计是维持软件开发速度的根本。重构可以帮助你更快速地开发软件,因为它阻止系统腐败变质,它甚至还可以提高设计质量。

2.何时重构

重构本来就不是一件应该特别拨出时间做的事情,重构应该随时随地进行。你不应该为重构而重构,你之所以重构,是因为你想做别的什么事,而重构可以帮助你把那些事做好。

2.1添加功能时重构

最常见的重构时机就是我想给软件添加新特性的时候。此时,重构的直接原因往往是为了帮助我理解需要修改的代码--这些代码可能是别人写的,也可能是我自己写的。无论何时,只要我想理解代码所做的事,我就会问自己:是否能对这段代码进行重构,使我能更快地理解它。然后我就会重构。之所以这么做,部分原因是为了让我下次再看这段代码时容易理解,但最主要的原因是:如果在前进过程中把代码结构理清,我就可以从中理解更多东西。

在这里,重构的另一个原动力是:原有代码的设计无法帮助我轻松添加我所需要的特性。我看着设计,然后对自己说:“如果用某种方式来设计,添加特性会简单得多。”这种情况下我不会因为自己过去的错误而懊--我用重构来弥补它。之所以这么做,部分原因是为了让未来增加新特性时能够更轻松一些,但最主要的原因还是我发现这是最快捷的途径。重构是一个快速流畅的过程,一旦完成重构,新特性的添加就会更快速、更流畅。

2.2修补错误时重构

调试过程中运用重构,多半是为了让代码更具可读性。当我看着代码并努力理解它的时候,我用重构帮助加深自己的理解。我发现以这种程序来处理代码,常常能够帮助我找出bug。你可以这么想:如果收到一份错误报告,这就是需要重构的信号,因为显然代码还不够清晰--没有清晰到让你能一眼看出bug。

2.3复审代码时重构

很多公司都会做常规的代码复审,因为这种活动可以改善开发状况。这种活动有助于在开发团队中传播知识,也有助于让较有经验的开发者把知识传递给比较欠缺经验的人,并帮助更多人理解大型软件系统中的更多部分。代码复审对于编写清晰代码也很重要。我的代码也许对我自己来说很清晰,对他人则不然,这是无法避免的。因为要让开发者设身处地为那些不熟悉自己所做所为的人着想,实在太困难了。代码复审也让更多人有机会提出有用的建议,毕竟我在一个星期之内能够想出的好点子很有限。

我发现,重构可以帮助我复审别人的代码。开始重构前我可以先阅读代码,得到一定程度的理解,并提出一些建议。一旦想到一些点子,我就会考虑是否可以通过重构立即轻松地实现它们。如果可以,我就会动手。这样做了几次以后,我可以把代码看得更清楚,提出更多恰当的建议。我不必想象代码应该是什么样,我可以“看见”它是什么样。于是我可以获得更高层次的认识。如果不进行重构,我永远无法得到这样的认识。

重构还可以帮助代码复审工作得到更具体的结果。不仅获得建议,而且其中许多建议能够立刻实现。最终你将从实践中得到比以往多得多的成就感。

为了让过程正常运转,你的复审团队必须保持精练。最好是一个复审者搭配一个原作者,共同处理这些代码。复审者提出修改建议,然后两人共同判断这些修改是否能够通过重构轻松实现。如果真能够如此,就一起着手修改。

如果是比较大的设计复审工作,那么在一个较大团队内保留多种观点通常会更好一些。此时直接展示代码往往不是最佳办法。可以运用UML示意图展现设计,并以CRC卡展示软件情节。换句话说,和团队进行设计复审,而和单个复审者进行代码复审。

程序有两面价值:“今天可以为你做什么”和“明天可以为你做什么”。
大多数时候,我们都只关注自己今天想要程序做什么。
不论是修复错误或是添加特性,我们都是为了让程序能力更强,让它在今天更有价值。

但是系统当下的行为,只是整个故事的一部分,如果没有认清这一点,你无法长期从事编程工作。
如果你为求完成今天的任务而不择手段,导致不可能在明天完成明天的任务,那么最终还是会失败。
但是,你知道自己今天需要什么,却不一定知道自己明天需要什么。
也许你可以猜到明天的需求,也许吧,但肯定还有些事情出乎你的意料。

对于今天的工作,我了解得很充分;对于明天的工作,我了解得不够充分。
但如果我纯粹只是为今天工作,明天我将完全无法工作。

重构是一条摆脱困境的道路。如果你发现昨天的决定已经不适合今天的情况,放心改变这个决定就是,然后你就可以完成今天的工作了。
明天回头看今天的理解也许觉得很幼稚,那时你还可以改变你的理解。

是什么让程序如此难以相与呢?眼下我能想起下述四个原因,它们是:
    1.难以阅读的程序,难以修改;
    2.逻辑重复的程序,难以修改;
    3.添加新行为时需要修改已有代码的程序,难以修改;
    4.带复杂条件逻辑的程序,难以修改。

因此,我们希望程序:
    1.容易阅读;
    2.所有逻辑都只在唯一地点指定;
    3.新的改动不会危及现有行为;
    4.尽可能简单表达条件逻辑。

重构是这样一个过程:
它在一个目前可运行的程序上进行,在不改变程序行为的前提下使其具备上述美好性质,
使我们能够继续保持高速开发,从而增加程序的价值。

3.重构难题

学习一种可以大幅提高生产力的新技术时,你总是难以察觉其不适用的场合。通常你在一个特定场景中学习它,这个场景往往时某个项目。这种情况下你很难看出什么会造成这种新技术成效不彰甚或形成危害。

我们知道重构的好处,我们知道重构可以给我们的工作带来立竿见影的改变。但是我们还没有获得足够的经验,我们还看不到它的局限性。

随着更多人学会重构技巧,我们也将对它有更多了解。对你而言这意味着:虽然我坚决认为你应该尝试一下重构,获得它所提供的利益,但与此同时,你也应该时时监控其过程,注意寻找重构可能引入的问题。请让我们知道你所遭遇的问题,随着对重构的了解日益增多,我们将找出更多解决办法,并清楚知道哪些问题是真正难以解决的。

3.1数据库

重构经常出问题的一个领域就是数据库。绝大多数商用程序都与它们背后的数据库结构紧密耦合在一起,这也是数据库结构如此难以修改的原因之一。另一个原因是数据迁移(migration),就算你非常小心地将系统分层,将数据库结构和对象模型间的依赖降至最低,但数据库结构的改变还是让你不得不迁移所有数据,这可能是件漫长而烦琐的工作。

在非对象数据库中,解决这个问题的办法之一就是:在对象模型和数据库模型之间插入一个分隔层,这就可以隔离两个模型各自的变化。升级某一模型时无需同时升级另一模型,只需升级上述的分隔层即可。这样的分隔层会增加系统复杂度,但可以给你带来很大的灵活度。如果你同时拥有多个数据库,或如果数据库模型较为复杂使你难以控制,那么即使不进行重构,这分隔层也是很重要的。

你无需一开始就插入分隔层,可以在发现对象模型变得不稳定时再产生它,这样你就可以为你的改变找到最好的平衡点。

对开发者而言,对象数据库既有帮助也有妨碍。某些面向对象数据库提供不同版本的对象之间的自动迁移功能,这减少了数据迁移时的工作量,但还是会损失一定时间。如果各个数据库之间的数据迁移并非自动进行,你就必须自行完成迁移工作,这个工作量可是很大的。这种情况下你必须更加留神类中的数据结构变化。你仍然可以放心将类的行为转移过去,但转移字段时就必须格外小心。数据尚未被转移前你就得先运用访问函数造成“数据已经转移”的假象。一旦你确定知道数据应该放在何处,就可以一次性地将数据迁移过去。这时唯一需要修改的只有访问函数,这也降低了错误风向。

3.2修改接口

关于对象,另一件重要事情是:它们允许你分开修改软件模块的实现和接口。你可以安全地修改对象内部实现而不影响他人,但对于接口要特别谨慎--如果接口被修改了,任何事情都有可能发生。

一直对重构带来困扰的一件事就是:许多重构手法的确会修改接口。像 RenameMethod 这么简单的重构手法所做的一切就是修改接口。这对极为珍贵的封装概念会带来什么影响呢?

如果某个函数的所有调用者都在你的控制之下,那么即使修改函数名称也不会有任何问题。哪怕面对一个 public 函数,只要能取得并修改其所有调用者,你也可以安心地将这个函数改名。只有当需要修改的接口被那些“找不到、即使找到也不能修改”的代码使用时,接口的修改才会成为问题。如果情况真是如此,我就会说:这个接口是个已发布接口(published interface)--比公开接口(public interface)更进一步。接口一旦发布,你就再也无法仅仅修改调用者而能够安全地修改接口了,你需要一个更复杂的流程。

这个想法改变了我们的问题。如今的问题是:该如何面对那些必须修改“已发布接口”的重构手法?简言之,如果重构手法改变了已发布接口,你必须同时维护新旧两个接口,直到所有用户都有时间对这个变化做出反应。幸运的是,这不太困难。你通常都有办法把事情组织好,让旧接口继续工作。请尽量这么做:让旧接口调用新接口。当你要修改某个函数名称时,请留下旧函数,让它调用新函数。千万不要复制函数实现,那会让你陷入重复代码的泥淖中难以自拔。

这个过程的一个好例子就是 Java 容器类(集合类,collection classes)。Java2的新容器取代了原先一些容器。当Java2容器发布时,JavaSoft花了很大力气来为开发者提供一条顺利迁徙之路。

“保留旧接口”的办法通常可行,但很烦人。起码在一段时间里你必须构造并维护一些额外的函数。它们会使接口变得复杂,使接口难以使用。还好我们有另一个选择:不要发布接口。当然我不是说要完全禁止,因为很明显你总得发布一些接口。如果你正在建造供外部使用的API,就必须发布接口。之所以说尽量不要发布,是因为我常常看到一些开发团队公开了太多接口。我曾经看到一支三人团队这么工作:每个人都向另外两人公开发布接口。这使他们不得不经常来回维护接口,而其实他们原本可以直接进入程序库,径行修改自己管理的那一部分,那会轻松许多。过度强调代码所有权的团队常常会犯这种错误。发布接口很有用,但也有代价。所以除非真有必要,不要发布接口。这可能意味需要改变你的代码所有权观念,让每个人都可以修改别人的代码,以适应接口的改动。以结对编程的方式完成这一切通常是个好主意。

3.3难以通过重构手法完成的设计改动

通过重构,可以排除所有设计错误吗?是否存在某些核心设计决策,无法以重构手法修改?当然某些情况下我们可以很有效地重构,这常常令我们倍感惊讶,但的确也有难以重构的地方。比如说在一个项目中,我们很难将不考虑安全性需求时构造起来的系统重构为具备良好安全性系统。

这种情况下的办法就是:先想象重构的情况。考虑候选设计方案时,问自己:将某个设计重构为另一个设计的难度有多大?如果看上去很简单,就不必太担心选择是否得当,此时可以选择最简单的设计,哪怕它不能覆盖所有潜在需求也没关系。但如果预先看不到简单的重构办法,就得在设计上投入更多力气。

3.4何时不该重构

有时候你根本不应该重构,例如:当你应该重新编写所有代码的时候。有时候既有代码实在太混乱,重构它还不如重新写一个来得简单,做出这种决定很困难,没有什么好准则可以判断何时应该放弃重构。

重写(而非重构)的一个清楚讯号就是:现有代码根本不能正常运作。你可能知识试着做点测试,然后发现代码中满是错误,根本无法稳定运作。记住,重构之前,代码必须起码能够在大部分情况下正常运作。

一个折中办法就是:将“大块头软件”重构为封装良好的小型软件。然后就可以逐一对组件做出”重构或重建“的决定。这是一个颇有希望的办法,对于一个重要的遗留系统,这肯定会是一个很好的方向。

另外,如果项目已近最后期限,你也应该避免重构。在此时机,从重构过程赢得的生产力只有在最后期限过后才能体现出来,而那个时候已经为时晚矣。Ward Cunningham 对此有一个很好的看法:他把未完成的重构工作形容为“债务”。很多公司都需要借债来使自己更有效地运转。但借债就得付利息,过于复杂的代码所造成的维护和扩展的额外成本就是利息。你可以承受一定程度的利息,但如果利息太高你就会被压垮。把债务管理好是很重要的,你应该随时通过重构来偿还一部分债务。

如果项目已经非常接近最后期限不应该再分心于重构,因为已经没有时间了。不过多个项目经验显示:重构的确能够提高生产力。如果最后你没有足够时间,通常就表示你其实早该进行重构。

4.重构与设计

重构肩负一项特殊使命:它和设计彼此互补。许多人都把设计看作软件开发的关键环节,而把编程看作只是机械式的低级劳动。他们认为设计就像画工程图而编码就像施工,但是你要知道,软件和机器有着很大的差异:软件的可塑性更强,而且完全是思想产品。正如Alistair Cockburn 所说:“有了设计,我可以思考得更快,但是其中充满小漏洞。”

有一种观点认为:重构可以取代预先设计。这意思是你根本不必做任何设设计。只管按照最初想法开始编码,让代码有效运作,然后再将它重构成型。事实上这种办法真的可行。的确有人这么做,最后获得设计良好的软件。极限编程的支持者极力提倡这种办法。

尽管如上所言,只运用重构也能收到效果,但这并不是最有效的途径。是的,就连极限编程的爱好者们也会进行预先设计。他们会使用CRC卡或类似的东西来检验各种不同想法,然后才得到第一个可被接受的解决方案,然后才能开始编码,然后才能重构。关键在于:重构改变了预先设计的角色。如果没有重构,你就必须保证预先做出的设计正确无误,这个压力太大了。这意味如果将来要对原始设计任何修改,代价都将非常高昂。因此你需要把更多时间和精力放在预先设计上,以避免日后修改。

如果你选择重构,问题的重点就转变了。你仍然做预先设计,但是不必一定找出正确的解决方案。此刻的你只需要得到个足够合理的解决方案就够了。你很肯定地知道,在实现这个初始解决方案的时候,你对问题的理解也会逐渐加深,你可能会察觉最佳解决方案和你当初设想的有些不同。只要有重构这把利器在手,就不成问题,因为重构让日后的修改成本不再高昂。

这种转变导致一个重要结果;软件设计向简化前进了一大步。过去未曾运用重构时,我们总是力求得到灵活的解决方案。任何一个需求都让我们提心吊胆地猜疑:在系统的有生之年,这个需求会导致怎样的变化?由于变更设计的代价非常高昂,所以我们希望建造一个足够灵活、足够牢靠的解决方案,希望它能承受我所能预见的所有需求变化。问题在于:要建造一个灵活的解决方案,所需的成本难以估算。灵活的解决方案比简单的解决方案复杂许多,所以最终得到的软件通常也会更难维护--虽然它在我们预先设想的方向上的确是更加灵活。就算幸运地走在预先设想的方向上,你也必须理解如何修改设计。如果变化只出现在一两个地方,那不算大问题。然而变化其实可能出现在系统各处。如果在所有可能的变化出现地点都建立起灵活性整个系统的复杂度和维护难度都会大大提高。当然,如果最后发现所有这些灵活性都毫无必要,这才是最大的失败。你知道,这其中肯定有些灵活性的确派不上用场,但你却无法预测到底是哪些派不上用场。为了获得自己想要的灵活性,你不得不加入比实际需要更多的灵活性。

有了重构,你就可以通过一条不同的途径来应付变化带来的风险。你仍旧需要思考潜在的变化,仍旧需要考虑灵活的解决方案。但是你不必再逐一实现这些解决方案,而是应该问问自己:“把一个简单的解决方案重构成这个灵活的方案有多大难度?”如果答案是“相当容易”,那么你就只需实现目前的简单方案就行了。

重构可以带来更简单的设计,同时又不损失灵活性,这也降低了设计过程的难度,减轻了设计压力。一旦对重构带来的简单性有更多感受,你甚至可以不必再预先思考前述所谓的灵活方案--一旦需要它,你总有足够的信心去重构。是的,当下只管建造可运行的最简化系统,至于灵活而复杂的设计,多数时候你都不会需要它

5.重构与性能

关于重构,有一个常被提出的问题:它对程序的性能将造成怎样的影响?为了让软件易于理解,你常会做出一些使程序运行变慢的修改。这是个重要的问题。我并不赞成为了提高设计的纯洁性而忽视性能,把希望寄托于更快的硬件身上也绝非正道。已经有很多软件因为速度太慢而被用户拒绝,日益提高的机器速度也只不过略微放宽了速度方面的限制而已。但是,换个角度说,虽然重构可能使软件运行更慢,但它也使软件的性能优化更容易。除了对性能有严格要求的实时系统,其他任何情况下“编写快速软件”的秘密就是:首先写出可调整的软件,然后调整它以求获得足够速度。

我看过三种编写快速软件的方法。其中最严格的是时间预算法,这通常只用于性能要求极高的实时系统。如果使用这种方法,分解你的设计时就要做好预算,给每个组件预先分配一定资源--包括时间和执行轨迹。每个组件绝对不能超出自己的预算,就算拥有组件之间有调度预配时间的机制也不行。这种方法高度重视性能,对于心律调节器一类的系统是必须的,因为在这样的系统中迟来的数据就是错误的数据。但对其他系统(例如企业信息系统)而言,如此追求高性能就有点过分了。

第二种方法是持续关注法。这种方法要求任何程序员在任何时间做任何事时都要设法保持系统的高性能。这种方式很常见,感觉上很有吸引力,但通常不会起太大作用。任何修改如果是为了提高性能,通常会使程序难以维护,继而减缓开发速度。如果最终得到的软件的确更快了,那么这点损失尚有所值,可惜通常事与愿违,因为性能改善一旦被分散到程序各角落,每次改善都只不过是从对程序行为的一个狭隘视角出发而已。

关于性能,一件很有趣的事情是:如果你对大多数程序进行分析,就会发现它把大半时间都耗费在一小半代码身上。如果你一视同仁地优化所有代码,90%的优化工作都是白费劲的,因为被你优化的代码大多很少被执行。你花时间做优化是为了让程序运行更快,但如果因为缺乏对程序的清楚认识而花费时间,那些时间就都是被浪费掉了。

第三种性能提升法就是利用上述的90%统计数据。采用这种方法时,你编写构造良好的程序,不对性能投以特别的关注,直至进入性能优化阶段——那通常是在开发后期。一旦进入该阶段,你再按照某个特定程序来调整程序性能。

在性能优化阶段,你首先应该用一个度量工具来监控程序的运行,让它告诉你程序中哪些地方大量消耗时间和空间。这样你就可以找出性能热点所在的一小段代码。然后你应该集中关注这些性能热点,并使用持续关注法中的优化手段来优化它们。由于你把注意力都集中在热点上,较少的工作量便可显现较好的成果。即便如此你还是必须保持谨慎,和重构一样,你应该小幅度进行修改。每走一步都需要编译、测试、再次度量。如果没能提高性能,就应该撤销此次修改。你应该继续这个“发现热点、去除热点”的过程,直到获得客户满意的性能为止。

一个构造良好的程序可从两方面帮助这一优化形式。首先,它让你有比较充裕的时间进行性能调整,因为有构造良好的代码在手,你就能够更快速地添加功能也就有更多时间用在性能问题上(准确的度量则保证你把这些时间投资在恰当地点)。其次,面对构造良好的程序,你在进行性能分析时便有较细的粒度,于是度量工具把你带入范围较小的程序段落中,而性能的调整也比较容易些。由于代码更加清晰,因此你能够更好地理解自己的选择,更清楚哪种调整起关键作用。

短期来看,重构的确可能使软件变慢,但它使优化阶段的软件性能调整更容易,最终还是会得到更好的效果。

坏代码的味道

现在,对于重构如何运作,你已经有了相当好的理解。但是知道“如何”决不代表知道”何时“。决定何时重构、何时停止和知道重构机制如何运转一样重要。

难题来了!解释“如何删除一个实例变量”或“如何产生个继承体系”很容易,因为这些都是很简单的事情。但要解释“该在什么时候做这些动作”就没那么顺成章了。除了露几手含混的编程美学,我还希望让某些东西更具说服力一些。

我们并不试图给你一个何时必须重构的精确衡量标准。从我们的经验看来,没有任何量度规矩比得上一个见识广博者的直觉。我们只会告诉你一些迹象,它会指出“这里有一个可以用重构解决的问题”。你必须培养出自己的判断力,学会判断个类内有多少实例变量算是太大、一个函数内有多少行代码才算太长。

重复代码

如果你在一个以上的地方看到相同的程序结构,那么可以肯定:设法将他们合而为一,代码将更好。

最单纯的重复代码就是”同一个类的两个函数含有相同的表达式“。这时候你需要做的就是提炼出重复代码,然后让两个地点都调用被提炼出的那一段代码。

另一种常见情况就是“两个互为兄弟的子类内含相同表达式”。要避免这种情况,只需将相同的表达式提炼出来,然后再把提炼出来的方法推入超类内。

如果代码之间只是类似,并非完全相同,那么就得将相似部分和差异部分割开,构成单独一个函数。然后你可能发现可以运用 塑造模板函数 获得一个 模板方法 设计模式

如果有些函数以不同的算法做相同的事,你可以选择其中较清晰的一个,并将其他函数的算法替换掉。

如果两个毫不相关的类出现重复代码,你应该考虑对其中一个的重复代码进行提炼 ,将重复代码提炼到一个独立类中,然后在另一个类内便用这个新类。但是,重复代码所在的函数也可能的确只应该属于某个类,另一个类只能调用它,抑或这个函数可能属于第三个类,而另两个类应该引用这第三个类。你必须决定这个函数放在哪儿最合适,并确保它被安置后就不会再在其他任何地方出现。

过长函数

拥有短函数的对象会活得比较好、比较长。不熟悉面向对象技术的人,常常觉得对象程序中只有无穷无尽的委托,根本没有进行任何计算。也许要多年之后,你才会知道,这些小小函数有多大价值。“间接层”所能带来的全部利益--解释能力、共享能力、选择能力--都是由小型函数支持的。

很久以前程序员就已经认识到:程序愈长愈难理解。早期的编程语言中,子程序调用需要额外开销,这使得人们不太乐意使用小函数。现代OO语言几乎已经完全免除了进程内的函数调用开销。不过代码阅读者还是得多费力气,因为他必须经常转换上下文去看看子程序做了什么。某些开发环境允许用户同时看到两个函数,这可以帮助你省去部分麻烦,但是让小函数容易理解的真正关键在于一个好名字。如果你能给函数起个好名字,读者就可以通过名字了解函数的作用,根本不必去看其中写了些什么。

最终的效果是:你应该更积极地分解函数。我们遵循这样一条原则:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。我们可以对一组甚至短短一行代码做这件事哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释其用途,我们也该毫不犹豫地那么做。关键不在于函数的长度,而在于函数“做什么”和“如何做”之间的语义距离。

百分之九十九的场合里,要把函数变小,只需提炼方法。找到函数中适合集中在一起的部分,将它们提炼出来形成一个新函数。如果函数内有大量的参数和临时变量,它们会对你的函数提炼形成阻碍。如果你尝试提炼方法,最终就会把许多参数和临时变量当作参数,传递给被提炼出来的新函数,导致可读性几乎没有任何提升。此时,你可以使用以查询代替临时变量的方法来消除这些临时元素。引入对象参数 则可以将过长的参数列变得更简洁一些。

如果你已经这么做了,仍然有太多临时变量和参数,那就应该使出我们的杀手锏: 用函数对象取代函数

如何确定该提炼哪一段代码呢?一个很好的技巧是:寻找注释。它们通常能指出代码用途和实现手法之间的语义距离。如果代码前方有一行注释,就是在提醒你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名。就算只有一行代码,如果它需要以注释来说明,那也值得将它提炼到独立函数去。

条件表达式和循环常常也是提炼的信号。你可以分解条件表达式来 处理条件表达式。至于循环,你应该将循环和其内的代码提炼到一个独立函数中。

过大的类

如果想利用单个类做太多事情,其内往往就会出现太多实例变量。一旦如此重复代码也就接踵而至了。

你可以提炼类将几个变量一起提炼至新类内。提炼时应该选择类内彼此相关的变量,将它们放在一起。。通常如果类内的数个变量有着相同的前缀或字尾,这就意味有机会把它们提炼到某个组件内。如果这个组件适合作为一个子类,你会发现提炼出子类往往比较简单

和“太多实例变量”一样,类内如果有太多代码,也是代码重复、混乱并最终走向死亡的源头。最简单的解决方案是把多余的东西消弭于类内部。

这里有个技巧:先确定客户端如何使用它们,然后提炼出接口,为每一种使用方式提炼出一个接口,这或许可以帮助你看清楚如何分解这个类。

如果你的超大类是个GUI类,你可能需要把数据和行为移到一个独立的领域对象去。你可能需要两边各保留一些重复数据,并保持两边同步。

过长参数列表

有了对象,你就不必把函数需要的所有东西都以参数传递给它了,只需传给它足够的、让函数能从中获得自己需要的东西就行了。函数需要的东西多半可以在函数的宿主类中找到。面向对象程序中的函数,其参数列通常比在传统程序中短得多

这是好现象,因为太长的参数列难以理解,太多参数会造成前后不一致、不易使用,而且一旦你需要更多数据,就不得不修改它。如果将对象传递给函数,大多数修改都将没有必要,因为你很可能只需(在函数内)增加一两条请求,就能得到更多数据。

如果向已有的对象发出一条请求就可以取代一个参数,那么你应该以函数取代参数 。在这里,“已有的对象”可能是函数所属类内的一个字段,也可能是另一个参数。你还可以 保持对象完整,将来自同一对象的一堆数据收集起来,并以该对象替换它们。如果某些数据缺乏合理的对象归属,可以引入对象参数为它们制造出一个“参数对象

这里有一个重要的例外:有时候你明显不希望造成“被调用对象”与“较大对象”间的某种依赖关系。这时候将数据从对象中拆解出来单独作为参数,也很合情合理。但是请注意其所引发的代价。如果参数列太长或变化太频繁,你就需要重新考虑自己的依赖结构了。

发散式变化

我们希望软件能够更容易被修改--毕竟软件再怎么说本来就该是“软”的。一旦需要修改,我们希望能够跳到系统的某一点,只在该处做修改。

如果某个类经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。当你看着一个类说:“呃,如果新加入一个数据库,我必须修改这三个函数;如果新出现一种金融工具,我必须修改这四个函数。”那么此时也许将这个对象分成两个会更好,这么一来每个对象就可以只因一种变化而需要修改。当然,往往只有在加入新数据库或新金融工具后,你才能发现这一点。针对某一外界变化的所有相应修改,都只应该发生在单一类中,而这个新类内的所有内容都应该反应此变化。为此,你应该找出某特定原因而造成的所有变化,然后提炼出对象将它们提炼到另一个类中。

霰弹式修改

霰弹式修改类似发散式变化,但恰恰相反。如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,你所面临的就是霰弹式修改。如果需要修改的代码散布四处,你不但很难找到它们,也很容易忘记某个重要的修改。

这种情况下你应该移动方法和属性,把所有需要修改的代码放进同一个类。如果眼下没有合适的类可以安置这些代码,就创造一个,把一系列相关行为放进同一个类。

发散式变化是指“一个类受多种变化的影响”,霰弹式修改则是指“一种变化引发多个类相应修改”。这两种情况下你都会希望整理代码,使“外界变化与“需要修改的类”趋于一一对应。

依恋情结

对象技术的全部要点在于:这是一种“将数据和对数据的操作行为包装在一起”的技术。有一种经典情况是:函数对某个类的兴趣高过对自己所处类的兴趣。这种孺慕之情最通常的焦点便是数据。无数次经验里,我们看到某个函数为了计算某个值,从另一个对象那儿调用几乎半打的取值函数。疗法显而易见:把这个函数移至另一个地点。你应该把它移到它该去的地方。有时候函数中只有一部分受这种依恋之苦,这时候你应该提炼方法,把这一部分提炼到独立函数中,再把它移到它应该待的类里。

当然,并非所有情况都这么简单。一个函数往往会用到几个类的功能,那么它究竟该被置于何处呢?我们的原则是:判断哪个类拥有最多被此函数使用的数据,然后就把这个函数和那些数据摆在一起。如果先提炼方法将这个函数分解为数个较小函数并分别置放于不同地点,上述步骤也就比较容易完成了。

最根本的原则是:将总是一起变化的东西放在一块儿。数据和引用这些数据的行为总是一起变化的,但也有例外。如果例外出现,我们就搬移那些行为,保持变化只在一地发生。Strategy和Visitor使你得以轻松修改函数行为,因为它们将少量需被覆写的行为隔离开来--当然也付出了“多一层间接性”的代价。

冗赘类

你所创建的每一个类,都得有人去理解、维护,这些工作都是要花钱的。如果一个类的所得不值其身价它就应该消失。

项目中经常会出现这样的情况:某个类原本对得起自己的身价,但重构使它身形缩水,不再做那么多工作;或开发者事前规划了某些变化,并添加一个类来应对这些变化,但变化实际上并没有发生。

不论上述哪一种原因,这个类实际上都没有存在的意义了。

夸夸其谈未来性

当有人说“噢,我想我们总有一天需要做这事”并因而企图以各式各样的钩子和特殊情况来处理一些非必要的事情,这种坏味道就出现了。那么做的结果往往造成系统更难理解和维护。如果所有装置都会被用到,那就值得那么做;如果用不到,就不值得。用不上的装置只会挡你的路,所以,把它搬开吧。

如果函数或类的唯一用户是测试用例,你发现这样的函数或类,请把他连同测试用例一并删掉。

过度耦合的消息链

如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象然后再请求另一个对象... 这就是消息链。实际代码中你看到的可能是一长串 getThis 或一长串临时变量。采取这种方式,意味客户代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。

中间人

对象的基本特征之一就是封装--对外部世界隐藏其内部细节,封装往往伴随委托。

但是人们可能过度运用委托。你也许会看到某个类接口有一半的函数都委托给其他类,这样就是过度运用。这时应该移除中间人,直接和真正负责的对象打交道。


标题:jxc321
作者:jxc
地址:https://jxc321.com/articles/2023/06/30/1688124177343.html