07-08¶
7. 封装¶
7.1 封装记录(Encapsulate Record)¶
- 有意义的单元传递
记录型结构是多数编程语言提供的一种常见特性。它们能直观地组织起存在关联的数据,让我可以将数据作为有意义的单元传递,而不仅是一堆数据的拼凑。
- 对象可隐藏结构细节和帮助字段改名
对象可以隐藏其内部结构细节,隐藏结构的细节。该对象的用户不必追究存储的细节和计算的过程。同时这种封装还有助于字段的改名:可重新命名字段,但同时提供新老字段名的访问方法,这样可以渐进地修改调用方,直到替换全部完成。
- 数据结构直观
若这种记录只在程序的一个小范围里使用,那问题还不大,但若其使用范围变宽,“数据结构不直观”这个问题就会造成更多困扰。可以重构它,使其变得更直观——但如果真需要这样做,那还不如使用类来得直接。
- 嵌套结构也值得封装
程序中间常常需要互相传递嵌套的列表或散列映射结构,这些数据结构后续经常需要被序列化成JSON或XML。这样的嵌套结构同样值得封装,这样如果后续其结构需要变更或者需要修改记录内的值,封装能够帮更好地应对变化。
具体重构步骤
- 对持有记录的变量使用封装变量,将其封装到一个函数中;
记得为这个函数取一个容易搜索的名字。
-
创建一个类,将记录包装起来,并将记录变量的值替换为该类的一个实例。然后在类上定义一个访问函数,用于返回原始的记录。修改封装变量的函数,令其使用这个访问函数;
-
测试;
-
新建一个函数,让它返回该类的对象,而非那条原始的记录;
-
对于该记录的每处使用点,将原先返回记录的函数调用替换为那个返回实例对象的函数调用。使用对象上的访问函数来获取数据的字段,如果该字段的访问函数还不存在,那就创建一个。每次更改之后运行测试;
如果该记录比较复杂,例如是个嵌套解构,那么先重点关注客户端对数据的更新操作,对于读取操作可以考虑返回一个数据副本或只读的数据代理。
-
移除类对原始记录的访问函数,那个容易搜索的返回原始数据的函数也要一并删除;
-
测试;
-
如果记录中的字段本身也是复杂结构,考虑对其再次应用封装记录或封装集合手法;
7.2 封装集合(Encapsulate Collection)¶
- 清晰观测数据修改
封装程序中的所有可变数据可以让我们容易看清楚数据被修改的地点和修改方式,这样需要更改数据结构时就非常方便。
- 只对集合变量访问封装的问题
封装集合时人们常常犯一个错误即只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以直接被修改,而封装它的类则全然不知,无法介入。
- 注意集合取值函数返回
更好的做法是,不要让集合的取值函数返回原始集合,这就避免了客户端的意外修改。
- 直接返回集合值的问题
一种避免直接修改集合的方法是,永远不直接返回集合的值。这种方法提倡,不要直接使用集合的字段,而是通过定义类上的方法来代替。
增加使用特殊的类方法来处理这些场景,会增加许多额外代码,使集合操作容易组合的特性大打折扣。
- 限制集合的访问权
以某种形式限制集合的访问权,只允许对集合进行读操作。
- 提供取值函数
最常见的做法是,为集合提供一个取值函数,但令其返回一个集合的副本。这样即使有人修改了副本,被封装的集合也不会受到影响。
- 保持一致
采用哪种方法并无定式,最重要的是在同个代码库中做法要保持一致。建议只用一种方案,这样每个人都能很快习惯它,并在每次调用集合的访问函数时期望相同的行为。
具体重构步骤
-
如果集合的引用尚未被封装起来,先用封装变量封装它;
-
在类上添加用于“添加集合元素”和“移除集合元素”的函数;
如果存在对该集合的设值函数,尽可能先用移除设值函数移除它。如果不能移除该设值函数,至少让它返回集合的一份副本。
-
执行静态检查;
-
查找集合的引用点。如果有调用者直接修改集合,令该处调用使用新的添加/移除元素的函数。每次修改后执行测试;
-
修改集合的取值函数,使其返回一份只读的数据,可以使用只读代理或数据副本;
-
测试;
7.3 以对象取代基本类型(Replace Primitive with Object)¶
- 数据操作拓展
一旦发现对某个数据的操作不仅仅局限于打印时,我就会为它创建一个新类。一开始这个类也许只是简单包装一下简单类型的数据,不过只要类有了,日后添加的业务逻辑就有地可去了。
具体重构步骤
-
如果变量尚未被封装起来,先使用封装变量封装它;
-
为这个数据值创建一个简单的类。类的构造函数应该保存这个数据值,并为它提供一个取值函数;
-
执行静态检查;
-
修改第一步得到的设值函数,令其创建一个新类的对象并将其存入字段,如果有必要的话,同时修改字段的类型声明;
-
修改取值函数,令其调用新类的取值函数,并返回结果;
-
测试;
-
考虑对第一步得到的访问函数使用函数改名,以便更好反映其用途;
-
考虑应用将引用对象改为值对象或将值对象改为引用对象,明确指出新对象的角色是值对象还是引用对象;
7.4 以查询取代临时变量(Replace Templ with Query)¶
- 设立清晰的边界
如果正在分解一个冗长的函数,那么将变量抽取到函数里能使函数的分解过程更简单,因为这样就不再需要将变量作为参数传递给提炼出来的小函数。
将变量的计算逻辑放到函数中,也有助于在提炼得到的函数与原函数之间设立清晰的边界,这能帮助发现并避免难缠的依赖及副作用。
- 避免重复编写计算逻辑
改用函数还可以避免了在多个函数中重复编写计算逻辑。每当在不同的地方看见同一段变量的计算逻辑,可以尝试将它们挪到同一个函数里。
- 快照用途的临时变量
最简单的情况是,这个临时变量只被赋值一次,但在更复杂的代码片段里,变量也可能被多次赋值——此时应该将这些计算代码一并提炼到查询函数中。并且,待提炼的逻辑多次计算同样的变量时,应该能得到相同的结果。因此,对于那些做快照用途的临时变量(从变量名往往可见端倪)就不能使用本手法。
具体重构步骤
-
检查变量在使用前是否已经完全计算完毕,检查计算它的那段代码是否每次都能得到一样的值;
-
如果变量目前不是只读的,但是可以改造成只读变量,那就先改造它;
-
测试;
-
将为变量赋值的代码段提炼成函数;
如果变量和函数不能使用同样的名字,那么先为函数取个临时的名字。确保待提炼函数没有副作用。若有先应用将查询函数和修改函数分离手法隔离副作用。
-
测试;
-
应用内联变量手法移除临时变量;
7.5 提炼类(Extract Class)¶
- 类不断成长扩展
在实际工作中,类会不断成长扩展。随着责任不断增加,这个类会变得过分复杂。
- 大类的维护
维护大量函数和数据的的类往往因为太大而不易理解。此时需要考虑哪些部分可以分离出去,并将它们分离到一个独立的类中。
如果某些数据和某些函数总是一起出现,某些数据经常同时变化甚至彼此相依,这就表示你应该将它们分离出去。
- 类的子类化
如果发现子类化只影响类的部分特性,或发现某些特性需要以一种方式来子类化,某些特性则需要以另一种方式子类化,这就意味着需要分解原来的类。
具体重构步骤
-
决定如何分解类所负的责任;
-
创建一个新的类,用以表现从旧类中分离出来的责任;
如果旧类剩下的责任与旧类的名称不符,为旧类改名。
-
构造旧类时创建一个新类的实例,建立“从旧类访问新类”的连接关系;
-
对于你想搬移的每一个字段,运用搬移字段搬移之。每次更改后运行测试;
-
使用搬移函数将必要函数搬移到新类。先搬移较低层函数。每次更改后运行测试;
-
检查两个类的接口,去掉不再需要的函数,必要时为函数重新取一个适合新环境的名字;
-
决定是否公开新的类。如果确实需要,考虑对新类应用将引用对象改为值对象使其成为一个值对象;
7.6 内联类(Inline Class)¶
- 不承担责任的类
内联类正好与提炼类相反。如果一个类不再承担足够责任,不再有单独存在的理由(这通常是因为此前的重构动作移走了这个类的责任)。
- 重新安排职责
若想重新安排两个类的职责,并让它们产生关联。此时先将它们内联成一个类再用提炼类去分离其职责会更加简单。
具体重构步骤
-
对于待内联类(源类)中的所有public函数,在目标类上创建一个对应的函数,新创建的所有函数应该直接委托至源类;
-
修改源类public方法的所有引用点,令它们调用目标类对应的委托方法。每次更改后运行测试;
-
将源类中的函数与数据全部搬移到目标类,每次修改之后进行测试,直到源类 变成空壳为止;
-
删除源类,为它举行一个简单的“丧礼”;
7.7 隐藏委托关系(Hide Delegate)¶
- 好的模块化设计
一个好的模块化的设计,“封装”即使不是其最关键特征,也是最关键特征之一。“封装”意味着每个模块都应该尽可能少了解系统的其他部分。
- 去除委托依赖
如果某些客户端先通过服务对象的字段得到另一个对象(受托类),然后调用后者的函数,那么客户就必须知晓这一层委托关系。 万一受托类修改了接口,变化会波及通过服务对象使用它的所有客户端。
可以在服务对象上放置一个简单的委托函数,将委托关系隐藏起来,从而去除这种依赖。这么一来,即使将来委托关系发生变化,变化也只会影响服务对象,而不会直接波及所有客户端。
具体重构步骤
-
对于每个委托关系中的函数,在服务对象端建立一个简单的委托函数;
-
调整客户端,令它只调用服务对象提供的函数。每次调整后运行测;
-
如果将来不再有任何客户端需要取用 Delegate(受托类),便可移除服务对象 中的相关访问函数。
-
测试;
7.8 移除中间人(Remove Middle Man)¶
- 封装的代价
每当客户端要使用受托类的新特性时,就必须在服务端添加一个简单委托函数。随着受托类的特性越来越多,更多的转发函数会让服务类完全变成了一个中间人,此时就应该让客户直接调用受托类。
- 合适的隐藏程度
随着代码的变化,“合适的隐藏程度”这个尺度也相应改变。
具体重构步骤
-
为受托对象创建一个取值函数;
-
对于每个委托函数,让其客户端转为连续的访问函数调用。每次替换后运行测试;
替换完委托方法的所有调用点后,就可以删掉这个委托方法了。
这能通过可自动化的重构手法来完成,你可以先对受托字段使用封装变量,再应用内联函数内联所有使用它的函数。
7.9 替换算法(Substitute Algorithm)¶
- 更简单的解决方案
随着对问题有了更多理解,可能会发现在原先的做法之外,有更简单的解决方案,此时就需要改变原先的算法。
- 替换算法的困难
替换一个巨大且复杂的算法是非常困难的,只有先将它分解为较简单的小型函数,才能很有把握地进行算法替换工作。
具体重构步骤
-
整理一下待替换的算法,保证它已经被抽取到一个独立的函数中;
-
先只为这个函数准备测试,以便固定它的行为;
-
准备好另一个(替换用)算法;
-
执行静态检查;
-
运行测试,比对新旧算法的运行结果。如果测试通过,那就大功告成;否则在后续测试和调试过程中,以旧算法为比较参照标准;
8. 搬移特性¶
8.1 搬移函数(Move Function)¶
- 好的模块化
模块化是优秀软件设计的核心所在,好的模块化能够在修改程序时只需理解程序的一小部分。 为了设计出高度模块化的程序,需要保证互相关联的软件要素都能集中到一块,并确保块与块之间的联系易于查找、直观易懂。
同时,模块设计的理解并不是一成不变的,随着对代码的理解加深,就会看出那些软件要素如何组织最为恰当。要将这种理解反映到代码上,就得不断地搬移这些元素。
- 函数的上下文环境
任何函数都需要具备上下文环境才能存活。这个上下文可以是全局的,但它更多时候是由某种形式的模块所提供的。
对一个面向对象的程序而言,类作为最主要的模块化手段,其本身就能充当函数的上下文;通过嵌套的方式,外层函数也能为内层函数提供一个上下文。
- 更好的封装效果
搬移函数频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。此时若让它去与那些更亲密的元素相会,通常能取得更好的封装效果,因为系统别处就可以减少对当前模块的依赖。
- 频繁调用别处函数
如果在整理代码时,发现需要频繁调用一个别处的函数,也可以考虑搬移这个函数。
具体重构步骤
- 检查函数在当前上下文里引用的所有程序元素(包括变量和函数),考虑是否需要将它们一并搬移;
如果发现有些被调用的函数也需要搬移,我通常会先搬移它们。这样可以保证移动一组函数时,总是从依赖最少的那个函数入手。
如果该函数拥有一些子函数,并且它是这些子函数的唯一调用者,那么可以先将子函数内联进来,一并搬移到新家后再重新提炼出子函数。
- 检查待搬移函数是否具备多态性;
在面向对象的语言里,还需要考虑该函数是否覆写了超类的函数,或者为子类所覆写。
- 将函数复制一份到目标上下文中。调整函数,使它能适应新家;
如果函数里用到了源上下文(source context)中的元素,就需要将这些元素一并传递过去,要么通过函数参数,要么是将当前上下文的引用传递到新的上下文那边去。
搬移函数通常意味着,还需要起个新名字,使它更符合新的上下文。
- 执行静态检查;
- 设法从源上下文中正确引用目标函数;
- 修改源函数,使之成为一个纯委托函数;
- 测试;
- 考虑对源函数使用内联函数
也可以不做内联,让源函数一直做委托调用。但如果调用方直接调用目标函数也不费太多周折,那么最好还是把中间人移除掉。
8.2 搬移字段(Move Field)¶
- 糟糕的数据结构
一个糟糕的数据结构则将招致许多无用代码,这些代码更多是在差劲的数据结构中间纠缠不清,而非为系统实现有用的行为。
代码凌乱,势必难以理解;不仅如此,坏的数据结构本身也会掩藏程序的真实意图。
- 设计恰当的数据结构
通常做些预先的设计来设法得到最恰当的数据结构,此时若具备一些领域驱动设计(domain-driven design)方面的经验和知识,往往有助于更好地设计数据结构。
- 参数传递的联系
总是一同出现、一同作为函数参数传递的数据,最好是规整到同一条记录中,以体现它们之间的联系。
- 更新字段的搬移
若更新一个字段时,需要同时在多个结构中做出修改,那也是一个征兆,表明该字段需要被搬移到一个集中的地点,这样每次只需修改一处地方。
- 类也需要保持健康
用以指称数据结构的术语都是“记录”(record),但以上论述对类和对象同样适用。类只是一种多了实例函数的记录,它与其他任何数据结构一样,都需要保持健康。
具体重构步骤
- 确保源字段已经得到了良好封装;
- 测试;
- 在目标对象上创建一个字段(及对应的访问函数);
- 执行静态检查;
- 确保源对象里能够正常引用目标对象;
也许已经有现成的字段或方法得到目标对象。如果没有,尝试简单地创建一个方法完成此事。若不行则可能就得在源对象里创建一个字段,用于存储目标对象了。此次修改可能留存很久,但也可只做临时修改,等到系统其他部分的重构完成就回来移除它。
- 调整源对象的访问函数,令其使用目标对象的字段;
如果源类的所有实例对象都共享对目标对象的访问权,那么可以考虑先更新源类的设值函数,让它修改源字段时,对目标对象上的字段做同样的修改。 然后,再通过引入断言,当检测到源字段与目标字段不一致时抛出错误。一旦你确定改动没有引入任何可观察的行为变化,就可以放心地让访问函数直接使用目标对象的字段了。
- 测试;
- 移除源对象上的字段;
- 测试;
8.3 搬移语句到函数(Move Statements into Function)¶
- 消除重复
要维护代码库的健康发展,需要遵守几条黄金守则,其中最重要的一条当属“消除重复”。如果发现调用某个函数时,总有一些相同的代码也需要每次执行,那么可考虑将此段代码合并到函数里头。
- 有助于理解的清理
如果某些语句与一个函数放在一起更像一个整体,并且更有助于理解,那可将语句搬移到函数里去。
如果它们与函数不像一个整体,但仍应与函数一起执行,那可以用提炼函数将语句和函数一并提炼出去。
具体重构步骤
- 如果重复的代码段离调用目标函数的地方还有些距离,则先用移动语句将这些语句挪动到紧邻目标函数的位置;
- 如果目标函数仅被唯一一个源函数调用,那么只需将源函数中的重复代码段剪切并粘贴到目标函数中即可,然后运行测试。本做法的后续步骤至此可以忽略;
- 如果函数不止一个调用点,那么先选择其中一个调用点应用提炼函数,将待搬移的语句与目标函数一起提炼成一个新函数;给新函数取个临时的名字,只要易于搜索即可;
- 调整函数的其他调用点,令它们调用新提炼的函数。每次调整之后运行测试;
- 完成所有引用点的替换后,应用内联函数将目标函数内联到新函数里,并移除原目标函数;
- 对新函数应用函数改名,将其改名为原目标函数的名字;
如果你能想到更好的名字,那就用更好的那个。
8.4 搬移语句到调用者(Move Statements to Callers)¶
- 抽象的边界
作为程序员,我们的职责就是设计出结构一致、抽象合宜的程序,而程序抽象能力的源泉正是来自函数。与其他抽象机制的设计一样,我们并非总能平衡好抽象的边界。
- 函数边界偏移
函数边界发生偏移的一个征兆是,以往在多个地方共用的行为,如今需要在某些调用点面前表现出不同的行为。于是得把表现不同的行为从函数里挪出,并搬移到其调用处。
具体重构步骤
- 最简单的情况下,原函数非常简单,其调用者也只有寥寥一两个,此时只需把要搬移的代码从函数里剪切出来并粘贴回调用端去即可,必要的时候做些调整。运行测试。如果测试通过,那就大功告成,本手法可以到此为止;
- 若调用点不止一两个,则需要先用提炼函数将你不想搬移的代码提炼成一个新函数,函数名可以临时起一个,只要后续容易搜索即可;
函数是一个超类方法,并且有子类进行了覆写,那么还需要对所有子类的覆写方法进行同样的提炼操作,保证继承体系上每个类都有一份与超类相同的提炼函数。接着将子类的提炼函数删除,让它们引用超类提炼出来的函数。
- 对原函数应用内联函数;
- 对提炼出来的函数应用改变函数声明,令其与原函数使用同一个名字;
如果你能想到更好的名字,那就用更好的那个。
8.5 以函数调用取代内联代码(Replace Inline Code With Function Call)¶
- 提升代码表达力
善用函数可以帮助我将相关的行为打包起来,这对于提升代码的表达力大有裨益——一个命名良好的函数,本身就能极好地解释代码的用途,使读者不必了解其细节。
- 函数消除重复
函数同样有助于消除重复,因为同一段代码不需要编写两次,每次调用一下函数即可。此外,当需要修改函数的内部实现时,也不需要四处寻找有没有漏改的相似代码。
- 函数取代内联代码
如果一些内联代码做的事情仅仅是已有函数的重复,则可以一个函数调用取代内联代码。但有一种情况需要特殊对待,那就是当内联代码与函数之间只是外表相似但其实并无本质联系时。
- 判断内联和函数真正重复
判断内联代码与函数之间是否真正重复,从函数名往往可以看出端倪:
如果一个函数命名得当,也确实与内联代码做了一样的事,那么这个名字用在内联代码的语境里也应该十分协调;
如果函数名显得不协调,可能是因为命名本身就比较糟糕(此时可以运用函数改名来解决),也可能是因为函数与内联代码彼此的用途确实有所不同。 若是后者的情况,就不应该用函数调用取代该内联代码。
具体重构步骤
- 将内联代码替代为对一个既有函数的调用;
- 测试;
8.6 移动语句(Slide Statements)¶
- 存在关联的东西一起出现
让存在关联的东西一起出现,可以使代码更容易理解。如果有几行代码取用了同一个数据结构,那么最好是让它们在一起出现,而不是夹杂在取用其他数据结构的代码中间。
- 更好的抽象效果
通常来说,把相关代码搜集到一处,往往是另一项重构(通常是在提炼函数)开始之前的准备工作。相比于仅仅把几行相关的代码移动到一起,将它们提炼到独立的函数往往能起到更好的抽象效果。
具体重构步骤
- 确定待移动的代码片段应该被搬往何处。仔细检查待移动片段与目的地之间的语句,看看搬移后是否会影响这些代码正常工作。如果会,则放弃这项重构;
往前移动代码片段时,如果片段中声明了变量,则不允许移动到任何变量的声明语句之前。
往后移动代码片段时,如果有语句引用了待移动片段中的变量,则不允许移动到该语句之后。
往后移动代码片段时,如果有语句修改了待移动片段中引用的变量,则不允许移动到该语句之后。
往后移动代码片段时,如果片段中修改了某些元素,则不允许移动到任何引用了这些元素的语句之后。
- 剪切源代码片段,粘贴到上一步选定的位置上;
- 测试;
如果测试失败,那么尝试减小移动的步子:要么是减少上下移动的行数,要么是一次搬移更少的代码。
8.7 拆分循环(Split Loop)¶
- 身兼数职的循环
如果在一次循环中做了两件不同的事,那么每当需要修改循环时,你都得同时理解这两件事情。如果能够将循环拆分,让一个循环只做一件事情,那就能确保每次修改时你只需要理解要修改的那块代码的行为就可以了。
- 拆分循环更容易使用
拆分循环还能让每个循环更容易使用。如果一个循环只计算一个值,那么它直接返回该值即可。
具体重构步骤
- 复制一遍循环代码;
- 识别并移除循环中的重复代码,使每个循环只做一件事;
- 测试;
完成循环拆分后,考虑对得到的每个循环应用提炼函数。
8.8 以管道取代循环(Replace Loop with Pipeline)¶
- 集合管道处理
如今越来越多的编程语言都提供了更好的语言结构来处理迭代过程,这种结构就叫作集合管道(collection pipeline)。
集合管道[mf-cp] 是这样一种技术,它允许我使用一组运算来描述集合的迭代过程,其中每种运算接收的入参和返回值都是一个集合。
具体重构步骤
- 创建一个新变量,用以存放参与循环过程的集合;
也可以简单地复制一个现有的变量赋值给新变量。
- 从循环顶部开始,将循环里的每一块行为依次搬移出来,在上一步创建的集合变量上用一种管道运算替代之。每次修改后运行测试;
- 搬移完循环里的全部行为后,将循环整个删除;
如果循环内部通过累加变量来保存结果,那么移除循环后,将管道运算的最终结果赋值给该累加变量。
8.9 移除死代码(Remove Dead Code)¶
- 应该忽略的代码
当尝试阅读代码、理解软件的运作原理时,无用代码确实会带来很多额外的思维负担。它们周围没有任何警示或标记能告诉程序员,让他们能够放心忽略这段函数,因为已经没有任何地方使用它了。
- 立即删除代码
一旦代码不再被使用,我们就该立马删除它。有可能以后又会需要这段代码,可以从版本控制系统里再次将它翻找出来。
具体重构步骤
- 如果死代码可以从外部直接引用,比如它是一个独立的函数时,先查找一下还有无调用点;
- 将死代码移除;
- 测试;