炼金术(7): 何以解忧,唯有重构

软件发布|下载排行|最新软件

当前位置:首页IT学院IT技术

炼金术(7): 何以解忧,唯有重构

ffl   2020-03-26 我要评论

很多时候,把代码梳理一遍,把逻辑写正确,把依赖关系理顺,BUG就不见了。一个Bugly的遗留系统,只有彻底的重构,让程序首先处于「良构」状态,才可以正常的开发、维护和发版本。其中有一个本质的问题,就是让代码实现「高内聚、低耦合」。下面是我的重构笔记。

我发现我原来习以为常的编程习惯,我一开始就不会写出这种乱七八糟耦合的问题,所以有很长一段时间以来我都感觉不到写代码要注意「高内聚、低耦合」问题了。可是这次重构,让我又看到了那些意大利面条代码是怎么回事,而要拆开它们,一步步接触耦合,重新把这些代码写到「正常」,我才又「感觉」到写代码需要「高内聚、低耦合」这件事,对很多人来说是需要经过学习和练习的。

这次重构再一次证明了「全局变量是万恶之源」,这个人用JavaScript写了很多类,但是呢,每个模块里都返回了这个类的一个「假单例」,进一步又「向上」「向下」,在上下两层都是用了这个虚假的单例,导致两边的内部都严重耦合这些「类的实例」,也就等价于直接使用了一堆的全局变量。更恶劣的是,这些类的成员变量是直接暴露,到处赋值,把所有变量都暴露在「没有任何封装和保护」下的「任意修改」。

我这几天简直就是反复在一层一层重构:

  1. 解除双向耦合,层跟层之间只能是 A<----B<---C<----D 这种单向依赖,而不能互相依赖。程序里的层跟层之间,要做到单向依赖,就能让流程清晰,构架合理。
  2. 所有的变量修改「封装」到类内部,全部通过方法来修改。在这个基础上,内部变量的修改,在内部状态机里面做保护。
  3. 仔细、彻底清理几个重要的有限状态机(Finite State Machine),画出状态转换的完整状态转换图,内部必须有enterState转换方法保护,任何错误转换都直接报错。我觉的这是直接体现「编程」是什么的地方,不懂有限状态机,就不是真正的编程。我看到很多定义了一堆状态,但是状态之间是可以随意跳转的代码,这种都是Bugly的根源。
  4. 收缩一个类状态被修改的点。一个类定义了一组方法和属性,只应该在某个场合下被使用,所有使用了这个类的地方,如果不是尽量控制在狭小的范围,那么状态修改就在扩散,这些分散不但让状态的变化难以被理解,也不利于维护。一步步收缩范围,根据「相关性」逐渐分析,哪些逻辑应该集中在某个地方管理。
  5. 函数里的逻辑,不应该是一堆看不出干什么的代码构成。而应该尽量由一组一眼就看的清楚的函数调用构成,如果不是,那么就需要重构这部分逻辑,让它们在合适的地方组成一个合适的,功能明确的函数。
  6. 分离不同进程的类到不同的文件夹。每个进程只应该使用自己进程里的类,否则,你会遇到诸如「这个变量我明明修改了,怎么就是不对呢」的问题,因为你修改的和你读区的根本就是两个不同进程的变量,虽然看上去是「同一个类」,如果你有多线程代码,也是类似。明确每个类属于哪个进程。用含义明确的文件夹物理分离它们。每个类只应该被一个进程使用,除非它是一个没有状态的工具类。这也进一步说明了不要使用全局变量,一不小心,你就在两个进程内使用了「同一个变量」的属于两个进程的副本。不要给自己制造这种混淆的机会。
  7. 如何解除 A<--->B 这种耦合呢?虽然我是在JavaScript里写代码,我还是会思考什么时候使用「接口」,什么时候使用「函数」来解除耦合的问题。许年年来,基于面向对象的设计模式,都在告诉你要面向接口来解除耦合,真的是这样的吗?

很久以来,我都已经 忘记了要写一个接口了,因为动态语言里并不需要什么直接的接口。我认真思考了下,如果一个类确实有可能含有多种不同的相似的子类型,这个时候继承是很自然的,例如,B1,B2,B3继承B。此时AB的依赖,B可以是一个抽象类,也可以就是一个接口IB,这没有什么区别。反之,B也可以对IA依赖。由此设计模式一个系列基本上就是在说这件事。

但是,我可以不用接口实现解除耦合么?合理设计回调函数就可以做到。例如:

B.xxxxx(params, onXXXX, onYYYY)

只要B的函数参数里定义好合适的回调函数,那么我并不需要B内部调用任何A的方法,A如果要把自己逻辑混进Bxxxxx方法的逻辑里,只要使用B的时候,处理这些回调就可以:

b.xxxxx(params,(...)=>{
    这里加入A的逻辑
},(...)=>{
    这里加入A的逻辑
});

这个时候,B如果要做到通用,就是尽量设计好合适的参数和回调。

进一步,你可能会在A的内部使用B。这样B虽然解除了对A的依赖,但是AB的依赖还是在,那么,应该怎样进一步解除这种耦合呢?一种抽象方法如是有效的,那就反复使用它:

A.yyyyy(params, onXXXX, onYYYY);

这个时候,把A的逻辑和B的逻辑绑定在一起就是更外层的「责任」,AB负责「提供机制」,外层,例如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在调用地方的代码「一眼就看出来AB之间如何协同工作完成任务」,这点是我考虑很多代码应该写在哪里的关键。

那就是,一个函数应该是:

run(); // 内部完成了神秘的任务

还是应该是:

if(a.init()){
   a.xxxx();
   a.zzzz();
};

更好呢?我认为,至少应该在xxxx函数的上一层调用地方,在那个粒度提供直观的这个「程序在干什么」的直观逻辑。

我认为接口的解藕,在于有同一个接口有多个不同的场景,但是相似子类的时候。而如果不是,那么「高阶函数」的组合就是更好的选择。这个更好是类似「如无必要,务增实体」这类的思想,或者说「奥姆卡剃刀」原理。

以上就是重构的几点感受,在重构项目中,也有助于我们理解构架是什么,因为为了让项目达到「良构」,我们必须理解很多「为什么」。

--end--

Copyright 2022 版权所有 软件发布 访问手机版

声明:所有软件和文章来自软件开发商或者作者 如有异议 请与本站联系 联系我们