分解模块时最重要的标准,也许就是识别出那些模块应该对外界隐藏的小秘密了[Parnas]。
7.1 封装记录(Encapsulate Record)
1 | organization = { name: "Acme Gooseberries", country: "GB" }; |
- 对于可变数据,对象可以隐藏结构的细节,用户不必追究存储的细节和计算的过程(比如记录两个数据,且还有可能从数据计算第三个数据,又或者两个数据经常变换的情况)
- 不希望用户去修改配置数据的时候,封装可以保护原始数据,避免意料之外的修改
- 但如果是一些不可变的配置数据,就没有那么大必要去封装了,比如物理计算的一些静态变量
比如移动类的当前角度和目标角度,用户传入目标角度,再根据某种算法去计算当前角度。使用者不需要关心内部的算法,且需要避免去直接设置当前角度(因为会绕过某些算法),这时候就要将取值和设值封装起来了。
但在一些弱类型语言还是能强行拿到内部变量去使用,这时要通过一些命名规范去提醒使用者了。
一个好的习惯是先看看要使用的内部变量在类里面是否有取值设值函数,如果找不到可以问设计者是否可以在外部修改使用。
7.2 封装集合(Encapsulate Collection)
1 | class Person { |
如果是一个序列化数据读取到内存结构,涉及的属性很多,且层次很深。(比如角色的各种属性)
- 一种做法是将所有对外暴露修改的变量都封装起来,比如攻击值、防御值、速度等等,用到哪个封装哪个。好处是对于该数据结构能读取或修改的地方一目了然,坏处是会大大增加代码量。
- 另一种是将完整的数据结构交出去,用户可以通过它拿到想拿的信息。但这样无法避免用户对数据造成修改,如果这个结构包含一些需要深拷贝的引用,将这个数据设为只读还不够,很有可能在外部不经意被修改。可以粗暴的深拷贝一份数据返回,但可能有性能问题。
- 需要权衡两种做法,合理利用拷贝和取值函数,但负责的做法总是不要让用户能直接修改源数据,不然后续查bug很难受
如果对于限制复杂结构的只读比较困难,也可以换个思路,及时检测谁修改了不应该修改的数据并提醒,比如项目之前出现的修改了VectorZero事件
7.3 以对象取代基本类型(Replace Primitive with Object)
1 | orders.filter(o => "high" === o.priority |
开发初期,你往往决定以简单的数据项表示简单的情况,比如使用数字或字符串等。但随着开发的进行,你可能会发现,这些简单数据项不再那么简单了。比如说,一开始你可能会用一个字符串来表示“电话号码”的概念,但是随后它又需要“格式化”“抽取区号”之类的特殊行为。这类逻辑很快便会占领代码库,制造出许多重复代码,增加使用时的成本。
核心思想是围绕某个基本类型变量衍生了一系列计算逻辑,比如比较、设置是否合法等等,这时候就要考虑将这个基础类型变量和其相关的计算逻辑封装成对象了。
一旦开始了封装,以后围绕他的业务逻辑有了新地方可以放置。
7.4 以查询取代临时变量(Replace Temp with Query)
1 | const basePrice = this._quantity * this._itemPrice; |
- 临时变量可以节省一些重复代码的计算,但如果是两块不同功能的逻辑使用了同一个临时变量,为了设立更清晰的边界还是有必要将其转换成用函数查询,长代码的危害性还是比一两次多余计算危害性更大
- 适用的场景是那些每次计算都能获得相同值的临时变量,如果和调用时序有关、快照存储上一次的结果这种临时变量,是不适用的
- 使用查询函数代替临时变量后,还可以加以内联变量,节省代码行数
当有意去除大量长代码时,这个手法将会起到大作用
7.5 & 7.6 提炼类(Extract Class)、内联类(Inline Class)
1 | class Person { |
何时该提炼
当发现类内部有多个字段互相关联,且携带了一些围绕它们的计算函数时。(一个有用的判断规则是:移除某些变量和函数,查看另一些变量是否还有用处)
还有一种时机是,发现其他类也需要这个相似的功能,这时候可以提取出来供两个地方使用。
比如缓存一个Transform,递归找寻子节点是否包含XXX名字的Transform,以及围绕这个Transform的其他方法
何时该内联
- 当一个类存在的意义不是很明确,有种特意为了使用类而使用类的时候,又或者一开始设想它比较通用,但实际发现只有一个类引用它
- 另一种是觉得类的划分不合理,这时候可以先内联成大类,再使用提炼类的手段再次拆分
7.7 & 7.8 隐藏委托关系(Hide Delegate)、移除中间人(Remove Middle Man)
1 | manager = aPerson.department.manager; |
何时该隐藏
- “封装”意味着每个模块都应该尽可能少了解系统的其他部分
- 将委托关系隐藏起来,从而去除这种依赖。这么一来,即使将来委托关系发生变化,变化也只会影响服务对象,而不会直接波及所有客户端
主要是为了应对变化或者不希望外部知道某两个模块的互相引用关系
何时该移除
每当客户端要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数。随着受托类的特性(功能)越来越多,更多的转发函数就会使人烦躁。服务类完全变成了一个中间人(81),此时就应该让客户直接调用受托类。
比如目前的动画播放组件,需要拿动画播放器去播放,每当动画播放器新增接口时,动画播放组件都要新增一组相同接口
两者如何权衡
隐藏关系的出发点是好点,设计新模块时可以大胆隐藏,但当发现这种隐藏带来的成本太大,去掉即可,只要把出问题的地方修补好就行了。
7.9 替换算法(Substitute Algorithm)
1 | function foundPerson(people) { |
如果有更简洁合理的算法能实现原有算法实现的功能,积极替换吧。比如游戏中一些物理计算可以调用比较成熟的引擎自带的计算,又或者一些编辑器代码可以用Linq来替代对数据的操作算法。