炼金术(7): 何以解忧,唯有重构
ffl 人气:1很多时候,把代码梳理一遍,把逻辑写正确,把依赖关系理顺,BUG就不见了。一个Bugly的遗留系统,只有彻底的重构,让程序首先处于「良构」状态,才可以正常的开发、维护和发版本。其中有一个本质的问题,就是让代码实现「高内聚、低耦合」。下面是我的重构笔记。
我发现我原来习以为常的编程习惯,我一开始就不会写出这种乱七八糟耦合的问题,所以有很长一段时间以来我都感觉不到写代码要注意「高内聚、低耦合」问题了。可是这次重构,让我又看到了那些意大利面条代码是怎么回事,而要拆开它们,一步步接触耦合,重新把这些代码写到「正常」,我才又「感觉」到写代码需要「高内聚、低耦合」这件事,对很多人来说是需要经过学习和练习的。
这次重构再一次证明了「全局变量是万恶之源」,这个人用JavaScript写了很多类,但是呢,每个模块里都返回了这个类的一个「假单例」,进一步又「向上」「向下」,在上下两层都是用了这个虚假的单例,导致两边的内部都严重耦合这些「类的实例」,也就等价于直接使用了一堆的全局变量。更恶劣的是,这些类的成员变量是直接暴露,到处赋值,把所有变量都暴露在「没有任何封装和保护」下的「任意修改」。
我这几天简直就是反复在一层一层重构:
- 解除双向耦合,层跟层之间只能是
A<----B<---C<----D
这种单向依赖,而不能互相依赖。程序里的层跟层之间,要做到单向依赖,就能让流程清晰,构架合理。 - 所有的变量修改「封装」到类内部,全部通过方法来修改。在这个基础上,内部变量的修改,在内部状态机里面做保护。
- 仔细、彻底清理几个重要的有限状态机(Finite State Machine),画出状态转换的完整状态转换图,内部必须有enterState转换方法保护,任何错误转换都直接报错。我觉的这是直接体现「编程」是什么的地方,不懂有限状态机,就不是真正的编程。我看到很多定义了一堆状态,但是状态之间是可以随意跳转的代码,这种都是Bugly的根源。
- 收缩一个类状态被修改的点。一个类定义了一组方法和属性,只应该在某个场合下被使用,所有使用了这个类的地方,如果不是尽量控制在狭小的范围,那么状态修改就在扩散,这些分散不但让状态的变化难以被理解,也不利于维护。一步步收缩范围,根据「相关性」逐渐分析,哪些逻辑应该集中在某个地方管理。
- 函数里的逻辑,不应该是一堆看不出干什么的代码构成。而应该尽量由一组一眼就看的清楚的函数调用构成,如果不是,那么就需要重构这部分逻辑,让它们在合适的地方组成一个合适的,功能明确的函数。
- 分离不同进程的类到不同的文件夹。每个进程只应该使用自己进程里的类,否则,你会遇到诸如「这个变量我明明修改了,怎么就是不对呢」的问题,因为你修改的和你读区的根本就是两个不同进程的变量,虽然看上去是「同一个类」,如果你有多线程代码,也是类似。明确每个类属于哪个进程。用含义明确的文件夹物理分离它们。每个类只应该被一个进程使用,除非它是一个没有状态的工具类。这也进一步说明了不要使用全局变量,一不小心,你就在两个进程内使用了「同一个变量」的属于两个进程的副本。不要给自己制造这种混淆的机会。
- 如何解除
A<--->B
这种耦合呢?虽然我是在JavaScript里写代码,我还是会思考什么时候使用「接口」,什么时候使用「函数」来解除耦合的问题。许年年来,基于面向对象的设计模式,都在告诉你要面向接口来解除耦合,真的是这样的吗?
很久以来,我都已经 忘记了要写一个接口了,因为动态语言里并不需要什么直接的接口。我认真思考了下,如果一个类确实有可能含有多种不同的相似的子类型,这个时候继承是很自然的,例如,B1
,B2
,B3
继承B
。此时A
对B
的依赖,B
可以是一个抽象类,也可以就是一个接口IB
,这没有什么区别。反之,B
也可以对IA
依赖。由此设计模式一个系列基本上就是在说这件事。
但是,我可以不用接口实现解除耦合么?合理设计回调函数就可以做到。例如:
B.xxxxx(params, onXXXX, onYYYY)
只要B
的函数参数里定义好合适的回调函数,那么我并不需要B
内部调用任何A
的方法,A如果要把自己逻辑混进B
的xxxxx
方法的逻辑里,只要使用B的时候,处理这些回调就可以:
b.xxxxx(params,(...)=>{
这里加入A的逻辑
},(...)=>{
这里加入A的逻辑
});
这个时候,B
如果要做到通用,就是尽量设计好合适的参数和回调。
进一步,你可能会在A
的内部使用B
。这样B
虽然解除了对A
的依赖,但是A
对B
的依赖还是在,那么,应该怎样进一步解除这种耦合呢?一种抽象方法如是有效的,那就反复使用它:
A.yyyyy(params, onXXXX, onYYYY);
这个时候,把A
的逻辑和B
的逻辑绑定在一起就是更外层的「责任」,A
和B
负责「提供机制」,外层,例如C
负责「使用策略」,从而做到「机制和策略的分离」
C:
a ,b;
a.yyyyy(params, (...)=>{
// 其他逻辑,例如加入c的逻辑
b.xxxxx(prams,(.....)=>{
// 加入A的逻辑
}, (...)=>{
// 加入A的逻辑
}
}, (...)=>{
// 其他逻辑,例如加入c的逻辑
});
这当然可能引起「回调嵌套地狱」,在许多情况下,可以使用语言层提供的async/await
来让代码更清晰一些。但是async/await
并不是回调的完备替代品,它只能让单出口的异步回调变成「伪同步」代码。例如:
xxx((ret)=>{
zzzz(ret)
});
变成:
let ret = await xxxx();
zzzz(ret);
但是这种能力它就比较啰嗦
xxxx((ret)=>{
zzzz(ret);
},(ret)=>{
yyyy(ret);
});
要处理这种多出口的回调,如果xxx
内部要么在第1个回调结束,要么在第2个回调结束,那可以通过返回值判断要怎么处理:
let {err,ret} = await xxxxx();
if(err){
zzzz(ret);
}else{
yyyy(ret);
}
但是,如果xxxx
内部在第1个回调之后,也可能再次调用第2个回调。或者任何一个回调会调用多次。这个时候把xxxx
函数变成不带回调的async
函数,逻辑会变的复杂,甚至不可能。
总之,这是题外话。我的核心要说明的是,通过在函数参数和回调的设计,就可以解除A<---->B
这种依赖关系。并且让C在调用地方的代码「一眼就看出来A
和B
之间如何协同工作完成任务」,这点是我考虑很多代码应该写在哪里的关键。
那就是,一个函数应该是:
run(); // 内部完成了神秘的任务
还是应该是:
if(a.init()){
a.xxxx();
a.zzzz();
};
更好呢?我认为,至少应该在xxxx
函数的上一层调用地方,在那个粒度提供直观的这个「程序在干什么」的直观逻辑。
我认为接口的解藕,在于有同一个接口有多个不同的场景,但是相似子类的时候。而如果不是,那么「高阶函数」的组合就是更好的选择。这个更好是类似「如无必要,务增实体」这类的思想,或者说「奥姆卡剃刀」原理。
以上就是重构的几点感受,在重构项目中,也有助于我们理解构架是什么,因为为了让项目达到「良构」,我们必须理解很多「为什么」。
--end--
加载全部内容