《重构》笔记摘录 - 5.搬移特性

5 阅读12分钟

1 搬移函数(Move Function)

模块化是优秀软件设计的核心所在,好的模块化能够让我在修改程序时只需理解程序的一小部分。为了设计出高度模块化的程序,我得保证互相关联的软件要素都能集中到一块,并确保块与块之间的联系易于查找、直观易懂。同时,随着我对代码的理解加深,我会知道那些软件要素如何组织最为恰当。要将这种理解反映到代码上,就得不断地搬移这些元素。

任何函数都需要具备上下文环境才能存活。这个上下文可以是全局的,但它更多时候是由某种形式的模块所提供的。对一个面向对象的程序而言,类作为最主要的模块化手段,其本身就能充当函数的上下文;通过嵌套的方式,外层函数也能为内层函数提供一个上下文。不同的语言提供的模块化机制各不相同,但这些模块的共同点是,它们都能为函数提供一个赖以存活的上下文环境。

搬移函数最直接的一个动因是,它频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。此时,让它去与那些更亲密的元素相会,通常能取得更好的封装效果,因为系统别处就可以减少对当前模块的依赖。

同样,如果我在整理代码时,发现需要频繁调用一个别处的函数,我也会考虑搬移这个函数。有时你在函数内部定义了一个帮助函数,而该帮助函数可能在别的地方也有用处,此时就可以将它搬移到某些更通用的地方。同理,定义在一个类上的函数,可能挪到另一个类中去更方便我们调用。

是否需要搬移函数常常不易抉择。为了做出决定,我需要仔细检查函数当前上下文与目标上下文之间的区别,需要查看函数的调用者都有谁,它自身又调用了哪些函数,被调用函数需要什么数据,等等。

我发现好的做法是先把函数安置到某一个上下文里去,这样我就能发现它们是否契合,如果不太合适我可以再把函数搬移到别的地方。

2 搬移字段(Move Field)

编程活动中你需要编写许多代码,为系统实现特定的行为,但往往数据结构才是一个健壮程序的根基。

一个适应于问题域的良好数据结构,可以让行为代码变得简单明了,而一个糟糕的数据结构则将招致许多无用代码,这些代码更多是在差劲的数据结构中间纠缠不清,而非为系统实现有用的行为。代码凌乱,势必难以理解;不仅如此,坏的数据结构本身也会掩藏程序的真实意图。

好的数据结构至关重要,不过这也与编程活动的许多方面一样,它们都很难一次做对。

我通常都会做些预先的设计,设法得到最恰当的数据结构,此时如果你具备一些领域驱动设计 (domain-driven design)方面的经验和知识,往往有助于你更好地设计数据结构。

但即便经验再丰富,技能再熟练,我仍然发现我在进行初版设计时往往还是会犯错。在如果我发现数据结构已经不适应于需求,就应该马上修缮它。如果容许瑕疵存在并进一步累积,它们就会经常使我困惑,并且使代码愈来愈复杂。

总是一同出现、一同作为函数参数传递的数据,最好是规整到同一条记录中,以体现它们之间的联系。

如果修改一条记录时,总是需要同时改动另一条记录,那么说明很可能有字段放错了位置。

此外,如果我更新一个字段时,需要同时在多个结构中做出修改,那也是一个征兆,表明该字段需要被搬移到一个集中的地点,这样每次只需修改一处地方。

搬移字段的操作通常是在其他更大的改动背景下发生的。实施字段搬移后,我可能会发现字段的诸多使用者应该通过目标对象来访问它,而不应该再通过源对象来访问。诸如此类的清理,我会在此后的重构中一并完成。同样,我也可能因为字段当前的一些用法而无法直接搬移它。我得先对其使用方式做一些重构,然后才能继续搬移工作。

3 搬移语句到函数(Move Statements into Function)

要维护代码库的健康发展,需要遵守几条黄金守则,其中最重要的一条当属“消除重复”。如果我发现调用某个函数时,总有一些相同的代码也需要每次执行,那么我会考虑将此段代码合并到函数里头。这样,日后对这段代码的修改只需改一处地方,还能对所有调用者同时生效。如果将来代码对不同的调用者需有不同的行为,那时再通过搬移语句到调用者将它(或其一部分)搬移出来也十分简单。

如果某些语句与一个函数放在一起更像一个整体,并且更有助于理解,那我就会毫不犹豫地将语句搬移到函数里去。如果它们与函数不像一个整体,但仍应与函数一起执行,那我可以用提炼函数将语句和函数一并提炼出去。这基本就是我下面要描述的做法了,只是下面还多了内联和改名的步骤。这些清理工作通常有其必要性,可以在完成核心步骤后再择机完成。

4 搬移语句到调用者(Move Statements to Callers)

作为程序员,我们的职责就是设计出结构一致、抽象合宜的程序,而程序抽象能力的源泉正是来自函数。与其他抽象机制的设计一样,我们并非总能平衡好抽象的边界。

随着系统能力发生演进(通常只要是有用的系统,功能都会演进),原先设定的抽象边界总会悄无声息地发生偏移。对于函数来说,这样的边界偏移意味着曾经视为一个整体一个单元的行为,如今可能已经分化出两个甚至是多个不同的关注点。

