1 将查询函数和修改函数分离(Separate Query from Modifier)
如果某个函数只是提供一个值,没有任何看得到的副作用,那么这是一个很有价值的东西。我可以任意调用这个函数,也可以把调用动作搬到调用函数的其他地方。 这种函数的测试也更容易。简而言之,需要操心的事情少多了。
明确表现出“有副作用”与“无副作用”两种函数之间的差异,是个很好的想法。下面是一条好规则:任何有返回值的函数,都不应该有看得到的副作用——命令与查询分离(Command-Query Separation) 。
如果遇到一个“既有返回值又有副作用”的函数,我就会试着将查询动作从修改动作中分离出来。
2 函数参数化(Parameterize Function)
如果我发现两个函数逻辑非常相似,只有一些字面量值不同,可以将其合并成一个函数,以参数的形式传入不同的值,从而消除重复。
这个重构可以使函数更有用,因为重构后的函数还可以用于处理其他的值。
3 移除标记参数(Remove Flag Argument)
“标记参数”是这样的一种参数:调用者用它来指示被调函数应该执行哪一部分逻辑。
我不喜欢标记参数,因为它们让人难以理解到底有哪些函数可以调用、应该怎么调用。拿到一份API 以后,我首先看到的是一系列可供调用的函数,但标记参数却隐藏了函数调用中存在的差异性。使用这样的函数,我还得弄清标记参数有哪些可用的值。
布尔型的标记尤其糟糕,因为它们不能清晰地传达其含义。在调用一个函数时,我很难弄清 true 到底是什么意思。如果明确用一个函数来完成一项单独的任务,其含义会清晰得多。
并非所有类似这样的参数都是标记参数。如果调用者传入的是程序中流动的数据,这样的参数不算标记参数;只有调用者直接传入字面量值,这才是标记参数。另外,在函数实现内部,如果参数值只是作为数据传给其他函数,这就不是标记参数;
只有参数值影响了函数内部的控制流,这才是标记参数。
移除标记参数不仅使代码更整洁,并且能帮助开发工具更好地发挥作用。去掉标记参数后,代码分析工具能更容易地体现出“高级”和“普通”两种预订逻辑在使用时的区别。
如果一个函数有多个标记参数,可能就不得不将其保留,否则我就得针对各个参数的各种取值的所有组合情况提供明确函数。不过这也是一个信号,说明这个函数可能做得太多,应该考虑是否能用更简单的函数来组合出完整的逻辑。
4 保持对象完整(Preserve Whole Object)
如果我看见代码从一个记录结构中导出几个值,然后又把这几个值一起传递给一个函数,我会更愿意把整个记录传给这个函数,在函数体内部导出所需的值。
“传递整个记录”的方式能更好地应对变化:如果将来被调的函数需要从记录中导出更多的数据,我就不用为此修改参数列表。并且传递整个记录也能缩短参数列表,让函数调用更容易看懂。
如果有很多函数都在使用记录中的同一组数据,处理这部分数据的逻辑常会重复,此时可以把这些处理逻辑搬移到完整对象中去。
也有时我不想采用本重构手法,因为我不想让被调函数依赖完整对象,尤其是两者不在同一个模块中的时候。从一个对象中抽取出几个值,单独对这几个值做某些逻辑操作,这是一种代码坏味道(依恋情结),通常标志着这段逻辑应该被搬移到对象中。
保持对象完整经常发生在引入参数对象之后,我会搜寻使用原来的数据泥团的代码,代之以使用新的对象。如果几处代码都在使用对象的一部分功能,可能意味着应该用提炼类把这一部分功能单独提炼出来。
还有一种常被忽视的情况:调用者将自己的若干数据作为参数,传递给被调用函数。这种情况下,我可以将调用者的自我引用(在 JavaScript 中就是 this)作为参数,直接传递给目标函数。
5 以查询取代参数(Replace Parameter with Query)
函数的参数列表应该总结该函数的可变性,标示出函数可能体现出行为差异的主要方式。和任何代码中的语句一样,参数列表应该尽量避免重复,并且参数列表越短就越容易理解。
如果调用函数时传入了一个值,而这个值由函数自己来获得也是同样容易,这就是重复。这个本不必要的参数会增加调用者的难度,因为它不得不找出正确的参数值,其实原本调用者是不需要费这个力气的。
“同样容易”四个字,划出了一条判断的界限。去除参数也就意味着“获得正确的参数值”的责任被转移:有参数传入时,调用者需要负责获得正确的参数值;参数去除后,责任就被转移给了函数本身。一般而言,我习惯于简化调用方,因此我愿意把责任移交给函数本身,但如果函数难以承担这份责任,就另当别论了。
不使用以查询取代参数最常见的原因是,移除参数可能会给函数体增加不必要的依赖关系——迫使函数访问某个程序元素,而我原本不想让函数了解这个元素的存在,这种“不必要的依赖关系”除了新增的以外,也可能是我想要稍后去除的,例如为了去除一个参数,我可能会在函数体内调用一个有问题的函数,或是从一个对象中获取某些原本想要剥离出去的数据。在这些情况下,都应该慎重考虑使用以查询取代参数。
如果想要去除的参数值只需要向另一个参数查询就能得到,这是使用以查询取代参数最安全的场景。如果可以从一个参数推导出另一个参数,那么几乎没有任何理由要同时传递这两个参数。
另外有一件事需要留意:如果在处理的函数具有引用透明性(referential transparency,即不论任何时候,只要传入相同的参数值,该函数的行为永远一致),这样的函数既容易理解又容易测试,我不想使其失去这种优秀品质。我不会去掉它的参数,让它去访问一个可变的全局变量。
6 以参数取代查询(Replace Query with Parameter)
在浏览函数实现时,我有时会发现一些令人不快的引用关系,例如,引用一个全局变量,或者引用另一个我想要移除的元素。为了解决这些令人不快的引用,我需要将其替换为函数参数,从而将处理引用关系的责任转交给函数的调用者。
需要使用本重构的情况大多源于我想要改变代码的依赖关系——为了让目标函数不再依赖于某个元素,我把这个元素的值以参数形式传递给该函数。
这里需要注意权衡:如果把所有依赖关系都变成参数,会导致参数列表冗长重复;如果作用域之间的共享太多,又会导致函数间依赖过度。我一向不善于微妙的权衡,所以“能够可靠地改变决定”就显得尤为重要,这样随着我的理解加深,程序也能从中受益。
如果一个函数用同样的参数调用总是给出同样的结果,我们就说这个函数具有“引用透明性” (referential transparency),这样的函数理解起来更容易。如果一个函数使用了另一个元素,而后者不具引用透明性,那么包含该元素的函数也就失去了引用透明性。只要把“不具引用透明性的元素”变成参数传入,函数就能重获引用透明性。虽然这样就把责任转移给了函数的调用者,但是具有引用透明性的模块能带来很多益处。
有一个常见的模式:在负责逻辑处理的模块中只有纯函数,其外再包裹处理I/O和其他可变元素的逻辑代码。借助以参数取代查询,我可以提纯程序的某些组成部分,使其更容易测试、更容易理解。
不过以参数取代查询并非只有好处。把查询变成参数以后,就迫使调用者必须弄清如何提供正确的参数值,这会增加函数调用者的复杂度,而我在设计接口时通常更愿意让接口的消费者更容易使用。归根到底,这是关于程序中责任分配的问题,而这方面的决策既不容易,也不会一劳永逸—这就是我需要非常熟悉本重构(及其反向重构)的原因。
7 移除设值函数(Remove Setting Method)
如果为某个字段提供了设值函数,这就暗示这个字段可以被改变。如果不希望在对象创建之后此字段还有机会被改变,那就不要为它提供设值函数(同时将该字段声明为不可变)。这样一来,该字段就只能在构造函数中赋值,我“不想让它被修改”的意图会更加清晰,并且可以排除其值被修改的可能性—这种可能性往往是非常大的。
有两种常见的情况需要讨论。一种情况是,有些人喜欢始终通过访问函数来读与字段值,包括在构造函数内也是如此。这会导致构造函数成为设值函数的唯一使用者。若果真如此,我更愿意去除设值函数,清晰地表达“构造之后不应该再更新字段值”的意图。另一种情况是,对象是由客户端通过创建脚本构造出来,而不是只有一次简单的构造函数调用。所谓“创建脚本”,首先是调用构造函数,然后就是一系列设值函数的调用,共同完成新对象的构造。创建脚本执行完以后,这个新生对象的部分(乃至全部)字段就不应该再被修改。设值函数只应该在起初的对象创建过程中调用。对于这种情况,我也会想办法去除设值函数,更清晰地表达我的意图。
8 以工厂函数取代构造函数(Replace Constructor with Factory Function)
很多面向对象语言都有特别的构造函数,专门用于对象的初始化。需要新建一个对象时,客户端通常会调用构造函数。
但与一般的函数相比,构造函数又常有一些丑陋的局限性。例如,Java的构造函数只能返回当前所调用类的实例,也就是说,我无法根据环境或参数信息返回子类实例或代理对象;构造函数的名字是固定的,因此无法使用比默认名字更清晰的函数名;构造函数需要通过特殊的操作符来调用(在很多语言中是new 关键字),所以在要求普通函数的场合就难以使用。
工厂函数就不受这些限制。工厂函数的实现内部可以调用构造函数,但也可以换成别的方式实现。
9 以命令取代数(Replace Function with Command)
函数,不管是独立函数,还是以方法(method)形式附着在对象上的函数,是程序设计的基本构造块。
不过,将函数封装成自己的对象,有时也是一种有用的办法。这样的对象我称之为“命令对象” (command object),或者简称“命令” (command)。这种对象大多只服务于单一函数,获得对该函数的请求,执行该函数,就是这种对象存在的意义。
与普通的函数相比,命令对象提供了更大的控制灵活性和更强的表达能力。除了函数调用本身,命令对象还可以支持附加的操作。我可以通过命令对象提供的方法来设置命令的参数值,从而支持更丰富的生命周期管理能力。我可以借助继承和钩子对函数行为加以定制。如果我所使用的编程语言支持对象但不支持函数作为一等公民,通过命令对象就可以给函数提供大部分相当于一等公民的能力。同样,即便编程语言本身并不支持嵌套函数,我也可以借助命令对象的方法和字段把复杂的函数拆解开,而且在测试和调试过程中可以直接调用这些方法。所有这些都是使用命令对象的好理由,所以我要做好准备,一旦有需要,就能把函数重构成命令。
不过我们不能忘记,命令对象的灵活性也是以复杂性作为代价的。所以,如果要在作为一等公民的函数和命令对象之间做个选择,95%的时候我都会选函数。只有当我特别需要命令对象提供的某种能力而普通的函数无法提供这种能力时,我才会考虑使用命令对象。
跟软件开发中的很多词汇一样,“命令”这个词承载了太多含义。在这里,“命令”是指一个对象,其中封装了一个函数调用请求。这是遵循《设计模式》一书中的命令模式(command pattern)。
10 以函数取代命令(Replace Command with Function)
命令对象为处理复杂计算提供了强大的机制。借助命令对象,可以轻松地将原本复杂的函数拆解为多个方法,彼此之间通过字段共享状态;拆解后的方法可以分别调用;开始调用之前的数据状态也可以逐步构建。
但这种强大是有代价的。大多数时候,我只是想调用一个函数,让它完成自己的工作就好。如果这个函数不是太复杂,那么命令对象可能显得费而不惠,我就应该考虑将其变回普通的函数。