应对变化

之前对SOLID做了一个总结 《SOLID》总结

这些原则是前辈们经过无数实践提炼出来的,百炼成刚,那是不是成了放之四海皆准的道理呢?某种程度上讲,还真就是准的,常被人耳提面命写的代码要遵守这些原则,想想code review时,是不是代码常常对比这些原则,被人指出没有遵循哪个原则

总结篇中画了这幅图,SOLID也的确是我们达到高内聚低耦合很重要的手段

1
2
3
4
5
6
7
8
9
10
//读取配置文件和计算
class Computer{
public int add() throws NumberFormatException, IOException {
File file = new File("D:/data.txt");
BufferedReader br = new BufferedReader(new FileReader(file));
int a = Integer.valueOf(br.readLine());
int b = Integer.valueOf(br.readLine());
return a+b;
}
}

这是一段被用来演示SRP的反例,从示例代码中看出,这个方法职责的确不单一,引起它变化的至少有两个地方:一是数据不一定从配置文件读取、二是计算方式可能会变

在code review时,不管是自己还是别人,的确让人觉得不够完美

因此,我们会花一番功夫,来让方法达到SOLID的要求,可如果此方法从系统上线运行几个月,甚至几年都无需变动,那我们花费的这些时间也只是自我感动,毕竟我们最终目标是给客户交付带来价值的系统,以最快的速度给公司带来效益

这其实是成本的问题,没错,程序员要有技术追求,但也得考虑成本

可总不能为了成本,忽略一切吧,那怎么处理呢,我们要达到“高内聚、低耦合”,SOLID是重要路径,但又不能不计成本地进行SOLID,更不能为了SOLID而SOLID

所以不能走两个极端,既不能坐视不管,也不能一味求全,在这两层之间应该还有一片灰色地带


这灰色地带是什么样的?怎么做才能不去穷举变化疲惫应对,而当真正变化来临时又能轻松应对?大佬提供了思路,不能以这些原则去应对软件变化,应该以无法为有法,以无限为有限。以实际需求变化来帮助我们识别变化,管理变化。这思路就是袁英杰提出的正交设计,有四大策略

四大策略

策略一:消除重复

重复代码,不管接手老项目还是住持新项目,都特别重视重复代码的比率,为什么呢?

  1. 重复代码增加维护成本,变动同一个逻辑会遗漏修改
  2. 重复代码说明团队沟通不畅,团员间没有交流或者没有必要的code review

这只是实践带来的观察,那么从理论角度说说消除重复的重要性

“重复”极度违背高内聚、低耦合原则,从而会大幅提升软件的长期维护成本;而我们所求的高内聚是指关联紧密的事物放在一起,两段完全相同的代码关联最为紧密,重复就意味着低内聚

更糟糕的是,本质重复的代码,都在表达同一项知识。如果他们表达(即依赖)的知识发生了变化,这些重复代码都需要修改,因而重复代码也意味着高耦合

重复意味着耦合

当完全重复的代码进行消除,会让系统更加高内聚、低耦合

小到代码块,大到模块也一样,如果两个模块之间部分重复,那么这两个模块都至少存在两个变化原因或两重职责;站在系统的角度看,它们之间有重复部分,也有差异部分,那这两个模块都存在两个变化原因

对于这一类型重复,比较典型的情况有两种:实现重复和客户重复

实现型重复

客户型重复

这个策略非常明确,极具操作性,消除重复后,明显提高系统内聚性,降低耦合性,在消除重复过程中,也提高了系统的可重用性,而且对于客户重复,还提高了扩展性

策略二:分离不同的变化方向

对于策略一使用时机,可以随时进行,重复也容易判定。除重复代码外,另一个驱动系统朝向高内聚方向演进的信号是:我们经常需要因为同一类原因,修改某个模块。而这个模块的其它部分却保持不变

分离不同变化方向,目标在于提高内聚度。因为多个变化方向,意味着一个模块存在多重职责。将不同的变化方向进行分离,也意味着各个变化职责的单一化

分离变化方向

对于变化方向分离,也得到了另外一个我们追求的目标:可扩展性

策略二相对策略一,最重要的就是时机,不然就会回到我们文章开头时的窘境:早了,过度设计;晚了,则被再次愚弄

当你发现需求导致一个变化方向出现时,将其从原有的设计中分离出去。此时时机刚刚好,不早不晚;Uncle Bob也曾给出答案:被第一颗子弹击中时,也就是当第一个变化方向出现时

这个世界里,本质上只存在三个数字:0,1,和N。

0意味着当一个需求还没有出现时,我们就不应该在系统中编写一行针对它的代码。

1意味着某种需求已经出现,我们只需要使用最简单的手段来实现它,无需考虑任何变化。

N则意味着,需求开始在某个方向开始变化,其次数可能是2,3,…N。但不管最终的次数是多少,你总应该在由1变为2时就需要解决此方向的变化。随后,无论最终N为何值,你都可以稳坐钓鱼台,通过一个个扩展来满足需求

如果我们足够细心,会发现策略消除重复和分离不同变化方向是两个高度相似和关联的策略:

它们都是关注于如何对原有模块进行拆分,以提高系统的内聚性。(虽然同时也往往伴随着耦合度的降低,但这些耦合度的降低都发生在别处,并未触及该如何定义API以降低客户与API之间耦合度)。

另外,如果两个模块有部分代码是重复的,往往意味着不同变化方向。

尽管如此,我们依然需要两个不同的策略。这是因为:变化方向,并不总是以重复代码的形式出现的(其典型症状是散弹式修改,或者if-else、switch-case、模式匹配);尽管其背后往往存在一个以重复代码形式表现的等价形式(这也是为何copy-paste-modify如此流行的原因)。

策略三:缩小依赖范围

前面两个策略解决了软件单元如何划分问题,现在需要关注合的问题:模块之间的粘合点API的定义

  • 首先,客户和实现模块的数量,会对耦合度产生重大的影响。它们数量越多,意味着 API 变更的成本越高,越需要花更大的精力来仔细斟酌。
  • 其次,对于影响面大的API(也意味着耦合度高),需要使用更加弹性的API定义框架,以有利于向前兼容性。

因此缩小依赖范围,就是要精简API

  1. API包含尽可能小的知识。因为任何一项知识的变化都会导致双方变化
  2. API也要高内聚,不应强迫API的客户依赖不需要的东西

策略四:向稳定的方向依赖

虽然缩小依赖范围,但终究还是要有依赖范围,还是必然存在耦合点。降低耦合度已到尽头。

耦合最大的问题在于:耦合点的变化,会导致依赖方跟着变化。这儿意味着如果耦合点不变,那依赖方也不会变化。换句话说,耦合点越稳定,依赖方受耦合变化影响的概率就越低

因此得出第四个策略:向稳定的方向依赖

耦合点也就是API,什么样的API更侧向于稳定?站在What,而不是 How 的角度;即站在需求的角度,而不是实现方式的角度定义API;也就是站在客户的角度,思考用户的本质需要,由此来定义API,而不是站在技术实现的方便程度角度来思考API定义

SOLID

一个好的面向对象设计,自然是符合高内聚,低耦合原则的对象划分和协作方式。

单一职责和开放封闭,更多的在强调类划分时的高内聚;而里氏替换,依赖倒置,接口隔离则更多的强调类与类之间协作接口(即API)定义的低耦合

单一职责,通过对变化原因的识别,将一个承担多重职责的类,不断分割为更小的,只具备单一变化原因的类。而单一变化原因指的是:一个变化,会引起整个类都发生变化。只有关联极其紧密的情况,才会导致这样的局面。因而,单一职责和高内聚某种程度是同义词。

但单一职责原则本身,并没有明确指示我们该如何判定一个类属于单一职责的,以及如何达到单一职责的状态。而策略消除重复,分离不同变化方向,正是让类达到单一职责的策略与途径

开放封闭原则,正是通过将不同变化方向进行分离,从而达到对于已经出现的变化方向,对于修改是封闭的,对于扩展是开放的

里氏替换原则强调的是,一个子类不应该破坏其父类与客户之间的契约。唯有如此,才能保证:客户与其父类所暴露的接口(即API)所产生的依赖关系是稳定的。子类只应该成为隐藏在API背后的某种具体实现方式。

依赖倒置原则则强调:为了让依赖关系是稳定的,不应该由实现侧根据自己的技术实现方式定义接口,然后强迫上层(即客户)依赖这种不稳定的API定义,而是应该站在上层(即客户)的角度去定义API(正所谓依赖倒置)

但是,虽然接口由上层定义,但最终接口的实现却依然由下层完成,因此依赖倒置描述为:上层不依赖下层,下层也不依赖上层,双方共同依赖于抽象。

最后,接口隔离原则强调的是:不应该强迫客户依赖它不需要的东西。显然,这是缩小依赖范围策略在面向对象范式下的产物

总结

尽管理论上讲,任意复杂的系统都可以被放入同一个函数里。但随着软件越来复杂,即便是智商最为发达的程序员也发现,单一过程的复杂度已经超出他的掌控极限。这逼迫人们必须对大问题进行分解,分而治之,这也是必须模块化的原因

模块化主要是两方面:

  1. 软件模块该如何划分?(怎么分)
  2. 模块间API该如何定义?(怎么合)

本文四个策略,前两个指导怎么高内聚,也就是怎么分;后两个指导耦合方式,怎么合

重要的是使用各个策略的使用时机,变化驱动识别变化、重构变化

变化导致的修改有两类:

  • 一个变化导致多处修改(重复);
  • 多个变化导致一处修改(多个变化方向);

由此得到前两个策略:消除重复;分离不同变化方向。

除此之外,我们要努力消除变化发生时不必要的修改,也有两种方式:

  • 不依赖不必要的依赖;
  • 不依赖不稳定的依赖;

这就是后面两个策略:缩小依赖范围,向着稳定的方向依赖。

Reference

变化驱动:正交设计

公众号:码农戏码
欢迎关注微信公众号『码农戏码』