函数边界发生偏移的一个征兆是,以往在多个地方共用的行为,如今需要在某些调用点面前表现出不同的行为。于是,我们得把表现不同的行为从函数里挪出,并搬移到其调用处。这种重构手法比较适合处理边界仅有些许偏移的场景,但有时调用点和调用者之间的边界已经相去甚远,此时便只能重新进行设计了。若果真如此,最好的办法是先用内联函数合并双方的内容,调整语句的顺序,再提炼出新的函数来,以形成更合适的边界。

5 以函数调用取代内联代码(Replace Inline Code with Function Call)

善用函数可以帮助我将相关的行为打包起来,这对于提升代码的表达力大有裨益,一个命名良好的函数,本身就能极好地解释代码的用途,使读者不必了解其细节。

函数同样有助于消除重复,因为同一段代码我不需要编写两次,每次调用一下函数即可。此外,当我需要修改函数的内部实现时,也不需要四处寻找有没有漏改的相似代码。当然,我可能需要检查函数的所有调用点,判断它们是否都应该使用新的实现,但通常很少需要这么仔细,即便需要,也总好过四处寻找相似代码。

如果我见到一些内联代码,它们做的事情仅仅是已有函数的重复,我通常会以一个函数调用取代内联代码。

但有一种情况需要特殊对待,那就是当内联代码与函数之间只是外表相似但其实并无本质联系时。这种情况下,当我改变了函数实现时,并不期望对应内联代码的行为发生改变。

判断内联代码与函数之间是否真正重复,从函数名往往可以看出端倪:如果一个函数命名得当,也确实与内联代码做了一样的事,那么这个名字用在内联代码的语境里也应该十分协调;如果函数名显得不协调,可能是因为命名本身就比较糟糕(此时可以运用函数改名来解决),也可能是因为函数与内联代码彼此的用途确实有所不同。若是后者的情况,我就不应该用函数调用取代该内联代码。

6 移动语句(Slide Statements)

让存在关联的东西一起出现,可以使代码更容易理解。

如果有几行代码取用了同一个数据结构,那么最好是让它们在一起出现,而不是夹杂在取用其他数据结构的代码中间。

最简单的情况下,我只需使用移动语句就可以让它们聚集起来。

此外还有一种常见的“关联”,就是关于变量的声明和使用。有人喜欢在函数顶部一口气声明函数用到的所有变量,我个人则喜欢在第一次需要使用变量的地方再声明它。

通常来说,把相关代码搜集到一处,往往是另一项重构(通常是在提炼函数)开始之前的准备工作。相比于仅仅把几行相关的代码移动到一起,将它们提炼到独立的函数往往能起到更好的抽象效果。但如果起先存在关联的代码就没有彼此在一起,那么我也很难应用提炼函数的手法。

7 拆分循环(Split Loop)

你常常能见到一些身兼多职的循环,它们一次做了两三件事情,不为别的,就因为这样可以只循环一次。但如果你在一次循环中做了两件不同的事,那么每当需要修改循环时,你都得同时理解这两件事情。

如果能够将循环拆分,让一个循环只做一件事情,那就能确保每次修改时你只需要理解要修改的那块代码的行为就可以了。拆分循环还能让每个循环更容易使用。如果一个循环只计算一个值,那么它直接返回该值即可;但如果循环做了太多件事,那就只得返回结构型数据或者通过局部变量传值了。

因此,一般拆分循环后,我还会紧接着对拆分得到的循环应用提炼函数。这项重构手法可能让许多程序员感到不安,因为它会迫使你执行两次循环。对此,我的建议是:先进行重构,然后再进行性能优化。

我得先让代码结构变得清晰,才能做进一步优化:如果重构之后该循环确实成了性能的瓶颈,届时再把拆开的循环合到一起也很容易。

但实际情况是,即使处理的列表数据更多一些,循环本身也很少成为性能瓶颈,更何况拆分出循环来通常还使一些更强大的优化手段变得可能。

9 移除死代码(Remove Dead Code)

事实上,我们部署到生产环境甚至是用户设备上的代码,从来未因代码量太大而产生额外费用。就算有几行用不上的代码,似乎也不会因此拖慢系统速度,或者占用过多的内存,大多数现代的编译器还会自动将无用的代码移除。

但当你尝试阅读代码、理解软件的运作原理时,无用代码确实会带来很多额外的思维负担。它们周围没有任何警示或标记能告诉程序员,让他们能够放心忽略这段函数,因为已经没有任何地方使用它了。

当程序员花费了许多时间,尝试理解它的工作原理时,却发现无论怎么修改这段代码都无法得到期望的输出。

一旦代码不再被使用,我们就该立马删除它。有可能以后又会需要这段代码,但我从不担心这种情况;就算真的发生,我也可以从版本控制系统里再次将它翻找出来。如果我真的觉得日后它极有可能再度启用,那还是要删掉它,只不过可以在代码里留一段注释,提一下这段代码的存在,以及它被移除的那个提交版本号。但老实讲,我已经记不得我上次撰写这样的注释是什么时候了,当然也未曾因为不写而感到过遗憾。

在以前,业界对于死代码的处理态度多是注释掉它。在版本控制系统还未普及、用起来又不太方便的年代,这样做有其道理;但现在版本控制系统已经相当普及。如今哪怕是一个极小的代码库我都会把它放进版本控制,这些无用代码理应可以放心清理了。