3.1 状态和副作用
对于大多数系统来说,我们根据系统状态决定系统采用的运作模式,这些状态体现在系统之外,比如高层建筑使用速度较快的电梯,而低层公寓可以选用成本较低、运行较慢的电梯。
在系统内部,我们用明确的局部属性、状态值和调用全局变量来记录当前系统的状态。电梯里有几个人、按了哪几层按钮,通信场景里网线开通到哪个路由节点,这些都直接影响系统接下来的行为。甚至系统的外观(贴图、广告页)也会影响用户的判断和行为,如图3-1所示。
图3-1 电梯的广告页影响用户的判断和行为
在编码语言层面,我们使用条件控制语句(if/while)描述在某些状态下程序执行的次序和跳转方向。我们还会用for循环语句检查所有元素是否满足某些条件,进而选择做映射处理还是break操作。如果把代码块看作一段过程(一个模块),执行语句会产生输出结果以外的副作用(条件控制语句一般没有标准的输出结果,所以产生副作用就是运行这段代码的意义),进而影响模块以外的系统状态。否则运行这段代码只是一个占用资源(也是对资源产生的副作用)、无意义的过程。
状态和副作用如同流程树中每段流程伸出了枝丫,理想情况是流程树组成了一小片森林,而实际上很可能是一片荆棘。这些流程树需要有很好的聚合度,每个模块需要能灵活处理因外部状态变化导致的内容改变。当外部状态变化不可控、模块执行的时序对模块产生了影响时,整理起来会很麻烦,图3-2所示是凌乱的状态管理。
图3-2 凌乱的状态管理
良好的设计可以帮助开发者避开系统中各状态的高耦合。从全局角度来说,开发者可以借助一些额外的分层,如适配器、中间者、代理、外观包装等模式,限制因内部状态变化产生的交汇影响,减少对外耦合。从每个细粒度的流程上看,我们希望系统是一个高度封装的纯结构体,便于后期调试、替换,并且稳定运行,进而确保系统的可维护性和扩展性。
在函数式思维中也要处理状态和副作用,没有状态和副作用的程序是无意义的。函数式的一般处理方式是基于要发生的“过程”,偏向于把状态处理集中在过程的一端,尽量理想化地将其处理成过程的输入参数,将副作用集中在过程的另一端作为输出结果。
还有一种处理方式是把一些对外部的依赖写在模块中,作为一种可被封装的不确定因素,比如对父类的Super调用、对this中信息的调用等。面向对象语言常常封装这些不确定因素,而函数式则倾向于在外部暴露它们,从而减少内部的不确定性。
这些形式都不是绝对的。在实际编码时,前端框架如Flux、React Hooks的一些写法也可能把影响外部再次调用的内容重新拉入模块(组件)内部。这些做法最终还是会展开成较为纯粹的函数调用链路,但写法上更符合开发者的编写习惯。
函数式语言的设计出发点偏向于研究怎么把“过程”进行组合、拼装和复用,这个组合、拼装的过程最好没有外部状态参与。大型项目需要对这类“过程”(需要集中管理的命令)进行一些批量的业务操作和必要的抽象封装,下面我们继续讨论这些业务过程的抽象。