代码重构专题文章
参考文档
前言
重构:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。本质上说,重构就是在代码写好之后改进它的设计。
一、重构,第一个示例
是需求的变化使重构变得必要。
如果要给程序添加一个特性,但发现代码因缺乏良好的结构而不易于进行更改,那就先重构程序,使其比较容易添加该特性,然后再添加该特性。
重构技术就是以微小的步伐修改程序,并且在每次修改后就运行测试。小的步子可以更快前进,请保证代码永远处于可工作状态。
二、重构的原则
两顶帽子
Kent Beck 提出了“两顶帽子”的比喻。使用重构技术开发软件时,我把自己的时间分配给两种截然不同的行为:添加新功能和重构。添加新功能时,我不应该修改既有代码,只管添加新功能。通过添加测试并让测试正常运行,我可以衡量自己的工作进度。重构时我就不能再添加功能,只管调整代码的结构。此时我不应该添加任何测试(除非发现有先前遗漏的东西),只在绝对必要(用以处理接口变化)时才修改测试。
为何重构
- 改进软件的设计,有助于代码维持该有的形态。
- 使软件更容易理解。
- 帮助找到bug,写出健壮的代码。
- 提高编程速度。
何时重构
- 添加新功能之前。
- 糟糕的代码逻辑、函数命名等不易懂。
- 捡垃圾式子重构,每次经过这段代码时都把它变好一点点。
- 上述三种是见机行事的重构,此外还有有计划的重构。
- 长期重构,每当有人靠近“重构区”的代码,就把它朝想要改进的方向推动一点。
- 复查代码(Code Review)时重构。
- 怎么对经理说?不要跟经理说!
- 不需要修改/阅读/理解...的代码不需要重构,重写难度更低时不重构。
重构的挑战
-
普遍认为“延缓新功能开发”
-
受限于代码所有权
-
分支集成回主线困难(如语义冲突等)。
持续集成(Continuous Integration,CI),也叫“基于主干开发”(Trunk-Based Development)。在使用 CI 时,每个团队成员每天至少向主线集成一次。这个实践避免了任何分支彼此差异太大,从而极大地降低了合并的难度。
-
“自测试的代码”这个要求太高
-
遗留代码面临没测试等窘境
-
数据库的重构,将多个小修改串联起来
-
三大实践——自测试代码、持续集成、重构——彼此之间有着很强的协同效应
-
重构应该发生在经常被执行的代码上
三、代码的坏味道
-
神秘命名(Mysterious Name): 变量、函数、类名晦涩难懂,无法直观表达其用途。
-
重复代码(Duplicated Code): 相同或相似的代码块在多处出现。
-
过长函数(Long Function): 函数包含过多逻辑,难以理解和维护。
-
过长参数列表(Long Parameter List): 函数参数过多,增加调用难度和出错概率。
-
全局数据(Global Data)
-
可变数据(Mutable Data): 全局变量和频繁变更的数据,导致难以追踪和控制状态。
-
发散式变化(Divergent Change): 某个类因不同原因而在不同方向上发生变化。
-
霰弹式修改(Shotgun Surgery): 一种变化需要在多个类中进行许多小修改。
-
依恋情结(Feature Envy): 函数对其他模块的数据或函数过于“依恋”,频繁交互。
-
数据泥团(Data Clumps): 相同的三四项数据总是结伴出现(例如在多个函数签名中)。
重构建议: 运用**提炼类(Extract Class)**将它们提炼到一个独立对象中。
-
基本类型偏执(Primitive Obsession): 过于依赖基本类型(如字符串、整数)来表示领域概念,而非创建小对象(如
Money、Coordinate、Range)。 -
重复的 Switch(Repeated Switches): 相同或类似的
switch/case结构散布在代码库中。重构建议: 考虑多态来替代
switch。 -
循环语句(Loops): 传统
for/while循环可能使代码意图模糊。重构建议: 优先使用函数式编程中的 Stream API 或其他高阶函数,使代码更简洁、表达力更强。
-
冗赘的元素(Lazy Element): 类、函数等结构看似提供抽象,实则无用,徒增复杂度。
-
夸夸其谈通用性(Speculative Generality): 过度设计、引入了目前用不上的通用功能,反而增加了维护成本。
-
临时字段(Temporary Field): 某些字段只在特定情况下被赋值和使用。
-
过长的消息链(Message Chains):
objA.getObjB().getObjC().getSomeProperty(),这种链式调用使得代码对深层结构过于依赖。 -
中间人(Middle Man): 类通过委托将所有工作转发给另一个类,自身没有实质性功能。
-
内幕交易(Insider Trading): 模块之间过度了解彼此的内部实现细节,导致紧密耦合。
-
过大的类(Large Class): 类承担了过多职责,拥有过多的字段和方法。
-
异曲同工的类(Alternative Classes with Different Interfaces): 两个类实现相似的功能,但接口不一致。
-
纯数据类(Data Class): 只有字段和访问器(getter/setter),缺少行为。
重构建议: 纯数据类往往意味着行为被放置在了错误的地方,应考虑将相关行为移入数据类。
-
被拒绝的遗赠(Refused Bequest): 子类不需要或不希望继承超类的某些方法或数据。
-
注释(Comments): 存在大量解释性注释,往往是因为代码本身不够清晰易懂。
重构建议: 好的代码应该是自解释的,尽量用代码本身表达意图,减少不必要的注释。
四、构筑测试体系
✅ 为什么需要测试?
- 自动化测试可以极大提高开发速度。
- 测试的重点应该放在“最担心出错的部分”。
- 一旦测试运行通过,就能大胆重构。
🕒 何时写测试?
- 在写功能代码之前先写测试(TDD 思路)。
- 这样能让注意力集中在 接口设计 而非实现细节。
🧰 最佳实践
- 测试应该是自验证的(自测试代码)。
- 测试执行应该像编译一样简单,最好能在每次构建时自动运行。
- 避免共享测试夹具,尽量保持独立性。
五、介绍重构目录
重构的记录格式
| 组成部分 | 简介 | 备注 |
|---|---|---|
| 名称(name) | 同时列出常见的别名,方便查找 | |
| 速写(sketch) | 简要描述重构手法,帮助快速理解 | 核心思想的概括 |
| 动机(motivation) | “为什么需要做这个重构”和“什么情况下不该做这个重构” | 明确重构的适用场景和价值 |
| 做法(mechanics) | 如何一步步进行此重构,通常包含具体的操作步骤 | 可操作性指导 |
| 范例(examples) | 以一个十分简单的例子说明此重构手法如何运作 | 实际应用演示 |
六、第一组重构
-
提炼函数(Extract Function)--“将意图与实现分开”:如果你需要花时间浏览一段代码才能弄清它到底在干什么,那么就应该将其提炼到一个函数中,并根据它所做的事为其命名。
-
内联函数(Inline Function)--通过内联手法,我可以找出那些有用的间接层,同时将无用的间接层去除。
-
提炼变量(Extract Variable)/引入解释性变量(Introduce Explaining Variable)--表达式有可能非常复杂而难以阅读。这种情况下,局部变量可以帮助我们将表达式分解为比较容易管理的形式。在面对一块复杂逻辑时,局部变量使我能给其中的一部分命名,这样我就能更好地理解这部分逻辑是要干什么。
-
内联变量(Inline Variable)/内联临时变量(Inline Temp)--在一个函数内部,变量能给表达式提供有意义的名字,因此通常变量是好东西。但有时候,这个名字并不比表达式本身更具表现力。还有些时候,变量可能会妨碍重构附近的代码。若果真如此,就应该通过内联的手法消除变量。
-
改变函数声明(Change Function Declaration)--有一个改进函数名字的好办法:先写一句注释描述这个函数的用途,再把这句注释变成函数的名字。修改参数列表不仅能增加函数的应用范围,还能改变连接一个模块所需的条件,从而去除不必要的耦合。
范例:函数改名(迁移式做法)
还是这个名字太过简略的函数:
function circum(radius) { return 2 * Math.PI * radius; }按照迁移式做法,我首先要对整个函数体使用提炼函数(106):
function circum(radius) { return circumference(radius); } function circumference(radius) { return 2 * Math.PI * radius; }此时我要执行测试,然后对旧函数使用内联函数(115):找出所有调用旧函数的地方,将其改为调用新函数。每次修改之后都可以执行测试,这样我就可以小步前进,每次修改一处调用者。所有调用者都修改完之后,我就可以删除旧函数。
大多数重构手法只用于修改我有权修改的代码,但这个重构手法同样适用于已发布 API——使用这些 API 的代码我无权修改。以上面的代码为例,创建出 circumference 函数之后,我就可以暂停重构,并(如果可以的话)将 circum 函数标记为 deprecated。然后我就耐心等待客户端改用 circumference 函数,等他们都改完了,我再删除 circum 函数。即便永远也抵达不了“删除 circum 函数”这个快乐的终点,至少新代码有了一个更好的名字。
-
封装变量(Encapsulate Variable)--前面的基本重构手法只封装了对最外层数据的引用。但也有很多时候,我需要把封装做得更深入,不仅控制对变量引用的修改,还要控制对变量内容的修改。
-
变量改名(Rename Variable)
-
引入参数对象(Introduce Parameter Object)--我常会看见,一组数据项总是结伴同行,出没于一个又一个函数。这样一组数据就是所谓的数据泥团,我喜欢代之以一个数据结构。
-
函数组合成类(Combine Functions into Class)--如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),我就认为,是时候组建一个类了。
-
函数组合成变换(Combine Functions into Transform)--在软件中,经常需要把数据“喂”给一个程序,让它再计算出各种派生信息。这些派生数值可能会在几个不同地方用到,因此这些计算逻辑也常会在用到派生数据的地方重复。我更愿意把所有计算派生数据的逻辑收拢到一处,这样始终可以在固定的地方找到和更新这些逻辑,避免到处重复。
-
拆分阶段(Split Phase)--每当看见一段代码在同时处理两件不同的事,我就想把它拆分成各自独立的模块,因为这样到了需要修改的时候,我就可以单独处理每个主题,而不必同时在脑子里考虑两个不同的主题。如果运气够好的话,我可能只需要修改其中一个模块,完全不用回忆起另一个模块的诸般细节。
七、封装
-
封装记录(Encapsulate Record)/以数据类取代记录(Replace Record with Data Class)--对象的用户不必追究存储的细节和计算的过程。同时,这种封装还有助于字段的改名。
-
封装集合(Encapsulate Collection)--我喜欢封装程序中的所有可变数据。这使我很容易看清楚数据被修改的地点和修改方式,这样当我需要更改数据结构时就非常方便。不要让集合的取值函数返回原始集合,这就避免了客户端的意外修改。一种避免直接修改集合的方法是,永远不直接返回集合的值。也许最常见的做法是,为集合提供一个取值函数,但令其返回一个集合的副本。总的来讲,我觉得对集合保持适度的审慎是有益的,我宁愿多复制一份数据,也不愿去调试因意外修改集合招致的错误。修改操作并不总是显而易见的。
-
以对象取代基本类型(Replace Primitive with Object)/以对象取代数据值(Replace Data Value with Object)/以类取代类型码(Replace Type Code with Class)--一旦我发现对某个数据的操作不仅仅局限于打印时,我就会为它创建一个新类。一开始这个类也许只是简单包装一下简单类型的数据,不过只要类有了,日后添加的业务逻辑就有地可去了。
-
以查询取代临时变量(Replace Temp with Query)--临时变量的一个作用是保存某段代码的返回值,以便在函数的后面部分使用它。临时变量允许我引用之前的值,既能解释它的含义,还能避免对代码进行重复计算。但尽管使用变量很方便,很多时候还是值得更进一步,将它们抽取成函数。
-
提炼类(Extract Class)--设想你有一个维护大量函数和数据的类。这样的类往往因为太大而不易理解。此时你需要考虑哪些部分可以分离出去,并将它们分离到一个独立的类中。
-
内联类(Inline Class)--内联类正好与提炼类(182)相反。如果一个类不再承担足够责任,不再有单独存在的理由(这通常是因为此前的重构动作移走了这个类的责任),我就会挑选这一“萎缩类”的最频繁用户(也是一个类),以本手法将“萎缩类”塞进另一个类中。应用这个手法的另一个场景是,我手头有两个类,想重新安排它们肩负的职责,并让它们产生关联。这时我发现先用本手法将它们内联成一个类再用提炼类(182)去分离其职责会更加简单。这是重新组织代码时常用的做法:有时把相关元素一口气搬移到位更简单,但有时先用内联手法合并各自的上下文,再使用提炼手法再次分离它们会更合适。
-
隐藏委托关系(Hide Delegate)--如果某些客户端先通过服务对象的字段得到另一个对象(受托类),然后调用后者的函数,那么客户就必须知晓这一层委托关系。万一受托类修改了接口,变化会波及通过服务对象使用它的所有客户端。我可以在服务对象上放置一个简单的委托函数,将委托关系隐藏起来,从而去除这种依赖。这么一来,即使将来委托关系发生变化,变化也只会影响服务对象,而不会直接波及所有客户端。
-
移除中间人(Remove Middle Man)--在隐藏委托关系(189)的“动机”一节中,我谈到了“封装受托对象”的好处。但是这层封装也是有代价的。每当客户端要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数。随着受托类的特性(功能)越来越多,更多的转发函数就会使人烦躁。服务类完全变成了一个中间人(81),此时就应该让客户直接调用受托类。(这个味道通常在人们狂热地遵循迪米特法则时悄然出现。我总觉得,如果这条法则当初叫作“偶尔有用的迪米特建议”,如今能少很多烦恼。)
很难说什么程度的隐藏才是合适的。还好,有了隐藏委托关系(189)和删除中间人,我大可不必操心这个问题,因为我可以在系统运行过程中不断进行调整。随着代码的变化,“合适的隐藏程度”这个尺度也相应改变。6 个月前恰如其分的封装,现今可能就显得笨拙。重构的意义就在于:你永远不必说对不起——只要把出问题的地方修补好就行了。
-
替换算法(Substitute Algorithm)--如果我发现做一件事可以有更清晰的方式,我就会用比较清晰的方式取代复杂的方式。
八、搬移特性
-
搬移函数(Move Function)/搬移函数(Move Method)--搬移函数最直接的一个动因是,它频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。此时,让它去与那些更亲密的元素相会,通常能取得更好的封装效果,因为系统别处就可以减少对当前模块的依赖。
-
搬移字段(Move Field)--数据结构才是一个健壮程序的根基。即便经验再丰富,技能再熟练,我仍然发现我在进行初版设计时往往还是会犯错。如果我发现数据结构已经不适应于需求,就应该马上修缮它。我开始寻思搬移数据。
-
搬移语句到函数(Move Statements into Function)--要维护代码库的健康发展,需要遵守几条黄金守则,其中最重要的一条当属“消除重复”。如果我发现调用某个函数时,总有一些相同的代码也需要每次执行,那么我会考虑将此段代码合并到函数里头。这样,日后对这段代码的修改只需改一处地方,还能对所有调用者同时生效。如果将来代码对不同的调用者需有不同的行为,那时再通过搬移语句到调用者(217)将它(或其一部分)搬移出来也十分简单。
如果某些语句与一个函数放在一起更像一个整体,并且更有助于理解,那我就会毫不犹豫地将语句搬移到函数里去。如果它们与函数不像一个整体,但仍应与函数一起执行,那我可以用提炼函数(106)将语句和函数一并提炼出去。
-
搬移语句到调用者(Move Statements to Callers)--函数边界发生偏移的一个征兆是,以往在多个地方共用的行为,如今需要在某些调用点面前表现出不同的行为。于是,我们得把表现不同的行为从函数里挪出,并搬移到其调用处。
-
以函数调用取代内联代码(Replace Inline Code with Function Call)--善用函数可以帮助我将相关的行为打包起来,这对于提升代码的表达力大有裨益—— 一个命名良好的函数,本身就能极好地解释代码的用途,使读者不必了解其细节。配合一些库函数使用,会使本手法效果更佳,因为我甚至连函数体都不需要自己编写了,库已经提供了相应的函数。
-
移动语句(Slide Statements)/合并重复的代码片段(Consolidate Duplicate Conditional Fragments)--让存在关联的东西一起出现,可以使代码更容易理解。如果有几行代码取用了同一个数据结构,那么最好是让它们在一起出现,而不是夹杂在取用其他数据结构的代码中间。
-
拆分循环(Split Loop)--你常常能见到一些身兼多职的循环,它们一次做了两三件事情,不为别的,就因为这样可以只循环一次。但如果你在一次循环中做了两件不同的事,那么每当需要修改循环时,你都得同时理解这两件事情。如果能够将循环拆分,让一个循环只做一件事情,那就能确保每次修改时你只需要理解要修改的那块代码的行为就可以了。本手法的意义不仅在于拆分出循环本身,而且在于它为进一步优化提供了良好的起点——下一步我通常会寻求将每个循环提炼到独立的函数中。
-
以管道取代循环(Replace Loop with Pipeline)--与大多数程序员一样,我入行的时候也有人告诉我,迭代一组集合时得使用循环。不过时代在发展,如今越来越多的编程语言都提供了更好的语言结构来处理迭代过程,这种结构就叫作集合管道(collection pipeline)。集合管道[mf-cp]是这样一种技术,它允许我使用一组运算来描述集合的迭代过程,其中每种运算接收的入参和返回值都是一个集合。这类运算有很多种,最常见的则非 map 和 filter 莫属:map 运算是指用一个函数作用于输入集合的每一个元素上,将集合变换成另外一个集合的过程;filter 运算是指用一个函数从输入集合中筛选出符合条件的元素子集的过程。运算得到的集合可以供管道的后续流程使用。我发现一些逻辑如果采用集合管道来编写,代码的可读性会更强——我只消从头到尾阅读一遍代码,就能弄清对象在管道中间的变换过程。
-
移除死代码(Remove Dead Code)
九、重新组织数据
-
拆分变量(Split Variable)/移除对参数的赋值(Remove Assignments to Parameters)/分解临时变量(Split Temp)--除了“循环变量”和“结果收集变量”这两种情况,还有很多变量用于保存一段冗长代码的运算结果,以便稍后使用。这种变量应该只被赋值一次。如果它们被赋值超过一次,就意味它们在函数中承担了一个以上的责任。如果变量承担多个责任,它就应该被替换(分解)为多个变量,每个变量只承担一个责任。
-
字段改名(Rename Field)--命名很重要,对于程序中广泛使用的记录结构,其中字段的命名格外重要。数据结构对于帮助阅读者理解特别重要。
-
以查询取代派生变量(Replace Derived Variable with Query)--有些变量其实可以很容易地随时计算出来。如果能去掉这些变量,也算朝着消除可变性的方向迈出了一大步。计算常能更清晰地表达数据的含义,而且也避免了“源数据修改时忘了更新派生变量”的错误。
-
将引用对象改为值对象(Change Reference to Value)--在把一个对象(或数据结构)嵌入另一个对象时,位于内部的这个对象可以被视为引用对象,也可以被视为值对象。两者最明显的差异在于如何更新内部对象的属性:如果将内部对象视为引用对象,在更新其属性时,我会保留原对象不动,更新内部对象的属性;如果将其视为值对象,我就会替换整个内部对象,新换上的对象会有我想要的属性值。
如果把一个字段视为值对象,我可以把内部对象的类也变成值对象[mf-vo]。值对象通常更容易理解,主要因为它们是不可变的。一般说来,不可变的数据结构处理起来更容易。我可以放心地把不可变的数据值传给程序的其他部分,而不必担心对象中包装的数据被偷偷修改。我可以在程序各处复制值对象,而不必操心维护内存链接。值对象在分布式系统和并发系统中尤为有用。
值对象和引用对象的区别也告诉我,何时不应该使用本重构手法。如果我想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用。
-
将值对象改为引用对象(Change Value to Reference)--一个数据结构中可能包含多个记录,而这些记录都关联到同一个逻辑数据结构。例如,我可能会读取一系列订单数据,其中有多条订单属于同一个顾客。遇到这样的共享关系时,既可以把顾客信息作为值对象来看待,也可以将其视为引用对象。如果将其视为值对象,那么每份订单数据中都会复制顾客的数据;而如果将其视为引用对象,对于一个顾客,就只有一份数据结构,会有多个订单与之关联。
如果顾客数据永远不修改,那么两种处理方式都合理。把同一份数据复制多次可能会造成一点困扰,但这种情况也很常见,不会造成太大问题。过多的数据复制有可能会造成内存占用的问题,但就跟所有性能问题一样,这种情况并不常见。
如果共享的数据需要更新,将其复制多份的做法就会遇到巨大的困难。此时我必须找到所有的副本,更新所有对象。只要漏掉一个副本没有更新,就会遭遇麻烦的数据不一致。这种情况下,可以考虑将多份数据副本变成单一的引用,这样对顾客数据的修改就会立即反映在该顾客的所有订单中。
把值对象改为引用对象会带来一个结果:对于一个客观实体,只有一个代表它的对象。这通常意味着我会需要某种形式的仓库,在仓库中可以找到所有这些实体对象。只为每个实体创建一次对象,以后始终从仓库中获取该对象。
十、简化条件逻辑
-
分解条件表达式(Decompose Conditional)--和任何大块头代码一样,我可以将它分解为多个独立的函数,根据每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新函数,从而更清楚地表达自己的意图。
-
合并条件表达式(Consolidate Conditional Expression)--有时我会发现这样一串条件检查:检查条件各不相同,最终行为却一致。如果发现这种情况,就应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式。之所以要合并条件代码,有两个重要原因。首先,合并后的条件代码会表述“实际上只有一次条件检查,只不过有多个并列条件需要检查而已”,从而使这一次检查的用意更清晰。
-
以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)--条件表达式通常有两种风格。第一种风格是:两个条件分支都属于正常行为。第二种风格则是:只有一个条件分支是正常行为,另一个分支则是异常的情况。
这两类条件表达式有不同的用途,这一点应该通过代码表现出来。如果两条分支都是正常行为,就应该使用形如 if...else...的条件表达式;如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”(guard clauses)。
以卫语句取代嵌套条件表达式的精髓就是:给某一条分支以特别的重视。如果使用 if-then-else 结构,你对 if 分支和 else 分支的重视是同等的。这样的代码结构传递给阅读者的消息就是:各个分支有同样的重要性。卫语句就不同了,它告诉阅读者:“这种情况不是本函数的核心逻辑所关心的,如果它真发生了,请做一些必要的整理工作,然后退出。”
“每个函数只能有一个入口和一个出口”的观念,根深蒂固于某些程序员的脑海里。我发现,当我处理他们编写的代码时,经常需要使用以卫语句取代嵌套条件表达式。现今的编程语言都会强制保证每个函数只有一个入口,至于“单一出口”规则,其实不是那么有用。在我看来,保持代码清晰才是最关键的:如果单一出口能使这个函数更清楚易读,那么就使用单一出口;否则就不必这么做。
-
以多态取代条件表达式(Replace Conditional with Polymorphism)--复杂的条件逻辑是编程中最难理解的东西之一,因此我一直在寻求给条件逻辑添加结构。很多时候,我发现可以将条件逻辑拆分到不同的场景(或者叫高阶用例),从而拆解复杂的条件逻辑。这种拆分有时用条件逻辑本身的结构就足以表达,但使用类和多态能把逻辑的拆分表述得更清晰。
一个常见的场景是:我可以构造一组类型,每个类型处理各自的一种条件逻辑。例如,我会注意到,图书、音乐、食品的处理方式不同,这是因为它们分属不同类型的商品。最明显的征兆就是有好几个函数都有基于类型代码的 switch 语句。若果真如此,我就可以针对 switch 语句中的每种分支逻辑创建一个类,用多态来承载各个类型特有的行为,从而去除重复的分支逻辑。
另一种情况是:有一个基础逻辑,在其上又有一些变体。基础逻辑可能是最常用的,也可能是最简单的。我可以把基础逻辑放进超类,这样我可以首先理解这部分逻辑,暂时不管各种变体,然后我可以把每种变体逻辑单独放进一个子类,其中的代码着重强调与基础逻辑的差异。
多态是面向对象编程的关键特性之一。跟其他一切有用的特性一样,它也很容易被滥用。我曾经遇到有人争论说所有条件逻辑都应该用多态取代。我不赞同这种观点。我的大部分条件逻辑只用到了基本的条件语句——if/else 和 switch/case,并不需要劳师动众地引入多态。但如果发现如前所述的复杂条件逻辑,多态是改善这种情况的有力工具。
-
引入特例(Introduce Special Case)/引入 Null 对象(Introduce Null Object)--一种常见的重复代码是这种情况:一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。如果我发现代码库中有多处以同样方式应对同一个特殊值,我就会想要把这个处理逻辑收拢到一处。
处理这种情况的一个好办法是使用“特例”(Special Case)模式:创建一个特例元素,用以表达对这种特例的共用行为的处理。这样我就可以用一个函数调用取代大部分特例检查逻辑。
-
引入断言(Introduce Assertion)--常常会有这样一段代码:只有当某个条件为真时,该段代码才能正常运行。例如,平方根计算只对正值才能进行,又例如,某个对象可能假设一组字段中至少有一个不等于 null。
这样的假设通常并没有在代码中明确表现出来,你必须阅读整个算法才能看出。有时程序员会以注释写出这样的假设,而我要介绍的是一种更好的技术——使用断言明确标明这些假设。
对这个例子而言,我更愿意把断言放在设值函数上。如果在 applyDiscount 函数处发生断言失败,我还得先费力搞清楚非法的折扣率值起初是从哪儿放进去的。
断言是帮助我们跟踪 bug 的最后一招,所以,或许听来讽刺,只有当我认为断言绝对不会失败的时候,我才会使用断言。
十一、重构API
-
将查询函数和修改函数分离(Separate Query from Modifier)--任何有返回值的函数,都不应该有看得到的副作用——命令与查询分离
-
函数参数化(Parameterize Function)/令函数携带参数(Parameterize Method)--如果我发现两个函数逻辑非常相似,只有一些字面量值不同,可以将其合并成一个函数,以参数的形式传入不同的值,从而消除重复。这个重构可以使函数更有用,因为重构后的函数还可以用于处理其他的值。
-
移除标记参数(Remove Flag Argument)/以明确函数取代参数(Replace Parameter with Explicit Methods)--我不喜欢标记参数,因为它们让人难以理解到底有哪些函数可以调用、应该怎么调用。拿到一份 API 以后,我首先看到的是一系列可供调用的函数,但标记参数却隐藏了函数调用中存在的差异性。使用这样的函数,我还得弄清标记参数有哪些可用的值。布尔型的标记尤其糟糕,因为它们不能清晰地传达其含义——在调用一个函数时,我很难弄清 true 到底是什么意思。如果明确用一个函数来完成一项单独的任务,其含义会清晰得多。
如果调用者传入的是程序中流动的数据,这样的参数不算标记参数;只有调用者直接传入字面量值,这才是标记参数。另外,在函数实现内部,如果参数值只是作为数据传给其他函数,这就不是标记参数;只有参数值影响了函数内部的控制流,这才是标记参数。
-
保持对象完整(Preserve Whole Object)--如果我看见代码从一个记录结构中导出几个值,然后又把这几个值一起传递给一个函数,我会更愿意把整个记录传给这个函数,在函数体内部导出所需的值。
-
以查询取代参数(Replace Parameter with Query)/以函数取代参数(Replace Parameter with Method)--函数的参数列表应该总结该函数的可变性,标示出函数可能体现出行为差异的主要方式。和任何代码中的语句一样,参数列表应该尽量避免重复,并且参数列表越短就越容易理解。如果调用函数时传入了一个值,而这个值由函数自己来获得也是同样容易,这就是重复。
如果想要去除的参数值只需要向另一个参数查询就能得到,这是使用以查询取代参数最安全的场景。如果可以从一个参数推导出另一个参数,那么几乎没有任何理由要同时传递这两个参数。
-
以参数取代查询(Replace Query with Parameter)--在浏览函数实现时,我有时会发现一些令人不快的引用关系,例如,引用一个全局变量,或者引用另一个我想要移除的元素。为了解决这些令人不快的引用,我需要将其替换为函数参数,从而将处理引用关系的责任转交给函数的调用者。
需要使用本重构的情况大多源于我想要改变代码的依赖关系——为了让目标函数不再依赖于某个元素,我把这个元素的值以参数形式传递给该函数。这里需要注意权衡:如果把所有依赖关系都变成参数,会导致参数列表冗长重复;如果作用域之间的共享太多,又会导致函数间依赖过度。我一向不善于微妙的权衡,所以“能够可靠地改变决定”就显得尤为重要,这样随着我的理解加深,程序也能从中受益。
-
移除设值函数(Remove Setting Method)--如果为某个字段提供了设值函数,这就暗示这个字段可以被改变。如果不希望在对象创建之后此字段还有机会被改变,那就不要为它提供设值函数(同时将该字段声明为不可变)。这样一来,该字段就只能在构造函数中赋值,我“不想让它被修改”的意图会更加清晰,并且可以排除其值被修改的可能性——这种可能性往往是非常大的。
-
以工厂函数取代构造函数(Replace Constructor with Factory Function)--很多面向对象语言都有特别的构造函数,专门用于对象的初始化。需要新建一个对象时,客户端通常会调用构造函数。但与一般的函数相比,构造函数又常有一些丑陋的局限性。例如,Java 的构造函数只能返回当前所调用类的实例,也就是说,我无法根据环境或参数信息返回子类实例或代理对象;构造函数的名字是固定的,因此无法使用比默认名字更清晰的函数名;构造函数需要通过特殊的操作符来调用(在很多语言中是 new 关键字),所以在要求普通函数的场合就难以使用。
工厂函数就不受这些限制。工厂函数的实现内部可以调用构造函数,但也可以换成别的方式实现。
-
以命令取代函数(Replace Function with Command)/以函数对象取代函数(Replace Method with Method Object)--函数,不管是独立函数,还是以方法(method)形式附着在对象上的函数,是程序设计的基本构造块。不过,将函数封装成自己的对象,有时也是一种有用的办法。这样的对象我称之为“命令对象”(command object),或者简称“命令”(command)。这种对象大多只服务于单一函数,获得对该函数的请求,执行该函数,就是这种对象存在的意义。
与普通的函数相比,命令对象提供了更大的控制灵活性和更强的表达能力。除了函数调用本身,命令对象还可以支持附加的操作,例如撤销操作。我可以通过命令对象提供的方法来设值命令的参数值,从而支持更丰富的生命周期管理能力。我可以借助继承和钩子对函数行为加以定制。如果我所使用的编程语言支持对象但不支持函数作为一等公民,通过命令对象就可以给函数提供大部分相当于一等公民的能力。同样,即便编程语言本身并不支持嵌套函数,我也可以借助命令对象的方法和字段把复杂的函数拆解开,而且在测试和调试过程中可以直接调用这些方法。
所有这些都是使用命令对象的好理由,所以我要做好准备,一旦有需要,就能把函数重构成命令。不过我们不能忘记,命令对象的灵活性也是以复杂性作为代价的。所以,如果要在作为一等公民的函数和命令对象之间做个选择,95%的时候我都会选函数。只有当我特别需要命令对象提供的某种能力而普通的函数无法提供这种能力时,我才会考虑使用命令对象。
-
以函数取代命令(Replace Command with Function)--命令对象为处理复杂计算提供了强大的机制。借助命令对象,可以轻松地将原本复杂的函数拆解为多个方法,彼此之间通过字段共享状态;拆解后的方法可以分别调用;开始调用之前的数据状态也可以逐步构建。但这种强大是有代价的。大多数时候,我只是想调用一个函数,让它完成自己的工作就好。如果这个函数不是太复杂,那么命令对象可能显得费而不惠,我就应该考虑将其变回普通的函数。
十二、处理继承关系
-
函数上移(Pull Up Method)--避免重复代码是很重要的。重复的两个函数现在也许能够正常工作,但假以时日却只会成为滋生 bug 的温床。无论何时,只要系统内出现重复,你就会面临“修改其中一个却未能修改另一个”的风险。通常,找出重复也有一定的难度。
-
字段上移(Pull Up Field)--如果各子类是分别开发的,或者是在重构过程中组合起来的,你常会发现它们拥有重复特性,特别是字段更容易重复。这样的字段有时拥有近似的名字,但也并非绝对如此。判断若干字段是否重复,唯一的办法就是观察函数如何使用它们。如果它们被使用的方式很相似,我就可以将它们提升到超类中去(在大多数语言中 protected 权限便已足够)。
-
构造函数本体上移(Pull Up Constructor Body)--构造函数是很奇妙的东西。它们不是普通函数,使用它们比使用普通函数受到更多的限制。
如果我看见各个子类中的函数有共同行为,我的第一个念头就是使用提炼函数(106)将它们提炼到一个独立函数中,然后使用函数上移(350)将这个函数提升至超类。但构造函数的出现打乱了我的算盘,因为它们附加了特殊的规则,对一些做法与函数的调用次序有所限制。要对付它们,我需要略微不同的做法。
-
函数下移(Push Down Method)--如果超类中的某个函数只与一个(或少数几个)子类有关,那么最好将其从超类中挪走,放到真正关心它的子类中去。这项重构手法只有在超类明确知道哪些子类需要这个函数时适用。如果超类不知晓这个信息,那我就得用以多态取代条件表达式(272),只留些共用的行--在超类。
-
字段下移(Push Down Field)--如果某个字段只被一个子类(或者一小部分子类)用到,就将其搬移到需要该字段的子类中。
-
以子类取代类型码(Replace Type Code with Subclasses)--软件系统经常需要表现“相似但又不同的东西”,比如员工可以按职位分类(工程师、经理、销售),订单可以按优先级分类(加急、常规)。表现分类关系的第一种工具是类型码字段——根据具体的编程语言,可能实现为枚举、符号、字符串或者数字。类型码的取值经常来自给系统提供数据的外部服务。
大多数时候,有这样的类型码就够了。但也有些时候,我可以再多往前一步,引入子类。继承有两个诱人之处。首先,你可以用多态来处理条件逻辑。如果有几个函数都在根据类型码的取值采取不同的行为,多态就显得特别有用。引入子类之后,我可以用以多态取代条件表达式(272)来处理这些函数。
-
移除子类(Remove Subclass)/以字段取代子类(Replace Subclass with Fields)--子类很有用,它们为数据结构的多样和行为的多态提供支持,它们是针对差异编程的好工具。但随着软件的演化,子类所支持的变化可能会被搬移到别处,甚至完全去除,这时子类就失去了价值。有时添加子类是为了应对未来的功能,结果构想中的功能压根没被构造出来,或者用了另一种方式构造,使该子类不再被需要了。
-
提炼超类(Extract Superclass)--如果我看见两个类在做相似的事,可以利用基本的继承机制把它们的相似之处提炼到超类。我可以用字段上移(353)把相同的数据搬到超类,用函数上移(350)搬移相同的行为。
-
折叠继承体系(Collapse Hierarchy)--在重构类继承体系时,我经常把函数和字段上下移动。随着继承体系的演化,我有时会发现一个类与其超类已经没多大差别,不值得再作为独立的类存在。此时我就会把超类和子类合并起来。
-
以委托取代子类(Replace Subclass with Delegate)--如果一个对象的行为有明显的类别之分,继承是很自然的表达方式。我可以把共用的数据和行为放在超类中,每个子类根据需要覆写部分特性。在面向对象语言中,继承很容易实现,因此也是程序员熟悉的机制。
但继承也有其短板。最明显的是,继承这张牌只能打一次。导致行为不同的原因可能有多种,但继承只能用于处理一个方向上的变化。比如说,我可能希望“人”的行为根据“年龄段”不同,并且根据“收入水平”不同。使用继承的话,子类可以是“年轻人”和“老人”,也可以是“富人”和“穷人”,但不能同时采用两种继承方式。
更大的问题在于,继承给类之间引入了非常紧密的关系。在超类上做任何修改,都很可能破坏子类,所以我必须非常小心,并且充分理解子类如何从超类派生。如果两个类的逻辑分处不同的模块、由不同的团队负责,问题就会更麻烦。
这两个问题用委托都能解决。对于不同的变化原因,我可以委托给不同的类。委托是对象之间常规的关系。与继承关系相比,使用委托关系时接口更清晰、耦合更少。因此,继承关系遇到问题时运用以委托取代子类是常见的情况。
(TODO:第一个代码例子没咋看懂,第二个还没看)
-
以委托取代超类(Replace Superclass with Delegate)/以委托取代继承(Replace Inheritance with Delegation)--在对象技术发展早期,有一个经典的误用继承的例子:让栈(stack)继承列表(list)。这个想法的出发点是想复用列表类的数据存储和操作能力。虽说复用是一件好事,但这个继承关系有问题:列表类的所有操作都会出现在栈类的接口上,然而其中大部分操作对一个栈来说并不适用。更好的做法应该是把列表作为栈的字段,把必要的操作委派给列表就行了。首先(尽量)使用继承,如果发现继承有问题,再使用以委托取代超类。