何时重构?何时停止?
神秘命名(Mysterious Name)
总不能看代码需要小虎解密卡解密吧,就像阅读里有一堆专业名词你看不懂,对你理解全文将是毁灭性打击。
一些通用的命名规范:
- 命名要具有描述性(除了描述意义,也可描述类型比如GO,以及是集合还是单一元素)
- 只使用人尽皆知的缩写,不要担心过长,让代码易于新读者理解更重要
- 避免增加无附加意义的词语
- 避免有歧义的单词
ps:替换变量名字需要注意替换干净,强类型语言可以用IDE的重构命名,弱类型语言比如lua,如果复制了一个类,此时对类名重命名可能会影响原有类
重复代码(Duplicated Code)
简而言之就是你需要新增某个功能时,需要在不同的地方新增相同的代码
坏味道:
- 同一个类的两个函数含有相同的表达式
- AB模块都引用了同一种枚举,但是枚举在AB模块都存在
手段:
- 提炼函数+移动语句:将相同部分代码提取到函数
- 函数上移:提取后避免A类调用B类函数
过长函数(Long Function)
用函数来取代注释,避免一个函数做过多事情。
吐槽下之前经历过的项目一个处理技能特效的函数,把所有可能触发的特效都塞里面,由于特效的播放依赖其他变量的变动(类似时间线机制),导致那个函数充斥了IF分支以及各种回调函数。整个函数长达上千行,让人望而却步。
坏味道(这里指可以拆成小函数的时机):
- 需要注释说明的地方
- 条件表达式
- 循环
手段:
- 大部分情况可以通过提炼函数解决
- 如果代码依赖大量变量导致需要传递的参数过多,则考虑减少参数手段(下面提到)
- 对分支或循环的内容进行函数拆分,部分分支甚至可以采用多态来优化
过长参数列表(Long Parameter List)
过长的参数列表会让调用时感到疑惑,特别是lua这种弱语言类型,经常在数第几个参数需要传什么内容,一不小心就将东西送错给别的参数
常用手段:
- 查询取代临时变量:比如某些变量就是纯粹用其他参数计算或查询得到的
- 引入参数对象:好几个数据成对出现,考虑抽成结构体或对象
- 保持对象完整:多处地方只是拿对象的某些成员,可以考虑传递对象
- 以命令取代函数
全局数据(Global Data)
减少代码出错的方法之一就是让变量的生命周期足够短和作用域足够小
试想某个变量作用域是全局,但遇到bug数据不对,若干地方都在修改这个变量,想想就汗流浃背了。
比如上次不小心将某个全局的VectorZero修改了,导致其他地方拿到的数据不是Zero
手段:
- 能不用全局就不用全局
- 能不修改就设置为可读
- 实在需要修改,请封装变量,通过函数来修改,这样可以追踪函数调用堆栈
可变数据(Mutable Data)
对数据的修改经常导致出乎意料的结果和难以发现的 bug。在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据。
手段:
- 函数式编程,所有数据都是副本,不修改原有数据。这种比较极端,在lua这种自由度高的语言不太适用
- 封装变量,让变量修改在可控范围
- 拆分变量,避免一个变量负责过多的用途,比如ab模块都用它来存数据
- 如果数据不需要修改数值,请不要增加设值函数(set)
- 如果可变数据可以通过计算处理,则尽量用查询来替代
主要还是看数据的作用域范围以及生命周期,如果都足够mini的情况,数据变动也不是什么大问题
发散式变化(Divergent Change)
最理想的情况,新增需求只需要在一处地方新增对应的逻辑,不需要几处脚本都修改。
但一般复杂的系统并不能达成这种理想条件,这时候需要注意本节提到的坏味道。
核心点是“每次只关心一次上下文”,新增需求不可避免会涉及多个方向的修改,那就应该每个方向都有专一的事情(上下文),职责单一原则。比如新增一个状态,表现层就在状态机内做,具体的移动逻辑在对应的移动组件,由状态统一去驱动,而不是某个脚本抢了移动组件的活,即某个上下文需要处理表现又要处理移动逻辑。
手段:
- 拆分阶段:将两个揉杂的阶段拆离,并用清晰的数据结构沟通
- 搬移函数、提炼函数、提炼类:来处理两个方向互相调用的逻辑
霰弹式修改(Shotgun Surgery)
如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,你所面临的坏味道就是霰弹式修改。如果需要修改的代码散布四处,你不但很难找到它们,也很容易错过某个重要的修改。
听着很像发散式变化,其实不然,这里应该是两个上下文沟通的逻辑分散在各个上下文,比如AB模块都需要读取一些枚举或者共同的数据,两个模块都实现了一份或者都刚好拥有各模块所需要的部分。比如技能释放逻辑和技能的ui表现,技能系统和ui系统都需要技能数据,各自又存了一份拷贝或者实现之类的,这时候应该抽离出来,放在一个中心的地方。
手段:
- 搬移函数、搬移字段:移动逻辑到同一模块
- 函数组合成类、函数组合成变换:把不同模块处理一样的数据的部分让类统一去处理
原则是:把本不该分散的逻辑拽回一处
依恋情结(Feature Envy)
一个函数跟另一个模块中的函数或数据交流格外频繁,远胜于在自己所处模块内部的交流
影响:
- 可读性、可维护性低
- 调用另一模块功能时往往需要打一套组合拳才能完成,需要知道过多的细节
- 往往会伴随有“内幕交易、重复代码、霰弹式修改……”
改进目标:
- 将函数搬移到对应的类
- 解除跨模块的过多交流
手段:
- 提炼函数、搬移函数
注:策略模式、访问者模式往往会带来依恋情节,这不是说这两个模式不可取。我们需要理解:从根本上,我们消除“依恋情节”和应用这些设计模式都是为了把一起变化的东西放到一块儿。
数据泥团(Data Clumps)
在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数,成群结队地待在一块儿。
判断:
- 删掉众多数据中的一项。如果这么做,其他数据有没有因而失去意义?
- 如果它们不再有意义,这就是一个明确信号:你应该为它们产生一个新对象
手段:
- 提炼类:给他们一个家,让他们呆一起吧
- 引入参数对象、保持对象完整:来缩短参数列表(以家为对象参与吧
基本类型偏执(Primitive Obsession)
赋予基本类型意义,比如日期,直接用int类型来使用(20240820),数字的加法和日期的加法并不一致,直接用int相加时要引入其他额外逻辑,这些额外逻辑就应该处在同一个类中,比如日期类。
直接用基本类型来面对具体业务需求,当后期需要新增功能时将显得很被动。比如游戏道具价格一开始用数字或者字符串去使用,但到后期游戏要做海外时,价格需要转换成其他地区的货币,这时候就要对原有价格进行换算。如果很多地方都要处理这种换算,又闻到一丝其他坏味道。
手段:
- 以对象取代基本类型:抽离基本类型成对象
- 提炼类、引入参数对象:如果基本类型数据总是成组出现
- 以子类取代类型码、以多态取代条件表达式:基本类型用于控制条件行为时
重复的 switch (Repeated Switches)
在不同的地方反复使用同样的 switch 逻辑(可能是以 switch/case 语句的形式,也可能是以连续的 if/else 语句的形式)
重复的 switch 的问题在于:每当你想增加一个选择分支时,必须找到所有的 switch,并逐一更新
比如一个系统里支持很多功能,同时只能使用一个功能,此时可能会有个标记记录当前是什么功能,那么代码中必然存在很多判断当前是什么功能需要做什么的代码。如果这种判断分散在这个系统各处,当后续要新增功能时会让人摸不着头脑该怎么新增。
手段:
- 以多态取代条件表达式:将每种功能必须提供的功能拆出成类,切换不同子类来保证切换逻辑简单、唯一
循环语句(Loops)
书里主张以管道取代循环,在c#语言即多用Linq这种基于迭代器的概念来取代循环,使得代码更清晰易懂。
但在游戏开发中使用Linq会造成大量垃圾对象,所以在编辑器可能用的多一点,比如下面代码
1 | mModules = editorAssembly |
1 | foreach (var type in editorAssembly.GetTypes()) |
题外话:
游戏开发中有很多时间上异步的逻辑,比如等待网络请求到了才能做什么事、等待多少秒后执行什么,
常用的方法有循环中轮询或者事件主动通知。这两种方法都会让逻辑比较分散,一眼看不出在做什么,有点像上面循环的例子,循环里做了太多事情。
基于上面有一种UniRx/UniTask的解决方案,其写法还和“以管道取代循环”的思路挺近似的。
1 | Observable.EveryUpdate() |
冗赘的元素(Lazy Element)
定义:冗赘的元素主要包括由于过度设计或在代码演进过程中,产生的冗余、废弃或不足以独立承担其职责的类、方法、变量等
影响:代码不简洁,存在多余的元素,造成在维护时无用修改,难以维护,影响代码的可读性。
改进目标:消除冗赘的程序元素,提高代码的可读性、可维护性。
感觉过度设计还是不多的,更多可能是一些废弃代码要及时移除
手段:
- 内联函数或内联类
- 如果这个类处于一个继承体系中,可以使用折叠继承体系
- 安全删除冗余元素
夸夸其谈通用性(Speculative Generality)
定义:过度的考虑程序的通用性
影响:过度的设计导致代码不易理解和维护
改进目标:删除过度设计的代码
手段:
- 折叠继承体系:如果你的某个抽象类其实没有太大作用
- 内联类、内联函数:不必要的委托
- 改变函数声明:如果函数的某些参数未被用上
- 移除死代码:根本没用到的代码
临时字段(Temporary Field)
定义:某个实例变量仅为代码中一小部分功能临时所用而创建
影响:
- 通常一个对象会需要它的全部的变量。当一个变量看上去没什么用,却要试图了解他为什么在哪里时,会使类的作用变得更难理解,影响了代码的可读性和可维护性
- 临时字段只在特定情况下有值(因此它们被对象需要),在这种情况之外,他的值是空的
- 可能某个函数需要这个字段,但在不执行这个函数的时候,这个字段是没有意义的
改进目标:消除临时字段,提升代码可读性、可维护性
手段:
- 提取类:将这个字段和相关的代码抽象成类
- 引入特例
过长的消息链(Message Chains)
定义:获取一个对象,从这个对象获取另一个对象,接着继续从另一个对象取东西,在实际代码中你看到的可能是一长串取值函数或一长串临时变量。
影响:客户端代码将与查找过程中的调用结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。
改进目标:针对过长消息链,可以用这时候应该使用隐藏委托关系,把调用链这种耦合关系放在中间人中。
手段:
- 观察清楚消息链的调用业务逻辑。
- 通过提炼函数把所有相同调用放在一个函数中
- 搬移函数到对应的中间人类中(新增或者使用以前的类)
- 替换这个函数到之前调用链。隐藏委托关系
中间人(Middle Man)
定义:一种过度使用委托(某类中一半以上方法都委托给其他类)的代码
影响:当需求发生某些变化的时候,作为中间人的代码总会被牵连一并修改,代码越发臃肿
比如需要调用系统里某个功能,系统里是某个成员来实现这个功能,很可能代码会变成在系统里新增一个函数,然后函数里再调用成员里的方法,多了一层中间人转接
改进目标:减少委托
手段:
- 移除中间人:内联、以委托取代超类/子类
内幕交易(Insider Trading)
定义:模块之间互相引用,私下直接进行大量的数据访问和交换
影响:增大模块间的耦合,容易导致循环依赖,严重影响可维护性
改进目标:消除模块间不合理的依赖关系(特别是循环依赖),将私下的数据访问和交换放到明面上,使模块间解耦,提高可维护性
感觉比较常见是另一个模块拿一个模块本不希望公开的数据,有些数据可能设计初衷是私有的,但为了快速迭代改成公有或者加上get/set。在lua这种弱语言更容易随便拿到内部变量,更多是加_去约束命名。
手段:
- 搬移函数、搬移字段:减少两个模块私下交流,如果避免不了,应挪到一个模块专门来复杂交流这件事,
- 隐藏委托关系:把另一个模块变成这两者的中介
- 以委托取代子类/超类:出现子类拿父类不希望使用的方法或数据,过度交流
过大的类(Large Class)
定义:由于属性未分组和职责不单一而包含过多属性、方法和代码行的类,一个类负责的事情过多
影响:随着属性、方法和代码行数的不断增加,重复代码接踵而至,最终走向混乱
改进目标:拆分过大的类,确保类职责单一
大体是当你发现要往这个类新增功能时发现很难有插入点,需要考虑的事情过多,就说明这个类有点过大了
手段:
- 提取类、子类:
- 把一些明显负责一样事情的字段、函数挪到新类,
- 有时候会发现这个类有多种状态,但是对外行为一致,可以采用提取子类
- 如果某个字段只有特定情况下使用,采取进一步提取
- 提取接口:
- 如果发现有大量代码重复,提炼成函数
异曲同工的类(Alternative Classes with Different Interfaces)
面向接口编程,两个类的行为一致,而且不同情况下可以互相替换,那就应该用接口来约束,常用的多态思想。
组合拳:接口+超类,可以关联前面的过多分支语句
手段:
- 改变函数声明:使两者函数签名一致(前提本身就行为一致)
- 搬移函数、提炼超类:为了让两者函数签名一致需要搬运代码
纯数据类(Data Class)
指的是只存放数据,但没有任何处理数据的方法。出现这种类常常意味着行为被放在了错误的地方
需要做的是:
- 对已有的数据明确哪些需要公开给外部,移除不必要的GET和SET
- 将对数据的处理函数挪到类内部
- 也有保留纯数据类的例外情况:只作为函数返回值且不可修改
被拒绝的遗赠(Refused Bequest)
定义: 被拒绝的遗赠是指:对于某个子类,它只想继承基类的部分函数和数据,不需要基类提供的全部内容,这些不需要的内容就成为了子类的负担
影响: 这种坏味道通常影响并不大,但如果子类拒绝实现部分接口或者基类的方法只适用于某个子类特定的方法,就会对可维护、可扩展性等造成较大影响。
改进目标: 改进不合理的继承体系,使代码结构清晰、可控。
并不建议对所有存在“被拒绝的遗赠”的代码都进行修改,我们经常使用继承复用一些行为,可以很好的应对日常工作,所以修改的成本和收益还是需要开发者自己权衡。但是当“被拒绝的遗赠”使开发人员困惑时,就建议及时处理掉。
手段:
- 函数/字段下移,让超类只持有子类共享的东西
- 以委托取代超类/子类
注释(Comments)
- 注释可以带我们找到本章先前提到的各种坏味道
- 找到坏味道后,我们首先应该以各种重构手法把坏味道去除
- 完成之后我们常常会发现:注释已经变得多余了,因为代码已经清楚地说明了一切
什么时候该注释:
- 如果你不知道该做什么,这才是注释的良好运用时机。除了用来记述将来的打算之外,
- 注释还可以用来标记你并无十足把握的区域。你可以在注释里写下自己“为什么做某某事”。这类信息可以帮助将来的修改者,尤其是那些健忘的家伙。